@checkstack/auth-backend 0.0.2

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/src/index.ts ADDED
@@ -0,0 +1,878 @@
1
+ import { betterAuth } from "better-auth";
2
+ import { drizzleAdapter } from "better-auth/adapters/drizzle";
3
+ import { APIError } from "better-auth/api";
4
+ import {
5
+ createBackendPlugin,
6
+ coreServices,
7
+ coreHooks,
8
+ authenticationStrategyServiceRef,
9
+ type AuthStrategy,
10
+ } from "@checkstack/backend-api";
11
+ import {
12
+ pluginMetadata,
13
+ permissionList,
14
+ authContract,
15
+ authRoutes,
16
+ permissions,
17
+ } from "@checkstack/auth-common";
18
+ import { NotificationApi } from "@checkstack/notification-common";
19
+ import * as schema from "./schema";
20
+ import { eq, inArray, or } from "drizzle-orm";
21
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
22
+ import { User } from "better-auth/types";
23
+ import { hashPassword, verifyPassword } from "better-auth/crypto";
24
+ import { createExtensionPoint } from "@checkstack/backend-api";
25
+ import { enrichUser } from "./utils/user";
26
+ import { createAuthRouter } from "./router";
27
+ import { validateStrategySchema } from "./utils/validate-schema";
28
+ import {
29
+ strategyMetaConfigV1,
30
+ STRATEGY_META_CONFIG_VERSION,
31
+ } from "./meta-config";
32
+ import {
33
+ platformRegistrationConfigV1,
34
+ PLATFORM_REGISTRATION_CONFIG_VERSION,
35
+ PLATFORM_REGISTRATION_CONFIG_ID,
36
+ } from "./platform-registration-config";
37
+ import { registerSearchProvider } from "@checkstack/command-backend";
38
+ import { resolveRoute } from "@checkstack/common";
39
+
40
+ export interface BetterAuthExtensionPoint {
41
+ addStrategy(strategy: AuthStrategy<unknown>): void;
42
+ }
43
+
44
+ export const betterAuthExtensionPoint =
45
+ createExtensionPoint<BetterAuthExtensionPoint>(
46
+ "auth.betterAuthExtensionPoint"
47
+ );
48
+
49
+ /**
50
+ * Sync permissions to database and assign to admin role.
51
+ * @param fullSync - If true, also performs orphan cleanup and default role sync.
52
+ * Should only be true when syncing ALL permissions (not per-plugin hooks).
53
+ */
54
+ async function syncPermissionsToDb({
55
+ database,
56
+ logger,
57
+ permissions,
58
+ fullSync = false,
59
+ }: {
60
+ database: NodePgDatabase<typeof schema>;
61
+ logger: { debug: (msg: string) => void };
62
+ permissions: {
63
+ id: string;
64
+ description?: string;
65
+ isAuthenticatedDefault?: boolean;
66
+ isPublicDefault?: boolean;
67
+ }[];
68
+ fullSync?: boolean;
69
+ }) {
70
+ logger.debug(`🔑 Syncing ${permissions.length} permissions to database...`);
71
+
72
+ for (const perm of permissions) {
73
+ const existing = await database
74
+ .select()
75
+ .from(schema.permission)
76
+ .where(eq(schema.permission.id, perm.id));
77
+
78
+ if (existing.length === 0) {
79
+ await database.insert(schema.permission).values(perm);
80
+ logger.debug(` -> Created permission: ${perm.id}`);
81
+ } else {
82
+ await database
83
+ .update(schema.permission)
84
+ .set({ description: perm.description })
85
+ .where(eq(schema.permission.id, perm.id));
86
+ }
87
+ }
88
+
89
+ // Assign all permissions to admin role
90
+ const adminRolePermissions = await database
91
+ .select()
92
+ .from(schema.rolePermission)
93
+ .where(eq(schema.rolePermission.roleId, "admin"));
94
+
95
+ for (const perm of permissions) {
96
+ const hasPermission = adminRolePermissions.some(
97
+ (rp) => rp.permissionId === perm.id
98
+ );
99
+
100
+ if (!hasPermission) {
101
+ await database
102
+ .insert(schema.rolePermission)
103
+ .values({
104
+ roleId: "admin",
105
+ permissionId: perm.id,
106
+ })
107
+ .onConflictDoNothing();
108
+ logger.debug(` -> Assigned permission ${perm.id} to admin role`);
109
+ }
110
+ }
111
+
112
+ // Only perform orphan cleanup and default sync when doing a full sync
113
+ // (i.e., when we have ALL permissions, not just one plugin's permissions from a hook)
114
+ if (!fullSync) {
115
+ return;
116
+ }
117
+
118
+ // Cleanup orphan permissions (no longer registered by any plugin)
119
+ const registeredIds = new Set(permissions.map((p) => p.id));
120
+ const allDbPermissions = await database.select().from(schema.permission);
121
+ const orphanPermissions = allDbPermissions.filter(
122
+ (p) => !registeredIds.has(p.id)
123
+ );
124
+
125
+ if (orphanPermissions.length > 0) {
126
+ logger.debug(
127
+ `🧹 Removing ${orphanPermissions.length} orphan permission(s)...`
128
+ );
129
+ for (const orphan of orphanPermissions) {
130
+ // Delete role_permission entries first (FK doesn't cascade)
131
+ await database
132
+ .delete(schema.rolePermission)
133
+ .where(eq(schema.rolePermission.permissionId, orphan.id));
134
+ // Then delete the permission itself
135
+ await database
136
+ .delete(schema.permission)
137
+ .where(eq(schema.permission.id, orphan.id));
138
+ logger.debug(` -> Removed orphan permission: ${orphan.id}`);
139
+ }
140
+ }
141
+
142
+ // Sync authenticated default permissions to users role
143
+ await syncAuthenticatedDefaultPermissionsToUsersRole({
144
+ database,
145
+ logger,
146
+ permissions,
147
+ });
148
+
149
+ // Sync public default permissions to anonymous role
150
+ await syncPublicDefaultPermissionsToAnonymousRole({
151
+ database,
152
+ logger,
153
+ permissions,
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Sync authenticated default permissions (isAuthenticatedDefault=true) to the "users" role.
159
+ * Respects admin-disabled defaults stored in disabled_default_permission table.
160
+ */
161
+ async function syncAuthenticatedDefaultPermissionsToUsersRole({
162
+ database,
163
+ logger,
164
+ permissions,
165
+ }: {
166
+ database: NodePgDatabase<typeof schema>;
167
+ logger: { debug: (msg: string) => void };
168
+ permissions: { id: string; isAuthenticatedDefault?: boolean }[];
169
+ }) {
170
+ // Debug: log all permissions with their isAuthenticatedDefault status
171
+ logger.debug(
172
+ `[DEBUG] All permissions received (${permissions.length} total):`
173
+ );
174
+ for (const p of permissions) {
175
+ logger.debug(
176
+ ` -> ${p.id}: isAuthenticatedDefault=${p.isAuthenticatedDefault}`
177
+ );
178
+ }
179
+
180
+ const defaultPermissions = permissions.filter(
181
+ (p) => p.isAuthenticatedDefault
182
+ );
183
+ logger.debug(
184
+ `👥 Found ${defaultPermissions.length} authenticated default permissions to sync to users role`
185
+ );
186
+ if (defaultPermissions.length === 0) {
187
+ logger.debug(
188
+ ` -> No authenticated default permissions found, skipping sync`
189
+ );
190
+ return;
191
+ }
192
+
193
+ // Get already disabled defaults (admin has removed them)
194
+ const disabledDefaults = await database
195
+ .select()
196
+ .from(schema.disabledDefaultPermission);
197
+ const disabledIds = new Set(disabledDefaults.map((d) => d.permissionId));
198
+
199
+ // Get current users role permissions
200
+ const usersRolePermissions = await database
201
+ .select()
202
+ .from(schema.rolePermission)
203
+ .where(eq(schema.rolePermission.roleId, "users"));
204
+
205
+ for (const perm of defaultPermissions) {
206
+ // Skip if admin has disabled this default
207
+ if (disabledIds.has(perm.id)) {
208
+ logger.debug(` -> Skipping disabled authenticated default: ${perm.id}`);
209
+ continue;
210
+ }
211
+
212
+ const hasPermission = usersRolePermissions.some(
213
+ (rp) => rp.permissionId === perm.id
214
+ );
215
+
216
+ if (!hasPermission) {
217
+ await database.insert(schema.rolePermission).values({
218
+ roleId: "users",
219
+ permissionId: perm.id,
220
+ });
221
+ logger.debug(
222
+ ` -> Assigned authenticated default permission ${perm.id} to users role`
223
+ );
224
+ }
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Sync public default permissions (isPublicDefault=true) to the "anonymous" role.
230
+ * Respects admin-disabled defaults stored in disabled_public_default_permission table.
231
+ */
232
+ async function syncPublicDefaultPermissionsToAnonymousRole({
233
+ database,
234
+ logger,
235
+ permissions,
236
+ }: {
237
+ database: NodePgDatabase<typeof schema>;
238
+ logger: { debug: (msg: string) => void };
239
+ permissions: { id: string; isPublicDefault?: boolean }[];
240
+ }) {
241
+ const publicDefaults = permissions.filter((p) => p.isPublicDefault);
242
+ logger.debug(
243
+ `🌐 Found ${publicDefaults.length} public default permissions to sync to anonymous role`
244
+ );
245
+ if (publicDefaults.length === 0) {
246
+ logger.debug(` -> No public default permissions found, skipping sync`);
247
+ return;
248
+ }
249
+
250
+ // Get already disabled public defaults (admin has removed them)
251
+ const disabledDefaults = await database
252
+ .select()
253
+ .from(schema.disabledPublicDefaultPermission);
254
+ const disabledIds = new Set(disabledDefaults.map((d) => d.permissionId));
255
+
256
+ // Get current anonymous role permissions
257
+ const anonymousRolePermissions = await database
258
+ .select()
259
+ .from(schema.rolePermission)
260
+ .where(eq(schema.rolePermission.roleId, "anonymous"));
261
+
262
+ for (const perm of publicDefaults) {
263
+ // Skip if admin has disabled this public default
264
+ if (disabledIds.has(perm.id)) {
265
+ logger.debug(` -> Skipping disabled public default: ${perm.id}`);
266
+ continue;
267
+ }
268
+
269
+ const hasPermission = anonymousRolePermissions.some(
270
+ (rp) => rp.permissionId === perm.id
271
+ );
272
+
273
+ if (!hasPermission) {
274
+ await database.insert(schema.rolePermission).values({
275
+ roleId: "anonymous",
276
+ permissionId: perm.id,
277
+ });
278
+ logger.debug(
279
+ ` -> Assigned public default permission ${perm.id} to anonymous role`
280
+ );
281
+ }
282
+ }
283
+ }
284
+
285
+ export default createBackendPlugin({
286
+ metadata: pluginMetadata,
287
+ register(env) {
288
+ let auth: ReturnType<typeof betterAuth> | undefined;
289
+ let db: NodePgDatabase<typeof schema> | undefined;
290
+
291
+ const strategies: AuthStrategy<unknown>[] = [];
292
+
293
+ // Strategy registry
294
+ const strategyRegistry = {
295
+ getStrategies: () => strategies,
296
+ };
297
+
298
+ // Permission registry - gets all permissions from PluginManager
299
+ const permissionRegistry = {
300
+ getPermissions: () => {
301
+ // Get all permissions from the central PluginManager registry
302
+ return env.pluginManager.getAllPermissions();
303
+ },
304
+ };
305
+
306
+ env.registerPermissions(permissionList);
307
+
308
+ env.registerExtensionPoint(betterAuthExtensionPoint, {
309
+ addStrategy: (s) => {
310
+ // Validate that the strategy schema doesn't have required fields without defaults
311
+ try {
312
+ validateStrategySchema(s.configSchema, s.id);
313
+ } catch (error) {
314
+ const message =
315
+ error instanceof Error ? error.message : String(error);
316
+ throw new Error(
317
+ `Failed to register authentication strategy "${s.id}": ${message}`
318
+ );
319
+ }
320
+ strategies.push(s);
321
+ },
322
+ });
323
+
324
+ // Helper to fetch permissions
325
+ const enrichUserLocal = async (user: User) => {
326
+ if (!db) return user;
327
+ return enrichUser(user, db);
328
+ };
329
+
330
+ // 2. Register Authentication Strategy (used by Core AuthService)
331
+ env.registerService(authenticationStrategyServiceRef, {
332
+ validate: async (request: Request) => {
333
+ if (!db) {
334
+ return; // Not initialized yet
335
+ }
336
+
337
+ // Check for API key authentication (Bearer ck_<appId>_<secret>)
338
+ const authHeader = request.headers.get("authorization");
339
+ if (authHeader?.startsWith("Bearer ck_")) {
340
+ const token = authHeader.slice(7); // Remove "Bearer "
341
+ const parts = token.split("_");
342
+ // Token format: ck_<uuid>_<secret>
343
+ // Split: ["ck", "uuid-with-dashes", "secret"]
344
+ // UUID has dashes, so we need to handle properly
345
+ if (parts.length >= 3 && parts[0] === "ck") {
346
+ // The UUID is parts[1] and potentially includes more parts if UUID has dashes
347
+ // For a UUID like "abc-def-ghi", after "ck_", we get the rest split by _
348
+ // Safer approach: find the application ID by parsing
349
+ const tokenWithoutPrefix = token.slice(3); // Remove "ck_"
350
+ // UUID is 36 chars, secret is 32 chars
351
+ const applicationId = tokenWithoutPrefix.slice(0, 36);
352
+ const secret = tokenWithoutPrefix.slice(37); // Skip the _ separator
353
+
354
+ if (applicationId && secret) {
355
+ // Look up application
356
+ const apps = await db
357
+ .select()
358
+ .from(schema.application)
359
+ .where(eq(schema.application.id, applicationId))
360
+ .limit(1);
361
+
362
+ if (apps.length > 0) {
363
+ const app = apps[0];
364
+ // Verify secret using bcrypt
365
+ const isValid = await verifyPassword({
366
+ hash: app.secretHash,
367
+ password: secret,
368
+ });
369
+
370
+ if (isValid) {
371
+ // Update lastUsedAt timestamp (fire-and-forget)
372
+ db.update(schema.application)
373
+ .set({ lastUsedAt: new Date() })
374
+ .where(eq(schema.application.id, applicationId))
375
+ .execute()
376
+ .catch(() => {
377
+ // Ignore errors from lastUsedAt update
378
+ });
379
+
380
+ // Fetch roles and compute permissions for the application
381
+ const appRoles = await db
382
+ .select({ roleId: schema.applicationRole.roleId })
383
+ .from(schema.applicationRole)
384
+ .where(
385
+ eq(schema.applicationRole.applicationId, applicationId)
386
+ );
387
+
388
+ const roleIds = appRoles.map((r) => r.roleId);
389
+
390
+ // Get permissions for these roles
391
+ let permissions: string[] = [];
392
+ if (roleIds.length > 0) {
393
+ const rolePerms = await db
394
+ .select({
395
+ permissionId: schema.rolePermission.permissionId,
396
+ })
397
+ .from(schema.rolePermission)
398
+ .where(inArray(schema.rolePermission.roleId, roleIds));
399
+
400
+ permissions = [
401
+ ...new Set(rolePerms.map((rp) => rp.permissionId)),
402
+ ];
403
+ }
404
+
405
+ // Return ApplicationUser
406
+ return {
407
+ type: "application" as const,
408
+ id: app.id,
409
+ name: app.name,
410
+ roles: roleIds,
411
+ permissions,
412
+ };
413
+ }
414
+ }
415
+ }
416
+ }
417
+ return; // Invalid API key
418
+ }
419
+
420
+ // Fall back to session-based authentication (better-auth)
421
+ if (!auth) {
422
+ return; // Not initialized yet
423
+ }
424
+
425
+ const session = await auth.api.getSession({
426
+ headers: request.headers,
427
+ });
428
+ if (!session?.user) return;
429
+ return enrichUserLocal(session.user);
430
+ },
431
+ });
432
+
433
+ // 3. Register Init logic
434
+ env.registerInit({
435
+ schema,
436
+ deps: {
437
+ database: coreServices.database,
438
+ rpc: coreServices.rpc,
439
+ rpcClient: coreServices.rpcClient,
440
+ logger: coreServices.logger,
441
+ auth: coreServices.auth,
442
+ config: coreServices.config,
443
+ },
444
+ init: async ({
445
+ database,
446
+ rpc,
447
+ rpcClient,
448
+ logger,
449
+ auth: _auth,
450
+ config,
451
+ }) => {
452
+ logger.debug("[auth-backend] Initializing Auth Backend...");
453
+
454
+ db = database;
455
+
456
+ // Function to initialize/reinitialize better-auth
457
+ const initializeBetterAuth = async () => {
458
+ const socialProviders: Record<string, unknown> = {};
459
+ logger.debug(
460
+ `[auth-backend] Processing ${strategies.length} strategies...`
461
+ );
462
+
463
+ for (const strategy of strategies) {
464
+ logger.debug(
465
+ `[auth-backend] -> Processing auth strategy: ${strategy.id}`
466
+ );
467
+
468
+ // Skip credential strategy - it's built into better-auth
469
+ if (strategy.id === "credential") continue;
470
+
471
+ // Load config from ConfigService
472
+ const strategyConfig = await config.get(
473
+ strategy.id,
474
+ strategy.configSchema,
475
+ strategy.configVersion,
476
+ strategy.migrations
477
+ );
478
+
479
+ // Check if strategy is enabled from meta config
480
+ const metaConfig = await config.get(
481
+ `${strategy.id}.meta`,
482
+ strategyMetaConfigV1,
483
+ STRATEGY_META_CONFIG_VERSION
484
+ );
485
+ const enabled = metaConfig?.enabled ?? false;
486
+
487
+ if (!enabled) {
488
+ logger.debug(
489
+ `[auth-backend] -> Strategy ${strategy.id} is disabled, skipping`
490
+ );
491
+ continue;
492
+ }
493
+
494
+ // Add to socialProviders (secrets are already decrypted by ConfigService)
495
+ logger.debug(
496
+ `[auth-backend] -> Config keys for ${
497
+ strategy.id
498
+ }: ${Object.keys(strategyConfig || {}).join(", ")}`
499
+ );
500
+ socialProviders[strategy.id] = strategyConfig;
501
+ logger.debug(
502
+ `[auth-backend] -> ✅ Added ${strategy.id} to socialProviders`
503
+ );
504
+ }
505
+
506
+ // Check if credential strategy is enabled from meta config
507
+ const credentialStrategy = strategies.find(
508
+ (s) => s.id === "credential"
509
+ );
510
+ const credentialMetaConfig = credentialStrategy
511
+ ? await config.get(
512
+ "credential.meta",
513
+ strategyMetaConfigV1,
514
+ STRATEGY_META_CONFIG_VERSION
515
+ )
516
+ : undefined;
517
+ // Default to true on fresh installs (no meta config)
518
+ const credentialEnabled = credentialMetaConfig?.enabled ?? true;
519
+
520
+ // Check platform registration setting
521
+ const platformRegistrationConfig = await config.get(
522
+ PLATFORM_REGISTRATION_CONFIG_ID,
523
+ platformRegistrationConfigV1,
524
+ PLATFORM_REGISTRATION_CONFIG_VERSION
525
+ );
526
+ const registrationAllowed =
527
+ platformRegistrationConfig?.allowRegistration ?? true;
528
+
529
+ logger.debug(
530
+ `[auth-backend] Initializing Better Auth with ${
531
+ Object.keys(socialProviders).length
532
+ } social providers: ${Object.keys(socialProviders).join(", ")}`
533
+ );
534
+
535
+ return betterAuth({
536
+ database: drizzleAdapter(database, {
537
+ provider: "pg",
538
+ schema: { ...schema },
539
+ }),
540
+ emailAndPassword: {
541
+ enabled: credentialEnabled,
542
+ disableSignUp: !registrationAllowed,
543
+ minPasswordLength: 8,
544
+ maxPasswordLength: 128,
545
+ sendResetPassword: async ({ user, url }) => {
546
+ // Send password reset notification via all enabled strategies
547
+ // Using void to prevent timing attacks revealing email existence
548
+ const notificationClient = rpcClient.forPlugin(NotificationApi);
549
+ const frontendUrl =
550
+ process.env.VITE_FRONTEND_URL || "http://localhost:5173";
551
+ const resetUrl = `${frontendUrl}/auth/reset-password?token=${
552
+ url.split("token=")[1] ?? ""
553
+ }`;
554
+
555
+ void notificationClient.sendTransactional({
556
+ userId: user.id,
557
+ notification: {
558
+ title: "Password Reset Request",
559
+ body: `You requested to reset your password. Click the button below to set a new password. This link will expire in 1 hour.\n\nIf you didn't request this, please ignore this message or contact support if you're concerned.`,
560
+ action: {
561
+ label: "Reset Password",
562
+ url: resetUrl,
563
+ },
564
+ },
565
+ });
566
+
567
+ logger.debug(
568
+ `[auth-backend] Password reset email sent to user: ${user.id}`
569
+ );
570
+ },
571
+ resetPasswordTokenExpiresIn: 60 * 60, // 1 hour
572
+ },
573
+ socialProviders,
574
+ basePath: "/api/auth",
575
+ baseURL: process.env.VITE_API_BASE_URL || "http://localhost:3000",
576
+ trustedOrigins: [
577
+ process.env.VITE_FRONTEND_URL || "http://localhost:5173",
578
+ ],
579
+ databaseHooks: {
580
+ user: {
581
+ create: {
582
+ before: async (user) => {
583
+ // Block new user creation when registration is disabled
584
+ // Credential registration is already blocked by disableSignUp,
585
+ // so any user.create here must be from social providers
586
+ if (!registrationAllowed) {
587
+ throw new APIError("FORBIDDEN", {
588
+ message:
589
+ "Registration is currently disabled. Please contact an administrator.",
590
+ });
591
+ }
592
+ return { data: user };
593
+ },
594
+ after: async (user) => {
595
+ // Auto-assign "users" role to new users
596
+ try {
597
+ await database.insert(schema.userRole).values({
598
+ userId: user.id,
599
+ roleId: "users",
600
+ });
601
+ logger.debug(
602
+ `[auth-backend] Assigned 'users' role to new user: ${user.id}`
603
+ );
604
+ } catch (error) {
605
+ // Role might not exist yet on first boot, that's okay
606
+ logger.debug(
607
+ `[auth-backend] Could not assign 'users' role to ${user.id}: ${error}`
608
+ );
609
+ }
610
+ },
611
+ },
612
+ },
613
+ },
614
+ });
615
+ };
616
+
617
+ // Initialize better-auth
618
+ auth = await initializeBetterAuth();
619
+
620
+ // Reload function for dynamic auth config changes
621
+ const reloadAuth = async () => {
622
+ logger.info(
623
+ "[auth-backend] Reloading authentication configuration..."
624
+ );
625
+ auth = await initializeBetterAuth();
626
+ logger.info("[auth-backend] ✅ Authentication reloaded successfully");
627
+ };
628
+
629
+ // IMPORTANT: Seed roles BEFORE syncing permissions so default perms can be assigned
630
+ logger.debug("🌱 Checking for initial roles...");
631
+ const adminRole = await database
632
+ .select()
633
+ .from(schema.role)
634
+ .where(eq(schema.role.id, "admin"));
635
+ if (adminRole.length === 0) {
636
+ await database.insert(schema.role).values({
637
+ id: "admin",
638
+ name: "Administrators",
639
+ isSystem: true,
640
+ });
641
+ logger.info(" -> Created 'admin' role.");
642
+ }
643
+
644
+ // Seed "users" role for default permissions
645
+ const usersRole = await database
646
+ .select()
647
+ .from(schema.role)
648
+ .where(eq(schema.role.id, "users"));
649
+ if (usersRole.length === 0) {
650
+ await database.insert(schema.role).values({
651
+ id: "users",
652
+ name: "Users",
653
+ description: "Default role for all authenticated users",
654
+ isSystem: true,
655
+ });
656
+ logger.info(" -> Created 'users' role.");
657
+ }
658
+
659
+ // Seed "anonymous" role for public access
660
+ const anonymousRole = await database
661
+ .select()
662
+ .from(schema.role)
663
+ .where(eq(schema.role.id, "anonymous"));
664
+ if (anonymousRole.length === 0) {
665
+ await database.insert(schema.role).values({
666
+ id: "anonymous",
667
+ name: "Anonymous Users",
668
+ description: "Permissions for unauthenticated (anonymous) users",
669
+ isSystem: true,
670
+ });
671
+ logger.info(" -> Created 'anonymous' role.");
672
+ }
673
+
674
+ // Seed "applications" role for external API applications
675
+ const applicationsRole = await database
676
+ .select()
677
+ .from(schema.role)
678
+ .where(eq(schema.role.id, "applications"));
679
+ if (applicationsRole.length === 0) {
680
+ await database.insert(schema.role).values({
681
+ id: "applications",
682
+ name: "Applications",
683
+ description: "Default role for external API applications",
684
+ isSystem: true,
685
+ });
686
+ logger.info(" -> Created 'applications' role.");
687
+ }
688
+
689
+ // Note: Permission sync happens in afterPluginsReady (when all plugins have registered)
690
+
691
+ // 4. Register oRPC router
692
+ const authRouter = createAuthRouter(
693
+ database as NodePgDatabase<typeof schema>,
694
+ strategyRegistry,
695
+ reloadAuth,
696
+ config,
697
+ permissionRegistry
698
+ );
699
+ rpc.registerRouter(authRouter, authContract);
700
+
701
+ // 5. Register Better Auth native handler
702
+ rpc.registerHttpHandler((req: Request) => auth!.handler(req));
703
+
704
+ // All auth management endpoints are now via oRPC (see ./router.ts)
705
+
706
+ // 5. Idempotent Admin User Seeding (roles already seeded above)
707
+ const adminId = "initial-admin-id";
708
+ const existingAdmin = await database
709
+ .select()
710
+ .from(schema.user)
711
+ .where(
712
+ or(
713
+ eq(schema.user.email, "admin@checkstack.dev"),
714
+ eq(schema.user.id, adminId)
715
+ )
716
+ );
717
+
718
+ // Skip seeding if user exists by either email or ID
719
+ if (existingAdmin.length === 0) {
720
+ await database.insert(schema.user).values({
721
+ id: adminId,
722
+ name: "Admin",
723
+ email: "admin@checkstack.dev",
724
+ emailVerified: true,
725
+ createdAt: new Date(),
726
+ updatedAt: new Date(),
727
+ });
728
+
729
+ const hashedAdminPassword = await hashPassword("admin");
730
+ await database.insert(schema.account).values({
731
+ id: "initial-admin-account-id",
732
+ accountId: "admin@checkstack.dev",
733
+ providerId: "credential",
734
+ userId: adminId,
735
+ password: hashedAdminPassword,
736
+ createdAt: new Date(),
737
+ updatedAt: new Date(),
738
+ });
739
+
740
+ await database.insert(schema.userRole).values({
741
+ userId: adminId,
742
+ roleId: "admin",
743
+ });
744
+
745
+ logger.info(
746
+ " -> Created initial admin user (admin@checkstack.dev : admin)"
747
+ );
748
+ }
749
+
750
+ // Register command palette commands
751
+ registerSearchProvider({
752
+ pluginMetadata,
753
+ commands: [
754
+ {
755
+ id: "users",
756
+ title: "Manage Users",
757
+ subtitle: "View and manage platform users",
758
+ iconName: "Users",
759
+ shortcuts: ["meta+shift+u", "ctrl+shift+u"],
760
+ route: resolveRoute(authRoutes.routes.settings) + "?tab=users",
761
+ requiredPermissions: [permissions.usersRead],
762
+ },
763
+ {
764
+ id: "createUser",
765
+ title: "Create User",
766
+ subtitle: "Create a new user account",
767
+ iconName: "UserPlus",
768
+ route:
769
+ resolveRoute(authRoutes.routes.settings) +
770
+ "?tab=users&action=create",
771
+ requiredPermissions: [permissions.usersCreate],
772
+ },
773
+ {
774
+ id: "roles",
775
+ title: "Manage Roles",
776
+ subtitle: "Manage roles and permissions",
777
+ iconName: "Shield",
778
+ route: resolveRoute(authRoutes.routes.settings) + "?tab=roles",
779
+ requiredPermissions: [permissions.rolesRead],
780
+ },
781
+ {
782
+ id: "applications",
783
+ title: "Manage Applications",
784
+ subtitle: "Manage external API applications",
785
+ iconName: "Key",
786
+ route:
787
+ resolveRoute(authRoutes.routes.settings) + "?tab=applications",
788
+ requiredPermissions: [permissions.applicationsManage],
789
+ },
790
+ ],
791
+ });
792
+
793
+ logger.debug("✅ Auth Backend initialized.");
794
+ },
795
+ // Phase 3: After all plugins are ready - sync all permissions including defaults
796
+ afterPluginsReady: async ({ database, logger, onHook }) => {
797
+ // Now that all plugins are ready, sync permissions including defaults
798
+ // This is critical because during init, other plugins haven't registered yet
799
+ const allPermissions = permissionRegistry.getPermissions();
800
+ logger.debug(
801
+ `[auth-backend] afterPluginsReady: syncing ${allPermissions.length} permissions from all plugins`
802
+ );
803
+ await syncPermissionsToDb({
804
+ database: database as NodePgDatabase<typeof schema>,
805
+ logger,
806
+ permissions: allPermissions,
807
+ fullSync: true,
808
+ });
809
+
810
+ // Subscribe to permission registration hook for future registrations
811
+ // This syncs new permissions when other plugins register them dynamically
812
+ onHook(
813
+ coreHooks.permissionsRegistered,
814
+ async ({ permissions }) => {
815
+ await syncPermissionsToDb({
816
+ database: database as NodePgDatabase<typeof schema>,
817
+ logger,
818
+ permissions,
819
+ });
820
+ },
821
+ {
822
+ mode: "work-queue",
823
+ workerGroup: "permission-db-sync",
824
+ maxRetries: 5,
825
+ }
826
+ );
827
+
828
+ // Subscribe to plugin deregistered hook for permission cleanup
829
+ // When a plugin is removed at runtime, delete its permissions from DB
830
+ onHook(
831
+ coreHooks.pluginDeregistered,
832
+ async ({ pluginId }) => {
833
+ logger.debug(
834
+ `[auth-backend] Cleaning up permissions for deregistered plugin: ${pluginId}`
835
+ );
836
+
837
+ // Delete all permissions with this plugin's prefix
838
+ const allDbPermissions = await database
839
+ .select()
840
+ .from(schema.permission);
841
+ const pluginPermissions = allDbPermissions.filter((p) =>
842
+ p.id.startsWith(`${pluginId}.`)
843
+ );
844
+
845
+ for (const perm of pluginPermissions) {
846
+ // Delete role_permission entries first
847
+ await database
848
+ .delete(schema.rolePermission)
849
+ .where(eq(schema.rolePermission.permissionId, perm.id));
850
+ // Then delete the permission itself
851
+ await database
852
+ .delete(schema.permission)
853
+ .where(eq(schema.permission.id, perm.id));
854
+ logger.debug(` -> Removed permission: ${perm.id}`);
855
+ }
856
+
857
+ logger.debug(
858
+ `[auth-backend] Cleaned up ${pluginPermissions.length} permissions for ${pluginId}`
859
+ );
860
+ },
861
+ {
862
+ mode: "work-queue",
863
+ workerGroup: "permission-cleanup",
864
+ maxRetries: 3,
865
+ }
866
+ );
867
+
868
+ logger.debug("✅ Auth Backend afterPluginsReady complete.");
869
+ },
870
+ });
871
+ },
872
+ });
873
+
874
+ // Re-export utility functions for use by custom auth strategies
875
+ export * from "./utils/auth-error-redirect";
876
+
877
+ // Re-export hooks for cross-plugin communication
878
+ export { authHooks } from "./hooks";