@rebasepro/server-core 0.0.1-canary.eae7889 → 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.
Files changed (132) hide show
  1. package/app/frontend/node_modules/esbuild/LICENSE.md +21 -0
  2. package/app/frontend/node_modules/esbuild/README.md +3 -0
  3. package/app/frontend/node_modules/esbuild/bin/esbuild +220 -0
  4. package/app/frontend/node_modules/esbuild/install.js +285 -0
  5. package/app/frontend/node_modules/esbuild/lib/main.d.ts +705 -0
  6. package/app/frontend/node_modules/esbuild/lib/main.js +2239 -0
  7. package/app/frontend/node_modules/esbuild/package.json +46 -0
  8. package/dist/index.es.js +1186 -1673
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/index.umd.js +1185 -1672
  11. package/dist/index.umd.js.map +1 -1
  12. package/dist/server-core/src/api/rest/api-generator.d.ts +15 -3
  13. package/dist/server-core/src/auth/admin-routes.d.ts +5 -0
  14. package/dist/server-core/src/auth/google-oauth.d.ts +36 -3
  15. package/dist/server-core/src/auth/index.d.ts +1 -0
  16. package/dist/server-core/src/cron/cron-scheduler.d.ts +45 -0
  17. package/dist/server-core/src/cron/index.d.ts +1 -1
  18. package/dist/server-core/src/init.d.ts +11 -1
  19. package/dist/types/src/controllers/auth.d.ts +8 -2
  20. package/dist/types/src/controllers/client.d.ts +13 -0
  21. package/dist/types/src/controllers/collection_registry.d.ts +2 -1
  22. package/dist/types/src/controllers/data_driver.d.ts +36 -1
  23. package/dist/types/src/controllers/navigation.d.ts +18 -6
  24. package/dist/types/src/controllers/registry.d.ts +9 -1
  25. package/dist/types/src/controllers/side_entity_controller.d.ts +7 -0
  26. package/dist/types/src/rebase_context.d.ts +17 -0
  27. package/dist/types/src/types/backend_hooks.d.ts +187 -0
  28. package/dist/types/src/types/collections.d.ts +31 -11
  29. package/dist/types/src/types/component_ref.d.ts +47 -0
  30. package/dist/types/src/types/cron.d.ts +1 -1
  31. package/dist/types/src/types/entity_views.d.ts +6 -7
  32. package/dist/types/src/types/formex.d.ts +40 -0
  33. package/dist/types/src/types/index.d.ts +3 -0
  34. package/dist/types/src/types/plugins.d.ts +6 -3
  35. package/dist/types/src/types/properties.d.ts +72 -88
  36. package/dist/types/src/types/slots.d.ts +20 -10
  37. package/dist/types/src/types/translations.d.ts +6 -0
  38. package/examples/firebase/node_modules/esbuild/LICENSE.md +21 -0
  39. package/examples/firebase/node_modules/esbuild/README.md +3 -0
  40. package/examples/firebase/node_modules/esbuild/bin/esbuild +220 -0
  41. package/examples/firebase/node_modules/esbuild/install.js +285 -0
  42. package/examples/firebase/node_modules/esbuild/lib/main.d.ts +705 -0
  43. package/examples/firebase/node_modules/esbuild/lib/main.js +2239 -0
  44. package/examples/firebase/node_modules/esbuild/package.json +46 -0
  45. package/examples/medmot-staging/frontend/node_modules/esbuild/LICENSE.md +21 -0
  46. package/examples/medmot-staging/frontend/node_modules/esbuild/README.md +3 -0
  47. package/examples/medmot-staging/frontend/node_modules/esbuild/bin/esbuild +220 -0
  48. package/examples/medmot-staging/frontend/node_modules/esbuild/install.js +285 -0
  49. package/examples/medmot-staging/frontend/node_modules/esbuild/lib/main.d.ts +705 -0
  50. package/examples/medmot-staging/frontend/node_modules/esbuild/lib/main.js +2239 -0
  51. package/examples/medmot-staging/frontend/node_modules/esbuild/package.json +46 -0
  52. package/examples/sdk-demo/node_modules/esbuild/LICENSE.md +21 -0
  53. package/examples/sdk-demo/node_modules/esbuild/README.md +3 -0
  54. package/examples/sdk-demo/node_modules/esbuild/bin/esbuild +223 -0
  55. package/examples/sdk-demo/node_modules/esbuild/install.js +289 -0
  56. package/examples/sdk-demo/node_modules/esbuild/lib/main.d.ts +716 -0
  57. package/examples/sdk-demo/node_modules/esbuild/lib/main.js +2242 -0
  58. package/examples/sdk-demo/node_modules/esbuild/package.json +49 -0
  59. package/package.json +9 -9
  60. package/packages/client/node_modules/esbuild/LICENSE.md +21 -0
  61. package/packages/client/node_modules/esbuild/README.md +3 -0
  62. package/packages/client/node_modules/esbuild/bin/esbuild +220 -0
  63. package/packages/client/node_modules/esbuild/install.js +285 -0
  64. package/packages/client/node_modules/esbuild/lib/main.d.ts +705 -0
  65. package/packages/client/node_modules/esbuild/lib/main.js +2239 -0
  66. package/packages/client/node_modules/esbuild/package.json +46 -0
  67. package/packages/client-postgresql/node_modules/esbuild/LICENSE.md +21 -0
  68. package/packages/client-postgresql/node_modules/esbuild/README.md +3 -0
  69. package/packages/client-postgresql/node_modules/esbuild/bin/esbuild +220 -0
  70. package/packages/client-postgresql/node_modules/esbuild/install.js +285 -0
  71. package/packages/client-postgresql/node_modules/esbuild/lib/main.d.ts +705 -0
  72. package/packages/client-postgresql/node_modules/esbuild/lib/main.js +2239 -0
  73. package/packages/client-postgresql/node_modules/esbuild/package.json +46 -0
  74. package/packages/common/node_modules/esbuild/LICENSE.md +21 -0
  75. package/packages/common/node_modules/esbuild/README.md +3 -0
  76. package/packages/common/node_modules/esbuild/bin/esbuild +220 -0
  77. package/packages/common/node_modules/esbuild/install.js +285 -0
  78. package/packages/common/node_modules/esbuild/lib/main.d.ts +705 -0
  79. package/packages/common/node_modules/esbuild/lib/main.js +2239 -0
  80. package/packages/common/node_modules/esbuild/package.json +46 -0
  81. package/packages/server-mongodb/node_modules/esbuild/LICENSE.md +21 -0
  82. package/packages/server-mongodb/node_modules/esbuild/README.md +3 -0
  83. package/packages/server-mongodb/node_modules/esbuild/bin/esbuild +220 -0
  84. package/packages/server-mongodb/node_modules/esbuild/install.js +285 -0
  85. package/packages/server-mongodb/node_modules/esbuild/lib/main.d.ts +705 -0
  86. package/packages/server-mongodb/node_modules/esbuild/lib/main.js +2239 -0
  87. package/packages/server-mongodb/node_modules/esbuild/package.json +46 -0
  88. package/packages/server-postgresql/node_modules/esbuild/LICENSE.md +21 -0
  89. package/packages/server-postgresql/node_modules/esbuild/README.md +3 -0
  90. package/packages/server-postgresql/node_modules/esbuild/bin/esbuild +220 -0
  91. package/packages/server-postgresql/node_modules/esbuild/install.js +285 -0
  92. package/packages/server-postgresql/node_modules/esbuild/lib/main.d.ts +705 -0
  93. package/packages/server-postgresql/node_modules/esbuild/lib/main.js +2239 -0
  94. package/packages/server-postgresql/node_modules/esbuild/package.json +46 -0
  95. package/packages/types/node_modules/esbuild/LICENSE.md +21 -0
  96. package/packages/types/node_modules/esbuild/README.md +3 -0
  97. package/packages/types/node_modules/esbuild/bin/esbuild +220 -0
  98. package/packages/types/node_modules/esbuild/install.js +285 -0
  99. package/packages/types/node_modules/esbuild/lib/main.d.ts +705 -0
  100. package/packages/types/node_modules/esbuild/lib/main.js +2239 -0
  101. package/packages/types/node_modules/esbuild/package.json +46 -0
  102. package/packages/utils/node_modules/esbuild/LICENSE.md +21 -0
  103. package/packages/utils/node_modules/esbuild/README.md +3 -0
  104. package/packages/utils/node_modules/esbuild/bin/esbuild +220 -0
  105. package/packages/utils/node_modules/esbuild/install.js +285 -0
  106. package/packages/utils/node_modules/esbuild/lib/main.d.ts +705 -0
  107. package/packages/utils/node_modules/esbuild/lib/main.js +2239 -0
  108. package/packages/utils/node_modules/esbuild/package.json +46 -0
  109. package/src/api/errors.ts +3 -2
  110. package/src/api/rest/api-generator-count.test.ts +113 -0
  111. package/src/api/rest/api-generator.ts +123 -22
  112. package/src/api/server.ts +8 -4
  113. package/src/auth/admin-routes.ts +133 -57
  114. package/src/auth/apple-oauth.ts +8 -18
  115. package/src/auth/google-oauth.ts +192 -22
  116. package/src/auth/index.ts +1 -0
  117. package/src/auth/rate-limiter.ts +9 -5
  118. package/src/auth/routes.ts +25 -5
  119. package/src/collections/loader.ts +3 -3
  120. package/src/cron/cron-scheduler.test.ts +301 -175
  121. package/src/cron/cron-scheduler.ts +220 -57
  122. package/src/cron/index.ts +1 -1
  123. package/src/init.ts +27 -5
  124. package/src/storage/LocalStorageController.ts +37 -13
  125. package/src/storage/S3StorageController.ts +4 -1
  126. package/src/storage/routes.ts +51 -5
  127. package/test/backend-hooks-admin.test.ts +394 -0
  128. package/test/backend-hooks-data.test.ts +408 -0
  129. package/history_diff.log +0 -385
  130. package/scratch.ts +0 -9
  131. package/test-ast.ts +0 -28
  132. package/test_output.txt +0 -1133
