@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.
- package/app/frontend/node_modules/esbuild/LICENSE.md +21 -0
- package/app/frontend/node_modules/esbuild/README.md +3 -0
- package/app/frontend/node_modules/esbuild/bin/esbuild +220 -0
- package/app/frontend/node_modules/esbuild/install.js +285 -0
- package/app/frontend/node_modules/esbuild/lib/main.d.ts +705 -0
- package/app/frontend/node_modules/esbuild/lib/main.js +2239 -0
- package/app/frontend/node_modules/esbuild/package.json +46 -0
- package/dist/index.es.js +1186 -1673
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +1185 -1672
- package/dist/index.umd.js.map +1 -1
- package/dist/server-core/src/api/rest/api-generator.d.ts +15 -3
- package/dist/server-core/src/auth/admin-routes.d.ts +5 -0
- package/dist/server-core/src/auth/google-oauth.d.ts +36 -3
- package/dist/server-core/src/auth/index.d.ts +1 -0
- package/dist/server-core/src/cron/cron-scheduler.d.ts +45 -0
- package/dist/server-core/src/cron/index.d.ts +1 -1
- package/dist/server-core/src/init.d.ts +11 -1
- package/dist/types/src/controllers/auth.d.ts +8 -2
- package/dist/types/src/controllers/client.d.ts +13 -0
- package/dist/types/src/controllers/collection_registry.d.ts +2 -1
- package/dist/types/src/controllers/data_driver.d.ts +36 -1
- package/dist/types/src/controllers/navigation.d.ts +18 -6
- package/dist/types/src/controllers/registry.d.ts +9 -1
- package/dist/types/src/controllers/side_entity_controller.d.ts +7 -0
- package/dist/types/src/rebase_context.d.ts +17 -0
- package/dist/types/src/types/backend_hooks.d.ts +187 -0
- package/dist/types/src/types/collections.d.ts +31 -11
- package/dist/types/src/types/component_ref.d.ts +47 -0
- package/dist/types/src/types/cron.d.ts +1 -1
- package/dist/types/src/types/entity_views.d.ts +6 -7
- package/dist/types/src/types/formex.d.ts +40 -0
- package/dist/types/src/types/index.d.ts +3 -0
- package/dist/types/src/types/plugins.d.ts +6 -3
- package/dist/types/src/types/properties.d.ts +72 -88
- package/dist/types/src/types/slots.d.ts +20 -10
- package/dist/types/src/types/translations.d.ts +6 -0
- package/examples/firebase/node_modules/esbuild/LICENSE.md +21 -0
- package/examples/firebase/node_modules/esbuild/README.md +3 -0
- package/examples/firebase/node_modules/esbuild/bin/esbuild +220 -0
- package/examples/firebase/node_modules/esbuild/install.js +285 -0
- package/examples/firebase/node_modules/esbuild/lib/main.d.ts +705 -0
- package/examples/firebase/node_modules/esbuild/lib/main.js +2239 -0
- package/examples/firebase/node_modules/esbuild/package.json +46 -0
- package/examples/medmot-staging/frontend/node_modules/esbuild/LICENSE.md +21 -0
- package/examples/medmot-staging/frontend/node_modules/esbuild/README.md +3 -0
- package/examples/medmot-staging/frontend/node_modules/esbuild/bin/esbuild +220 -0
- package/examples/medmot-staging/frontend/node_modules/esbuild/install.js +285 -0
- package/examples/medmot-staging/frontend/node_modules/esbuild/lib/main.d.ts +705 -0
- package/examples/medmot-staging/frontend/node_modules/esbuild/lib/main.js +2239 -0
- package/examples/medmot-staging/frontend/node_modules/esbuild/package.json +46 -0
- package/examples/sdk-demo/node_modules/esbuild/LICENSE.md +21 -0
- package/examples/sdk-demo/node_modules/esbuild/README.md +3 -0
- package/examples/sdk-demo/node_modules/esbuild/bin/esbuild +223 -0
- package/examples/sdk-demo/node_modules/esbuild/install.js +289 -0
- package/examples/sdk-demo/node_modules/esbuild/lib/main.d.ts +716 -0
- package/examples/sdk-demo/node_modules/esbuild/lib/main.js +2242 -0
- package/examples/sdk-demo/node_modules/esbuild/package.json +49 -0
- package/package.json +9 -9
- package/packages/client/node_modules/esbuild/LICENSE.md +21 -0
- package/packages/client/node_modules/esbuild/README.md +3 -0
- package/packages/client/node_modules/esbuild/bin/esbuild +220 -0
- package/packages/client/node_modules/esbuild/install.js +285 -0
- package/packages/client/node_modules/esbuild/lib/main.d.ts +705 -0
- package/packages/client/node_modules/esbuild/lib/main.js +2239 -0
- package/packages/client/node_modules/esbuild/package.json +46 -0
- package/packages/client-postgresql/node_modules/esbuild/LICENSE.md +21 -0
- package/packages/client-postgresql/node_modules/esbuild/README.md +3 -0
- package/packages/client-postgresql/node_modules/esbuild/bin/esbuild +220 -0
- package/packages/client-postgresql/node_modules/esbuild/install.js +285 -0
- package/packages/client-postgresql/node_modules/esbuild/lib/main.d.ts +705 -0
- package/packages/client-postgresql/node_modules/esbuild/lib/main.js +2239 -0
- package/packages/client-postgresql/node_modules/esbuild/package.json +46 -0
- package/packages/common/node_modules/esbuild/LICENSE.md +21 -0
- package/packages/common/node_modules/esbuild/README.md +3 -0
- package/packages/common/node_modules/esbuild/bin/esbuild +220 -0
- package/packages/common/node_modules/esbuild/install.js +285 -0
- package/packages/common/node_modules/esbuild/lib/main.d.ts +705 -0
- package/packages/common/node_modules/esbuild/lib/main.js +2239 -0
- package/packages/common/node_modules/esbuild/package.json +46 -0
- package/packages/server-mongodb/node_modules/esbuild/LICENSE.md +21 -0
- package/packages/server-mongodb/node_modules/esbuild/README.md +3 -0
- package/packages/server-mongodb/node_modules/esbuild/bin/esbuild +220 -0
- package/packages/server-mongodb/node_modules/esbuild/install.js +285 -0
- package/packages/server-mongodb/node_modules/esbuild/lib/main.d.ts +705 -0
- package/packages/server-mongodb/node_modules/esbuild/lib/main.js +2239 -0
- package/packages/server-mongodb/node_modules/esbuild/package.json +46 -0
- package/packages/server-postgresql/node_modules/esbuild/LICENSE.md +21 -0
- package/packages/server-postgresql/node_modules/esbuild/README.md +3 -0
- package/packages/server-postgresql/node_modules/esbuild/bin/esbuild +220 -0
- package/packages/server-postgresql/node_modules/esbuild/install.js +285 -0
- package/packages/server-postgresql/node_modules/esbuild/lib/main.d.ts +705 -0
- package/packages/server-postgresql/node_modules/esbuild/lib/main.js +2239 -0
- package/packages/server-postgresql/node_modules/esbuild/package.json +46 -0
- package/packages/types/node_modules/esbuild/LICENSE.md +21 -0
- package/packages/types/node_modules/esbuild/README.md +3 -0
- package/packages/types/node_modules/esbuild/bin/esbuild +220 -0
- package/packages/types/node_modules/esbuild/install.js +285 -0
- package/packages/types/node_modules/esbuild/lib/main.d.ts +705 -0
- package/packages/types/node_modules/esbuild/lib/main.js +2239 -0
- package/packages/types/node_modules/esbuild/package.json +46 -0
- package/packages/utils/node_modules/esbuild/LICENSE.md +21 -0
- package/packages/utils/node_modules/esbuild/README.md +3 -0
- package/packages/utils/node_modules/esbuild/bin/esbuild +220 -0
- package/packages/utils/node_modules/esbuild/install.js +285 -0
- package/packages/utils/node_modules/esbuild/lib/main.d.ts +705 -0
- package/packages/utils/node_modules/esbuild/lib/main.js +2239 -0
- package/packages/utils/node_modules/esbuild/package.json +46 -0
- package/src/api/errors.ts +3 -2
- package/src/api/rest/api-generator-count.test.ts +113 -0
- package/src/api/rest/api-generator.ts +123 -22
- package/src/api/server.ts +8 -4
- package/src/auth/admin-routes.ts +133 -57
- package/src/auth/apple-oauth.ts +8 -18
- package/src/auth/google-oauth.ts +192 -22
- package/src/auth/index.ts +1 -0
- package/src/auth/rate-limiter.ts +9 -5
- package/src/auth/routes.ts +25 -5
- package/src/collections/loader.ts +3 -3
- package/src/cron/cron-scheduler.test.ts +301 -175
- package/src/cron/cron-scheduler.ts +220 -57
- package/src/cron/index.ts +1 -1
- package/src/init.ts +27 -5
- package/src/storage/LocalStorageController.ts +37 -13
- package/src/storage/S3StorageController.ts +4 -1
- package/src/storage/routes.ts +51 -5
- package/test/backend-hooks-admin.test.ts +394 -0
- package/test/backend-hooks-data.test.ts +408 -0
- package/history_diff.log +0 -385
- package/scratch.ts +0 -9
- package/test-ast.ts +0 -28
- package/test_output.txt +0 -1133
package/src/auth/admin-routes.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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) => {
|
package/src/auth/apple-oauth.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import type { OAuthProvider, OAuthProviderProfile } from "./interfaces";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 {
|
package/src/auth/google-oauth.ts
CHANGED
|
@@ -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(
|
|
17
|
-
|
|
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,
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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("
|
|
44
|
-
|
|
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";
|
package/src/auth/rate-limiter.ts
CHANGED
|
@@ -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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
/**
|
package/src/auth/routes.ts
CHANGED
|
@@ -233,8 +233,16 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
|
|
|
233
233
|
displayName: displayName || undefined
|
|
234
234
|
});
|
|
235
235
|
|
|
236
|
-
//
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
324
|
-
|
|
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
|
|
30
|
-
|
|
31
|
-
const module = await
|
|
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) {
|