@@ -4,6 +4,7 @@ import type { AuthRepository } from "./interfaces";
4
4
  import { requireAuth, requireAdmin, createRequireAuth } from "./middleware";
5
5
  import { hashPassword, validatePasswordStrength } from "./password";
6
6
  import { AuthModuleConfig } from "./routes";
7
+ import type { BackendHooks, AdminUser, AdminRole, BackendHookContext } from "@rebasepro/types";
7
8
 
8
9
  interface AdminRouteOptions extends AuthModuleConfig {
9
10
  serviceKey?: string;
@@ -12,6 +13,10 @@ interface AdminRouteOptions extends AuthModuleConfig {
12
13
  * Invoked after the first admin user is promoted via POST /admin/bootstrap.
13
14
  */
14
15
  setBootstrapCompleted?: () => Promise<void>;
16
+ /**
17
+ * Backend-level hooks for intercepting admin data.
18
+ */
19
+ hooks?: BackendHooks;
15
20
  }
16
21
  import { HonoEnv } from "../api/types";
17
22
  import { randomBytes, createHash } from "crypto";
@@ -63,7 +68,50 @@ function hashToken(token: string): string {
63
68
  export function createAdminRoutes(config: AdminRouteOptions): Hono<HonoEnv> {
64
69
  const router = new Hono<HonoEnv>();
65
70
  const authRepo = config.authRepo;
66
- const { emailService, emailConfig } = config;
71
+ const { emailService, emailConfig, hooks } = config;
72
+
73
+ /** Build a BackendHookContext from Hono's context object */
74
+ function buildHookContext(c: { get: (key: string) => unknown }, method: BackendHookContext["method"]): BackendHookContext {
75
+ const user = c.get("user") as { userId: string; roles?: string[] } | undefined;
76
+ return {
77
+ requestUser: user ? { userId: user.userId, roles: user.roles ?? [] } : undefined,
78
+ method
79
+ };
80
+ }
81
+
82
+ /** Apply users.afterRead hook to an AdminUser, returning null to filter out */
83
+ async function applyUserAfterRead(user: AdminUser, ctx: BackendHookContext): Promise<AdminUser | null> {
84
+ if (!hooks?.users?.afterRead) return user;
85
+ return hooks.users.afterRead(user, ctx);
86
+ }
87
+
88
+ /** Apply users.afterRead hook to an array and filter nulls */
89
+ async function applyUserAfterReadBatch(users: AdminUser[], ctx: BackendHookContext): Promise<AdminUser[]> {
90
+ if (!hooks?.users?.afterRead) return users;
91
+ const results = await Promise.all(users.map(u => applyUserAfterRead(u, ctx)));
92
+ return results.filter((u): u is AdminUser => u !== null);
93
+ }
94
+
95
+ /** Apply roles.afterRead hook to an array and filter nulls */
96
+ async function applyRoleAfterReadBatch(roles: AdminRole[], ctx: BackendHookContext): Promise<AdminRole[]> {
97
+ if (!hooks?.roles?.afterRead) return roles;
98
+ const results = await Promise.all(roles.map(r => hooks!.roles!.afterRead!(r, ctx)));
99
+ return results.filter((r): r is AdminRole => r !== null);
100
+ }
101
+
102
+ /** Convert a DB user record + role IDs into the AdminUser API shape */
103
+ function toAdminUser(u: { id: string; email: string; displayName?: string | null; photoUrl?: string | null; createdAt?: Date | string; updatedAt?: Date | string }, roles: string[]): AdminUser {
104
+ return {
105
+ uid: u.id,
106
+ email: u.email,
107
+ displayName: u.displayName ?? null,
108
+ photoURL: u.photoUrl ?? null,
109
+ provider: "custom",
110
+ roles,
111
+ createdAt: u.createdAt instanceof Date ? u.createdAt.toISOString() : (u.createdAt ?? new Date().toISOString()),
112
+ updatedAt: u.updatedAt instanceof Date ? u.updatedAt.toISOString() : (u.updatedAt ?? new Date().toISOString())
113
+ };
114
+ }
67
115
 
68
116
  // Attach Rebase error handler to ensure exceptions are correctly formatted
69
117
  // instead of caught by Hono's default error handler from the sub-router.
@@ -142,6 +190,7 @@ export function createAdminRoutes(config: AdminRouteOptions): Hono<HonoEnv> {
142
190
  const search = c.req.query("search");
143
191
  const orderBy = c.req.query("orderBy");
144
192
  const orderDir = c.req.query("orderDir") as "asc" | "desc" | undefined;
193
+ const hookCtx = buildHookContext(c, "GET");
145
194
 
146
195
  // If pagination params are provided, use the paginated path
147
196
  if (limitParam !== undefined || search) {
@@ -157,21 +206,15 @@ export function createAdminRoutes(config: AdminRouteOptions): Hono<HonoEnv> {
157
206
  roleId: c.req.query("role") || undefined
158
207
  });
159
208
 
160
- const usersWithRoles = await Promise.all(
209
+ let usersWithRoles: AdminUser[] = await Promise.all(
161
210
  result.users.map(async (u) => {
162
211
  const roles = await authRepo.getUserRoleIds(u.id);
163
- return {
164
- uid: u.id,
165
- email: u.email,
166
- displayName: u.displayName,
167
- photoURL: u.photoUrl,
168
- roles,
169
- createdAt: u.createdAt,
170
- updatedAt: u.updatedAt
171
- };
212
+ return toAdminUser(u, roles);
172
213
  })
173
214
  );
174
215
 
216
+ usersWithRoles = await applyUserAfterReadBatch(usersWithRoles, hookCtx);
217
+
175
218
  return c.json({
176
219
  users: usersWithRoles,
177
220
  total: result.total,
@@ -182,20 +225,15 @@ export function createAdminRoutes(config: AdminRouteOptions): Hono<HonoEnv> {
182
225
 
183
226
  // Legacy: return all users (no pagination)
184
227
  const users = await authRepo.listUsers();
185
- const usersWithRoles = await Promise.all(
228
+ let usersWithRoles: AdminUser[] = await Promise.all(
186
229
  users.map(async (u) => {
187
230
  const roles = await authRepo.getUserRoleIds(u.id);
188
- return {
189
- uid: u.id,
190
- email: u.email,
191
- displayName: u.displayName,
192
- photoURL: u.photoUrl,
193
- roles,
194
- createdAt: u.createdAt,
195
- updatedAt: u.updatedAt
196
- };
231
+ return toAdminUser(u, roles);
197
232
  })
198
233
  );
234
+
235
+ usersWithRoles = await applyUserAfterReadBatch(usersWithRoles, hookCtx);
236
+
199
237
  return c.json({ users: usersWithRoles });
200
238
  });
201
239
 
@@ -207,27 +245,34 @@ export function createAdminRoutes(config: AdminRouteOptions): Hono<HonoEnv> {
207
245
  throw ApiError.notFound("User not found");
208
246
  }
209
247
 
210
- return c.json({
211
- user: {
212
- uid: result.user.id,
213
- email: result.user.email,
214
- displayName: result.user.displayName,
215
- photoURL: result.user.photoUrl,
216
- roles: result.roles.map(r => r.id),
217
- createdAt: result.user.createdAt,
218
- updatedAt: result.user.updatedAt
219
- }
220
- });
248
+ const hookCtx = buildHookContext(c, "GET");
249
+ let adminUser: AdminUser | null = toAdminUser(result.user, result.roles.map(r => r.id));
250
+
251
+ adminUser = await applyUserAfterRead(adminUser, hookCtx);
252
+ if (!adminUser) {
253
+ throw ApiError.notFound("User not found");
254
+ }
255
+
256
+ return c.json({ user: adminUser });
221
257
  });
222
258
 
223
259
  router.post("/users", requireAdmin, async (c) => {
224
260
  const body = await c.req.json();
225
- const { email, displayName, password, roles } = body;
261
+ let { email, displayName, password, roles } = body;
226
262
 
227
263
  if (!email) {
228
264
  throw ApiError.badRequest("Email is required", "INVALID_INPUT");
229
265
  }
230
266
 
267
+ // Apply beforeSave hook
268
+ const hookCtx = buildHookContext(c, "POST");
269
+ if (hooks?.users?.beforeSave) {
270
+ const hooked = await hooks.users.beforeSave({ email, displayName, roles }, hookCtx);
271
+ email = hooked.email ?? email;
272
+ displayName = hooked.displayName ?? displayName;
273
+ roles = hooked.roles ?? roles;
274
+ }
275
+
231
276
  const existing = await authRepo.getUserByEmail(email);
232
277
  if (existing) {
233
278
  throw ApiError.conflict("Email already exists", "EMAIL_EXISTS");
@@ -299,13 +344,17 @@ displayName: user.displayName }, appName);
299
344
  }
300
345
  // If admin provided a password explicitly, don't return it or send email
301
346
 
347
+ const createdAdminUser: AdminUser = toAdminUser(user, userRoles);
348
+
349
+ // Fire afterSave hook (fire-and-forget for side-effects)
350
+ if (hooks?.users?.afterSave) {
351
+ Promise.resolve(hooks.users.afterSave(createdAdminUser, hookCtx)).catch(err => {
352
+ console.error("[BackendHooks] users.afterSave error:", err instanceof Error ? err.message : err);
353
+ });
354
+ }
355
+
302
356
  return c.json({
303
- user: {
304
- uid: user.id,
305
- email: user.email,
306
- displayName: user.displayName,
307
- roles: userRoles
308
- },
357
+ user: createdAdminUser,
309
358
  invitationSent,
310
359
  ...(temporaryPassword ? { temporaryPassword } : {})
311
360
  }, 201);
@@ -381,13 +430,22 @@ displayName: existing.displayName }, appName);
381
430
  router.put("/users/:userId", requireAdmin, async (c) => {
382
431
  const userId = c.req.param("userId");
383
432
  const body = await c.req.json();
384
- const { email, displayName, password, roles } = body;
433
+ let { email, displayName, password, roles } = body;
385
434
 
386
435
  const existing = await authRepo.getUserById(userId);
387
436
  if (!existing) {
388
437
  throw ApiError.notFound("User not found");
389
438
  }
390
439
 
440
+ // Apply beforeSave hook
441
+ const hookCtx = buildHookContext(c, "PUT");
442
+ if (hooks?.users?.beforeSave) {
443
+ const hooked = await hooks.users.beforeSave({ email, displayName, roles }, hookCtx);
444
+ email = hooked.email ?? email;
445
+ displayName = hooked.displayName ?? displayName;
446
+ roles = hooked.roles ?? roles;
447
+ }
448
+
391
449
  const updates: Record<string, unknown> = {};
392
450
  if (email !== undefined) updates.email = email.toLowerCase();
393
451
  if (displayName !== undefined) updates.displayName = displayName;
@@ -410,14 +468,16 @@ displayName: existing.displayName }, appName);
410
468
 
411
469
  const result = await authRepo.getUserWithRoles(userId);
412
470
 
413
- return c.json({
414
- user: {
415
- uid: result!.user.id,
416
- email: result!.user.email,
417
- displayName: result!.user.displayName,
418
- roles: result!.roles.map(r => r.id)
419
- }
420
- });
471
+ const updatedAdminUser: AdminUser = toAdminUser(result!.user, result!.roles.map(r => r.id));
472
+
473
+ // Fire afterSave hook (fire-and-forget)
474
+ if (hooks?.users?.afterSave) {
475
+ Promise.resolve(hooks.users.afterSave(updatedAdminUser, hookCtx)).catch(err => {
476
+ console.error("[BackendHooks] users.afterSave error:", err instanceof Error ? err.message : err);
477
+ });
478
+ }
479
+
480
+ return c.json({ user: updatedAdminUser });
421
481
  });
422
482
 
423
483
  router.delete("/users/:userId", requireAdmin, async (c) => {
@@ -434,23 +494,39 @@ displayName: existing.displayName }, appName);
434
494
  throw ApiError.notFound("User not found");
435
495
  }
436
496
 
497
+ // Apply beforeDelete hook (throw to abort)
498
+ const hookCtx = buildHookContext(c, "DELETE");
499
+ if (hooks?.users?.beforeDelete) {
500
+ await hooks.users.beforeDelete(userId, hookCtx);
501
+ }
502
+
437
503
  await authRepo.deleteUser(userId);
438
504
 
505
+ // Fire afterDelete hook (fire-and-forget)
506
+ if (hooks?.users?.afterDelete) {
507
+ Promise.resolve(hooks.users.afterDelete(userId, hookCtx)).catch(err => {
508
+ console.error("[BackendHooks] users.afterDelete error:", err instanceof Error ? err.message : err);
509
+ });
510
+ }
511
+
439
512
  return c.json({ success: true });
440
513
  });
441
514
 
442
515
  router.get("/roles", requireAdmin, async (c) => {
443
516
  const roles = await authRepo.listRoles();
517
+ const hookCtx = buildHookContext(c, "GET");
444
518
 
445
- return c.json({
446
- roles: roles.map(r => ({
447
- id: r.id,
448
- name: r.name,
449
- isAdmin: r.isAdmin,
450
- defaultPermissions: r.defaultPermissions,
451
- config: r.config
452
- }))
453
- });
519
+ let adminRoles: AdminRole[] = roles.map(r => ({
520
+ id: r.id,
521
+ name: r.name,
522
+ isAdmin: r.isAdmin,
523
+ defaultPermissions: r.defaultPermissions,
524
+ config: r.config
525
+ }));
526
+
527
+ adminRoles = await applyRoleAfterReadBatch(adminRoles, hookCtx);
528
+
529
+ return c.json({ roles: adminRoles });
454
530
  });
455
531
 
456
532
  router.get("/roles/:roleId", requireAdmin, async (c) => {
@@ -1,8 +1,6 @@
1
1
  import type { OAuthProvider, OAuthProviderProfile } from "./interfaces";
2
2
  import { z } from "zod";
3
- import { createPrivateKey } from "crypto";
4
- import { SignJWT } from "jose";
5
-
3
+ import jwt from "jsonwebtoken";
6
4
  /**
7
5
  * Creates an Apple Sign In OAuth Provider integration.
8
6
  *
@@ -31,22 +29,14 @@ export function createAppleProvider(config: {
31
29
  * Apple requires this instead of a static client_secret.
32
30
  */
33
31
  async function generateClientSecret(): Promise<string> {
34
- const key = createPrivateKey({
35
- key: config.privateKey,
36
- format: "pem"
32
+ return jwt.sign({}, config.privateKey, {
33
+ algorithm: "ES256",
34
+ keyid: config.keyId,
35
+ issuer: config.teamId,
36
+ expiresIn: "180d",
37
+ audience: "https://appleid.apple.com",
38
+ subject: config.clientId
37
39
  });
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
40
  }
51
41
 
52
42
  return {
@@ -10,38 +10,208 @@ export interface GoogleUserInfo {
10
10
  emailVerified: boolean;
11
11
  }
12
12
 
13
+ export interface GoogleProviderConfig {
14
+ clientId: string;
15
+ /**
16
+ * The OAuth 2.0 client secret from Google Cloud Console.
17
+ *
18
+ * Required for the **authorization code flow** (Path 3), where the
19
+ * frontend sends an authorization `code` and the backend exchanges it
20
+ * server-side for tokens. This is the most secure flow because tokens
21
+ * never touch the browser.
22
+ *
23
+ * When omitted, only ID-token and access-token verification are available
24
+ * (Paths 1 & 2), which rely on the frontend obtaining tokens directly.
25
+ */
26
+ clientSecret?: string;
27
+ }
28
+
13
29
  /**
14
- * Creates a Google OAuth Provider integration
30
+ * Creates a Google OAuth Provider integration.
31
+ *
32
+ * Supports three verification paths:
33
+ *
34
+ * **Path 1 – ID Token** (One Tap / Sign In With Google button):
35
+ * Frontend sends `idToken`. Backend verifies cryptographically using
36
+ * Google's public keys. No secret required.
37
+ *
38
+ * **Path 2 – Access Token** (popup via `initTokenClient`):
39
+ * Frontend sends `accessToken`. Backend validates by calling Google's
40
+ * userinfo endpoint. No secret required.
41
+ *
42
+ * **Path 3 – Authorization Code** (most secure, requires `clientSecret`):
43
+ * Frontend sends `code` + `redirectUri`. Backend exchanges the code
44
+ * server-side for an ID token using `clientId` + `clientSecret`, then
45
+ * verifies the ID token. Tokens never touch the browser.
15
46
  */
16
- export function createGoogleProvider(clientId: string): OAuthProvider<{ idToken: string }> {
17
- const googleClient = new OAuth2Client(clientId);
47
+ export function createGoogleProvider(config: GoogleProviderConfig | string): OAuthProvider<{
48
+ idToken?: string;
49
+ accessToken?: string;
50
+ code?: string;
51
+ redirectUri?: string;
52
+ }> {
53
+ const clientId = typeof config === "string" ? config : config.clientId;
54
+ const clientSecret = typeof config === "string" ? undefined : config.clientSecret;
55
+ const googleClient = new OAuth2Client(clientId, clientSecret);
18
56
 
19
57
  return {
20
58
  id: "google",
21
59
  schema: z.object({
22
- idToken: z.string().min(1, "ID token is required")
23
- }),
24
- verify: async (payload: { idToken: string }): Promise<OAuthProviderProfile | null> => {
60
+ idToken: z.string().min(1).optional(),
61
+ accessToken: z.string().min(1).optional(),
62
+ code: z.string().min(1).optional(),
63
+ redirectUri: z.string().min(1).optional()
64
+ }).refine(
65
+ (data) => data.idToken || data.accessToken || (data.code && data.redirectUri),
66
+ { message: "One of idToken, accessToken, or code+redirectUri is required" }
67
+ ),
68
+ verify: async (payload: {
69
+ idToken?: string;
70
+ accessToken?: string;
71
+ code?: string;
72
+ redirectUri?: string;
73
+ }): Promise<OAuthProviderProfile | null> => {
25
74
  try {
26
- const ticket = await googleClient.verifyIdToken({
27
- idToken: payload.idToken,
28
- audience: clientId
29
- });
30
-
31
- const content = ticket.getPayload();
32
- if (!content) {
33
- return null;
75
+ // Path 1: verify an ID token (One Tap / renderButton)
76
+ if (payload.idToken) {
77
+ const ticket = await googleClient.verifyIdToken({
78
+ idToken: payload.idToken,
79
+ audience: clientId
80
+ });
81
+
82
+ const content = ticket.getPayload();
83
+ if (!content) {
84
+ throw new Error("Google ID token payload was empty");
85
+ }
86
+
87
+ return {
88
+ providerId: content.sub,
89
+ email: content.email || "",
90
+ displayName: content.name || null,
91
+ photoUrl: content.picture || null
92
+ };
93
+ }
94
+
95
+ // Path 2: verify an access token via Google's userinfo endpoint
96
+ if (payload.accessToken) {
97
+ const res = await fetch(
98
+ "https://www.googleapis.com/oauth2/v3/userinfo",
99
+ { headers: { Authorization: `Bearer ${payload.accessToken}` } }
100
+ );
101
+ if (!res.ok) {
102
+ throw new Error(`Google userinfo request failed with status ${res.status}`);
103
+ }
104
+ const info = await res.json() as {
105
+ sub: string;
106
+ email?: string;
107
+ name?: string;
108
+ picture?: string;
109
+ };
110
+ if (!info.sub || !info.email) {
111
+ throw new Error("Google userinfo response missing sub or email");
112
+ }
113
+ return {
114
+ providerId: info.sub,
115
+ email: info.email,
116
+ displayName: info.name || null,
117
+ photoUrl: info.picture || null
118
+ };
119
+ }
120
+
121
+ // Path 3: authorization code exchange (most secure)
122
+ // The frontend obtained a one-time authorization code via the
123
+ // Google OAuth consent screen. We exchange it server-side for
124
+ // tokens, so the access/id tokens never touch the browser.
125
+ if (payload.code && payload.redirectUri) {
126
+ if (!clientSecret) {
127
+ throw new Error(
128
+ "Google authorization code flow requires clientSecret. " +
129
+ "Configure GOOGLE_CLIENT_SECRET in your environment."
130
+ );
131
+ }
132
+
133
+ // Exchange the authorization code for tokens
134
+ const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
135
+ method: "POST",
136
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
137
+ body: new URLSearchParams({
138
+ code: payload.code,
139
+ client_id: clientId,
140
+ client_secret: clientSecret,
141
+ redirect_uri: payload.redirectUri,
142
+ grant_type: "authorization_code"
143
+ })
144
+ });
145
+
146
+ if (!tokenResponse.ok) {
147
+ const errorBody = await tokenResponse.text();
148
+ throw new Error(`Google token exchange failed (${tokenResponse.status}): ${errorBody}`);
149
+ }
150
+
151
+ const tokenData = await tokenResponse.json() as {
152
+ id_token?: string;
153
+ access_token?: string;
154
+ error?: string;
155
+ error_description?: string;
156
+ };
157
+
158
+ if (tokenData.error) {
159
+ throw new Error(`Google token exchange error: ${tokenData.error} – ${tokenData.error_description || "no details"}`);
160
+ }
161
+
162
+ // Prefer verifying the ID token (cryptographic verification)
163
+ if (tokenData.id_token) {
164
+ const ticket = await googleClient.verifyIdToken({
165
+ idToken: tokenData.id_token,
166
+ audience: clientId
167
+ });
168
+
169
+ const content = ticket.getPayload();
170
+ if (!content) {
171
+ throw new Error("Google ID token payload was empty after code exchange");
172
+ }
173
+
174
+ return {
175
+ providerId: content.sub,
176
+ email: content.email || "",
177
+ displayName: content.name || null,
178
+ photoUrl: content.picture || null
179
+ };
180
+ }
181
+
182
+ // Fallback: use the access token to fetch userinfo
183
+ if (tokenData.access_token) {
184
+ const userInfoRes = await fetch(
185
+ "https://www.googleapis.com/oauth2/v3/userinfo",
186
+ { headers: { Authorization: `Bearer ${tokenData.access_token}` } }
187
+ );
188
+ if (!userInfoRes.ok) {
189
+ throw new Error(`Google userinfo request failed after code exchange (${userInfoRes.status})`);
190
+ }
191
+ const info = await userInfoRes.json() as {
192
+ sub: string;
193
+ email?: string;
194
+ name?: string;
195
+ picture?: string;
196
+ };
197
+ if (!info.sub || !info.email) {
198
+ return null;
199
+ }
200
+ return {
201
+ providerId: info.sub,
202
+ email: info.email,
203
+ displayName: info.name || null,
204
+ photoUrl: info.picture || null
205
+ };
206
+ }
207
+
208
+ throw new Error("Google token exchange returned neither id_token nor access_token");
34
209
  }
35
210
 
36
- return {
37
- providerId: content.sub,
38
- email: content.email || "",
39
- displayName: content.name || null,
40
- photoUrl: content.picture || null
41
- };
211
+ throw new Error("No valid Google credential provided (expected idToken, accessToken, or code+redirectUri)");
42
212
  } catch (error) {
43
- console.error("Failed to verify Google ID token:", error);
44
- return null;
213
+ console.error("Google OAuth verification failed:", error);
214
+ throw error;
45
215
  }
46
216
  }
47
217
  };
package/src/auth/index.ts CHANGED
@@ -9,6 +9,7 @@ export type { PasswordValidationResult } from "./password";
9
9
 
10
10
  // OAuth Providers
11
11
  export { createGoogleProvider } from "./google-oauth";
12
+ export type { GoogleProviderConfig } from "./google-oauth";
12
13
  export { createLinkedinProvider } from "./linkedin-oauth";
13
14
  export { createGitHubProvider } from "./github-oauth";
14
15
  export { createMicrosoftProvider } from "./microsoft-oauth";
@@ -101,11 +101,15 @@ export function createRateLimiter(options: RateLimiterOptions = {}): MiddlewareH
101
101
  * Default key generator: extract client IP from standard headers.
102
102
  */
103
103
  function defaultKeyGenerator(c: Parameters<MiddlewareHandler<HonoEnv>>[0]): string {
104
- return (
105
- c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
106
- c.req.header("x-real-ip") ||
107
- "unknown"
108
- );
104
+ const forwardedFor = c.req.header("x-forwarded-for");
105
+ if (forwardedFor) {
106
+ const ips = forwardedFor.split(",");
107
+ // The leftmost IP can be easily spoofed by the client in the initial request.
108
+ // Reverse proxies append to the right. We take the rightmost IP as the most
109
+ // reliable indicator of the true client IP (the one closest to our server).
110
+ return ips[ips.length - 1].trim();
111
+ }
112
+ return c.req.header("x-real-ip") || "unknown";
109
113
  }
110
114
 
111
115
  /**
@@ -233,8 +233,16 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
233
233
  displayName: displayName || undefined
234
234
  });
235
235
 
236
- // Assign configured default role (never auto-assign admin via registration)
237
- if (config.defaultRole) {
236
+ // Auto-bootstrap: if this is the very first user in the system, promote to admin.
237
+ // This avoids the chicken-and-egg problem where the first user has no permissions
238
+ // and no way to access the bootstrap endpoint from the UI.
239
+ const existingUsers = await authRepo.listUsers();
240
+ const isFirstUser = existingUsers.length === 1 && existingUsers[0].id === user.id;
241
+
242
+ if (isFirstUser) {
243
+ await authRepo.setUserRoles(user.id, ["admin"]);
244
+ } else if (config.defaultRole) {
245
+ // Assign configured default role (never auto-assign admin via registration)
238
246
  await authRepo.assignDefaultRole(user.id, config.defaultRole);
239
247
  }
240
248
 
@@ -289,7 +297,13 @@ displayName: user.displayName });
289
297
  router.post(`/${provider.id}`, defaultAuthLimiter, async (c) => {
290
298
  const payload = parseBody(provider.schema, await c.req.json());
291
299
 
292
- const externalUser = await provider.verify(payload);
300
+ let externalUser;
301
+ try {
302
+ externalUser = await provider.verify(payload);
303
+ } catch (err: unknown) {
304
+ const msg = err instanceof Error ? err.message : String(err);
305
+ throw ApiError.unauthorized(`${provider.id} login failed: ${msg}`, "OAUTH_ERROR");
306
+ }
293
307
  if (!externalUser) {
294
308
  throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
295
309
  }
@@ -320,8 +334,14 @@ displayName: user.displayName });
320
334
 
321
335
  await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
322
336
 
323
- // Assign configured default role (never auto-assign admin via registration)
324
- if (config.defaultRole) {
337
+ // Auto-bootstrap: first user in the system gets admin
338
+ const allUsers = await authRepo.listUsers();
339
+ const isFirstUser = allUsers.length === 1 && allUsers[0].id === user.id;
340
+
341
+ if (isFirstUser) {
342
+ await authRepo.setUserRoles(user.id, ["admin"]);
343
+ } else if (config.defaultRole) {
344
+ // Assign configured default role (never auto-assign admin via registration)
325
345
  await authRepo.assignDefaultRole(user.id, config.defaultRole);
326
346
  }
327
347
 
@@ -26,9 +26,9 @@ export async function loadCollectionsFromDirectory(directory: string): Promise<E
26
26
  try {
27
27
  const fileUrl = pathToFileURL(filePath).href;
28
28
 
29
- // Use new Function to compile dynamic import natively and bypass tsc converting import() to require()
30
- const dynamicImport = new Function("url", "return import(url)");
31
- const module = await dynamicImport(fileUrl);
29
+ // Use standard import() so that tsx/loader hooks can
30
+ // resolve .ts files and workspace bare-specifiers.
31
+ const module = await import(fileUrl);
32
32
 
33
33
  // Expect the collection to be the default export
34
34
  if (module && module.default) {