@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/router.ts ADDED
@@ -0,0 +1,1051 @@
1
+ import { implement, ORPCError } from "@orpc/server";
2
+ import {
3
+ autoAuthMiddleware,
4
+ type RpcContext,
5
+ type AuthUser,
6
+ type RealUser,
7
+ type AuthStrategy,
8
+ type ConfigService,
9
+ toJsonSchema,
10
+ } from "@checkstack/backend-api";
11
+ import { authContract, passwordSchema } from "@checkstack/auth-common";
12
+ import { hashPassword } from "better-auth/crypto";
13
+ import * as schema from "./schema";
14
+ import { eq, inArray, and } from "drizzle-orm";
15
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
16
+ import { authHooks } from "./hooks";
17
+
18
+ /**
19
+ * Type guard to check if user is a RealUser (not a service).
20
+ */
21
+ function isRealUser(user: AuthUser | undefined): user is RealUser {
22
+ return user?.type === "user";
23
+ }
24
+ import {
25
+ strategyMetaConfigV1,
26
+ STRATEGY_META_CONFIG_VERSION,
27
+ } from "./meta-config";
28
+ import {
29
+ platformRegistrationConfigV1,
30
+ PLATFORM_REGISTRATION_CONFIG_VERSION,
31
+ PLATFORM_REGISTRATION_CONFIG_ID,
32
+ } from "./platform-registration-config";
33
+
34
+ /**
35
+ * Creates the auth router using contract-based implementation.
36
+ *
37
+ * Auth and permissions are automatically enforced via autoAuthMiddleware
38
+ * based on the contract's meta.userType and meta.permissions.
39
+ */
40
+ const os = implement(authContract)
41
+ .$context<RpcContext>()
42
+ .use(autoAuthMiddleware);
43
+
44
+ /**
45
+ * Get the enabled state for an authentication strategy from its meta config.
46
+ *
47
+ * @param strategyId - The ID of the strategy
48
+ * @param configService - The ConfigService instance
49
+ * @returns The enabled state:
50
+ * - If meta config exists: returns the stored enabled value
51
+ * - If no meta config (fresh install): defaults to true for credential, false for others
52
+ */
53
+ async function getStrategyEnabled(
54
+ strategyId: string,
55
+ configService: ConfigService
56
+ ): Promise<boolean> {
57
+ const metaConfig = await configService.get(
58
+ `${strategyId}.meta`,
59
+ strategyMetaConfigV1,
60
+ STRATEGY_META_CONFIG_VERSION
61
+ );
62
+
63
+ // Default: credential=true (fresh installs), others=false (require explicit config)
64
+ return metaConfig?.enabled ?? strategyId === "credential";
65
+ }
66
+
67
+ /**
68
+ * Set the enabled state for an authentication strategy in its meta config.
69
+ */
70
+ async function setStrategyEnabled(
71
+ strategyId: string,
72
+ enabled: boolean,
73
+ configService: ConfigService
74
+ ): Promise<void> {
75
+ await configService.set(
76
+ `${strategyId}.meta`,
77
+ strategyMetaConfigV1,
78
+ STRATEGY_META_CONFIG_VERSION,
79
+ { enabled }
80
+ );
81
+ }
82
+
83
+ /**
84
+ * Check if platform-wide registration is currently allowed.
85
+ *
86
+ * @param configService - The ConfigService instance
87
+ * @returns true if registration is allowed, false otherwise
88
+ */
89
+ async function isRegistrationAllowed(
90
+ configService: ConfigService
91
+ ): Promise<boolean> {
92
+ const config = await configService.get(
93
+ PLATFORM_REGISTRATION_CONFIG_ID,
94
+ platformRegistrationConfigV1,
95
+ PLATFORM_REGISTRATION_CONFIG_VERSION
96
+ );
97
+ return config?.allowRegistration ?? true;
98
+ }
99
+
100
+ export interface AuthStrategyInfo {
101
+ id: string;
102
+ }
103
+
104
+ /**
105
+ * Generate a cryptographically secure 32-character secret for API applications.
106
+ */
107
+ function generateSecret(): string {
108
+ const chars =
109
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
110
+ const array = new Uint8Array(32);
111
+ crypto.getRandomValues(array);
112
+ return Array.from(array, (byte) => chars[byte % chars.length]).join("");
113
+ }
114
+
115
+ export const createAuthRouter = (
116
+ internalDb: NodePgDatabase<typeof schema>,
117
+ strategyRegistry: { getStrategies: () => AuthStrategy<unknown>[] },
118
+ reloadAuthFn: () => Promise<void>,
119
+ configService: ConfigService,
120
+ permissionRegistry: {
121
+ getPermissions: () => {
122
+ id: string;
123
+ description?: string;
124
+ isAuthenticatedDefault?: boolean;
125
+ isPublicDefault?: boolean;
126
+ }[];
127
+ }
128
+ ) => {
129
+ // Public endpoint for enabled strategies (no authentication required)
130
+ const getEnabledStrategies = os.getEnabledStrategies.handler(async () => {
131
+ const registeredStrategies = strategyRegistry.getStrategies();
132
+
133
+ const enabledStrategies = await Promise.all(
134
+ registeredStrategies.map(async (strategy) => {
135
+ // Get enabled state from meta config
136
+ const enabled = await getStrategyEnabled(strategy.id, configService);
137
+
138
+ // Determine strategy type
139
+ const type: "credential" | "social" =
140
+ strategy.id === "credential" ? "credential" : "social";
141
+
142
+ return {
143
+ id: strategy.id,
144
+ displayName: strategy.displayName,
145
+ description: strategy.description,
146
+ type,
147
+ enabled,
148
+ icon: strategy.icon,
149
+ requiresManualRegistration: strategy.requiresManualRegistration,
150
+ };
151
+ })
152
+ );
153
+
154
+ // Filter to only return enabled strategies
155
+ return enabledStrategies.filter((s) => s.enabled);
156
+ });
157
+
158
+ const permissions = os.permissions.handler(async ({ context }) => {
159
+ const user = context.user;
160
+ if (!isRealUser(user)) {
161
+ return { permissions: [] };
162
+ }
163
+ return { permissions: user.permissions || [] };
164
+ });
165
+
166
+ const getUsers = os.getUsers.handler(async () => {
167
+ const users = await internalDb.select().from(schema.user);
168
+ if (users.length === 0) return [];
169
+
170
+ const userRoles = await internalDb
171
+ .select()
172
+ .from(schema.userRole)
173
+ .where(
174
+ inArray(
175
+ schema.userRole.userId,
176
+ users.map((u) => u.id)
177
+ )
178
+ );
179
+
180
+ return users.map((u) => ({
181
+ ...u,
182
+ roles: userRoles
183
+ .filter((ur) => ur.userId === u.id)
184
+ .map((ur) => ur.roleId),
185
+ }));
186
+ });
187
+
188
+ const deleteUser = os.deleteUser.handler(async ({ input: id, context }) => {
189
+ if (id === "initial-admin-id") {
190
+ throw new ORPCError("FORBIDDEN", {
191
+ message: "Cannot delete initial admin",
192
+ });
193
+ }
194
+
195
+ // Delete user and all related records in a transaction
196
+ // Foreign keys are set to "ON DELETE no action", so we must manually delete related records
197
+ await internalDb.transaction(async (tx) => {
198
+ // Delete user roles
199
+ await tx.delete(schema.userRole).where(eq(schema.userRole.userId, id));
200
+
201
+ // Delete sessions
202
+ await tx.delete(schema.session).where(eq(schema.session.userId, id));
203
+
204
+ // Delete accounts
205
+ await tx.delete(schema.account).where(eq(schema.account.userId, id));
206
+
207
+ // Finally, delete the user
208
+ await tx.delete(schema.user).where(eq(schema.user.id, id));
209
+ });
210
+
211
+ // Emit hook for cross-plugin cleanup (notifications, theme preferences, etc.)
212
+ await context.emitHook(authHooks.userDeleted, { userId: id });
213
+ });
214
+
215
+ const getRoles = os.getRoles.handler(async () => {
216
+ const roles = await internalDb.select().from(schema.role);
217
+ const rolePermissions = await internalDb
218
+ .select()
219
+ .from(schema.rolePermission);
220
+
221
+ return roles.map((role) => ({
222
+ id: role.id,
223
+ name: role.name,
224
+ description: role.description,
225
+ permissions: rolePermissions
226
+ .filter((rp) => rp.roleId === role.id)
227
+ .map((rp) => rp.permissionId),
228
+ isSystem: role.isSystem || false,
229
+ // Anonymous role cannot be assigned to users - it's for unauthenticated access
230
+ isAssignable: role.id !== "anonymous",
231
+ }));
232
+ });
233
+
234
+ const getPermissions = os.getPermissions.handler(async () => {
235
+ // Return only currently active permissions (registered by loaded plugins)
236
+ return permissionRegistry.getPermissions();
237
+ });
238
+
239
+ const createRole = os.createRole.handler(async ({ input }) => {
240
+ const { name, description, permissions: inputPermissions } = input;
241
+
242
+ // Generate UUID for new role
243
+ const id = crypto.randomUUID();
244
+
245
+ // Get active permissions to filter input
246
+ const activePermissions = new Set(
247
+ permissionRegistry.getPermissions().map((p) => p.id)
248
+ );
249
+
250
+ // Filter to only include active permissions
251
+ const validPermissions = inputPermissions.filter((p) =>
252
+ activePermissions.has(p)
253
+ );
254
+
255
+ await internalDb.transaction(async (tx) => {
256
+ // Create role
257
+ await tx.insert(schema.role).values({
258
+ id,
259
+ name,
260
+ description: description || undefined,
261
+ isSystem: false,
262
+ });
263
+
264
+ // Create role-permission mappings
265
+ if (validPermissions.length > 0) {
266
+ await tx.insert(schema.rolePermission).values(
267
+ validPermissions.map((permissionId) => ({
268
+ roleId: id,
269
+ permissionId,
270
+ }))
271
+ );
272
+ }
273
+ });
274
+ });
275
+
276
+ const updateRole = os.updateRole.handler(async ({ input, context }) => {
277
+ const { id, name, description, permissions: inputPermissions } = input;
278
+
279
+ // Track if user has this role (for permission elevation prevention)
280
+ const userRoles = isRealUser(context.user) ? context.user.roles || [] : [];
281
+ const isUserOwnRole = userRoles.includes(id);
282
+
283
+ // Check if role exists
284
+ const existingRole = await internalDb
285
+ .select()
286
+ .from(schema.role)
287
+ .where(eq(schema.role.id, id));
288
+
289
+ if (existingRole.length === 0) {
290
+ throw new ORPCError("NOT_FOUND", {
291
+ message: `Role ${id} not found`,
292
+ });
293
+ }
294
+
295
+ const isUsersRole = id === "users";
296
+ const isAdminRole = id === "admin";
297
+
298
+ // System roles can have name/description edited, but not deleted
299
+ // Admin role: permissions cannot be changed (wildcard permission)
300
+ // Users role: permissions can be changed with default tracking
301
+ // User's own role: permissions cannot be changed (prevent self-elevation)
302
+
303
+ // Get active permissions to filter input
304
+ const activePermissions = new Set(
305
+ permissionRegistry.getPermissions().map((p) => p.id)
306
+ );
307
+
308
+ // Filter to only include active permissions
309
+ const validPermissions = inputPermissions.filter((p) =>
310
+ activePermissions.has(p)
311
+ );
312
+
313
+ // Track disabled authenticated default permissions for "users" role
314
+ if (isUsersRole && !isUserOwnRole) {
315
+ const allPerms = permissionRegistry.getPermissions();
316
+ const defaultPermIds = allPerms
317
+ .filter((p) => p.isAuthenticatedDefault)
318
+ .map((p) => p.id);
319
+
320
+ // Find authenticated default permissions that are being removed
321
+ const removedDefaults = defaultPermIds.filter(
322
+ (defId) => !validPermissions.includes(defId)
323
+ );
324
+
325
+ // Insert into disabled_default_permission table
326
+ for (const permId of removedDefaults) {
327
+ await internalDb
328
+ .insert(schema.disabledDefaultPermission)
329
+ .values({
330
+ permissionId: permId,
331
+ disabledAt: new Date(),
332
+ })
333
+ .onConflictDoNothing();
334
+ }
335
+
336
+ // Remove from disabled table if being re-added
337
+ const readdedDefaults = validPermissions.filter((p) =>
338
+ defaultPermIds.includes(p)
339
+ );
340
+ for (const permId of readdedDefaults) {
341
+ await internalDb
342
+ .delete(schema.disabledDefaultPermission)
343
+ .where(eq(schema.disabledDefaultPermission.permissionId, permId));
344
+ }
345
+ }
346
+
347
+ // Track disabled public default permissions for "anonymous" role
348
+ const isAnonymousRole = id === "anonymous";
349
+ if (isAnonymousRole) {
350
+ const allPerms = permissionRegistry.getPermissions();
351
+ const publicDefaultPermIds = allPerms
352
+ .filter((p) => p.isPublicDefault)
353
+ .map((p) => p.id);
354
+
355
+ // Find public default permissions that are being removed
356
+ const removedPublicDefaults = publicDefaultPermIds.filter(
357
+ (defId) => !validPermissions.includes(defId)
358
+ );
359
+
360
+ // Insert into disabled_public_default_permission table
361
+ for (const permId of removedPublicDefaults) {
362
+ await internalDb
363
+ .insert(schema.disabledPublicDefaultPermission)
364
+ .values({
365
+ permissionId: permId,
366
+ disabledAt: new Date(),
367
+ })
368
+ .onConflictDoNothing();
369
+ }
370
+
371
+ // Remove from disabled table if being re-added
372
+ const readdedPublicDefaults = validPermissions.filter((p) =>
373
+ publicDefaultPermIds.includes(p)
374
+ );
375
+ for (const permId of readdedPublicDefaults) {
376
+ await internalDb
377
+ .delete(schema.disabledPublicDefaultPermission)
378
+ .where(
379
+ eq(schema.disabledPublicDefaultPermission.permissionId, permId)
380
+ );
381
+ }
382
+ }
383
+
384
+ await internalDb.transaction(async (tx) => {
385
+ // Update role name/description if provided (allowed for ALL roles including system and own roles)
386
+ if (name !== undefined || description !== undefined) {
387
+ const updates: { name?: string; description?: string | null } = {};
388
+ if (name !== undefined) updates.name = name;
389
+ if (description !== undefined) updates.description = description;
390
+
391
+ await tx.update(schema.role).set(updates).where(eq(schema.role.id, id));
392
+ }
393
+
394
+ // Skip permission changes for admin role (wildcard) or user's own role (prevent self-elevation)
395
+ if (isAdminRole || isUserOwnRole) {
396
+ return; // Don't modify permissions
397
+ }
398
+
399
+ // Replace permission mappings for non-admin roles
400
+ await tx
401
+ .delete(schema.rolePermission)
402
+ .where(eq(schema.rolePermission.roleId, id));
403
+
404
+ if (validPermissions.length > 0) {
405
+ await tx.insert(schema.rolePermission).values(
406
+ validPermissions.map((permissionId) => ({
407
+ roleId: id,
408
+ permissionId,
409
+ }))
410
+ );
411
+ }
412
+ });
413
+ });
414
+
415
+ const deleteRole = os.deleteRole.handler(async ({ input: id, context }) => {
416
+ // Security check: prevent users from deleting their own roles
417
+ const userRoles = isRealUser(context.user) ? context.user.roles || [] : [];
418
+ if (userRoles.includes(id)) {
419
+ throw new ORPCError("FORBIDDEN", {
420
+ message: "Cannot delete a role that you currently have",
421
+ });
422
+ }
423
+
424
+ // Check if role is a system role
425
+ const existingRole = await internalDb
426
+ .select()
427
+ .from(schema.role)
428
+ .where(eq(schema.role.id, id));
429
+
430
+ if (existingRole.length === 0) {
431
+ throw new ORPCError("NOT_FOUND", {
432
+ message: `Role ${id} not found`,
433
+ });
434
+ }
435
+
436
+ if (existingRole[0].isSystem) {
437
+ throw new ORPCError("FORBIDDEN", {
438
+ message: "Cannot delete system role",
439
+ });
440
+ }
441
+
442
+ // Delete role and related records in transaction
443
+ await internalDb.transaction(async (tx) => {
444
+ // Delete role-permission mappings
445
+ await tx
446
+ .delete(schema.rolePermission)
447
+ .where(eq(schema.rolePermission.roleId, id));
448
+
449
+ // Delete user-role mappings
450
+ await tx.delete(schema.userRole).where(eq(schema.userRole.roleId, id));
451
+
452
+ // Delete the role itself
453
+ await tx.delete(schema.role).where(eq(schema.role.id, id));
454
+ });
455
+ });
456
+
457
+ const updateUserRoles = os.updateUserRoles.handler(
458
+ async ({ input, context }) => {
459
+ const { userId, roles } = input;
460
+
461
+ const currentUserId = isRealUser(context.user)
462
+ ? context.user.id
463
+ : undefined;
464
+ if (userId === currentUserId) {
465
+ throw new ORPCError("FORBIDDEN", {
466
+ message: "Cannot update your own roles",
467
+ });
468
+ }
469
+
470
+ // Prevent assignment of the "anonymous" role - it's reserved for unauthenticated users
471
+ if (roles.includes("anonymous")) {
472
+ throw new ORPCError("BAD_REQUEST", {
473
+ message: "The 'anonymous' role cannot be assigned to users",
474
+ });
475
+ }
476
+
477
+ await internalDb.transaction(async (tx) => {
478
+ await tx
479
+ .delete(schema.userRole)
480
+ .where(eq(schema.userRole.userId, userId));
481
+ if (roles.length > 0) {
482
+ await tx.insert(schema.userRole).values(
483
+ roles.map((roleId) => ({
484
+ userId,
485
+ roleId,
486
+ }))
487
+ );
488
+ }
489
+ });
490
+ }
491
+ );
492
+
493
+ const getStrategies = os.getStrategies.handler(async () => {
494
+ const registeredStrategies = strategyRegistry.getStrategies();
495
+
496
+ return Promise.all(
497
+ registeredStrategies.map(async (strategy) => {
498
+ // Get redacted config from ConfigService
499
+ const config = await configService.getRedacted(
500
+ strategy.id,
501
+ strategy.configSchema,
502
+ strategy.configVersion,
503
+ strategy.migrations
504
+ );
505
+
506
+ // Convert Zod schema to JSON Schema with automatic secret metadata
507
+ const jsonSchema = toJsonSchema(strategy.configSchema);
508
+
509
+ // Get enabled state from meta config
510
+ const enabled = await getStrategyEnabled(strategy.id, configService);
511
+
512
+ return {
513
+ id: strategy.id,
514
+ displayName: strategy.displayName,
515
+ description: strategy.description,
516
+ icon: strategy.icon,
517
+ enabled,
518
+ configVersion: strategy.configVersion,
519
+ configSchema: jsonSchema,
520
+ config,
521
+ adminInstructions: strategy.adminInstructions,
522
+ };
523
+ })
524
+ );
525
+ });
526
+
527
+ const updateStrategy = os.updateStrategy.handler(async ({ input }) => {
528
+ const { id, enabled, config } = input;
529
+ const strategy = strategyRegistry.getStrategies().find((s) => s.id === id);
530
+
531
+ if (!strategy) {
532
+ throw new ORPCError("NOT_FOUND", {
533
+ message: `Strategy ${id} not found`,
534
+ });
535
+ }
536
+
537
+ // Save strategy configuration (if provided)
538
+ if (config) {
539
+ await configService.set(
540
+ id,
541
+ strategy.configSchema,
542
+ strategy.configVersion,
543
+ config, // Just the config, no enabled mixed in
544
+ strategy.migrations
545
+ );
546
+ }
547
+
548
+ // Save enabled state separately in meta config
549
+ await setStrategyEnabled(id, enabled, configService);
550
+
551
+ // Trigger auth reload
552
+ await reloadAuthFn();
553
+
554
+ return { success: true };
555
+ });
556
+
557
+ const reloadAuth = os.reloadAuth.handler(async () => {
558
+ await reloadAuthFn();
559
+ return { success: true };
560
+ });
561
+
562
+ const getRegistrationSchema = os.getRegistrationSchema.handler(() => {
563
+ return toJsonSchema(platformRegistrationConfigV1);
564
+ });
565
+
566
+ const getRegistrationStatus = os.getRegistrationStatus.handler(async () => {
567
+ const allowRegistration = await isRegistrationAllowed(configService);
568
+ return { allowRegistration };
569
+ });
570
+
571
+ const setRegistrationStatus = os.setRegistrationStatus.handler(
572
+ async ({ input }) => {
573
+ await configService.set(
574
+ PLATFORM_REGISTRATION_CONFIG_ID,
575
+ platformRegistrationConfigV1,
576
+ PLATFORM_REGISTRATION_CONFIG_VERSION,
577
+ { allowRegistration: input.allowRegistration }
578
+ );
579
+ // Trigger auth reload to apply new settings
580
+ await reloadAuthFn();
581
+ return { success: true };
582
+ }
583
+ );
584
+
585
+ const getAnonymousPermissions = os.getAnonymousPermissions.handler(
586
+ async () => {
587
+ const rolePerms = await internalDb
588
+ .select()
589
+ .from(schema.rolePermission)
590
+ .where(eq(schema.rolePermission.roleId, "anonymous"));
591
+ return rolePerms.map((rp) => rp.permissionId);
592
+ }
593
+ );
594
+
595
+ const filterUsersByPermission = os.filterUsersByPermission.handler(
596
+ async ({ input }) => {
597
+ const { userIds, permission } = input;
598
+
599
+ if (userIds.length === 0) return [];
600
+
601
+ // Single efficient query: join user_role with role_permission
602
+ // and filter by both userIds AND the specific permission
603
+ const usersWithPermission = await internalDb
604
+ .select({ userId: schema.userRole.userId })
605
+ .from(schema.userRole)
606
+ .innerJoin(
607
+ schema.rolePermission,
608
+ eq(schema.userRole.roleId, schema.rolePermission.roleId)
609
+ )
610
+ .where(
611
+ and(
612
+ inArray(schema.userRole.userId, userIds),
613
+ eq(schema.rolePermission.permissionId, permission)
614
+ )
615
+ )
616
+ .groupBy(schema.userRole.userId);
617
+
618
+ return usersWithPermission.map((row) => row.userId);
619
+ }
620
+ );
621
+
622
+ // ==========================================================================
623
+ // SERVICE-TO-SERVICE ENDPOINTS (for external auth providers like LDAP)
624
+ // ==========================================================================
625
+
626
+ const getUserById = os.getUserById.handler(async ({ input }) => {
627
+ const users = await internalDb
628
+ .select({
629
+ id: schema.user.id,
630
+ email: schema.user.email,
631
+ name: schema.user.name,
632
+ })
633
+ .from(schema.user)
634
+ .where(eq(schema.user.id, input.userId))
635
+ .limit(1);
636
+
637
+ return users.length > 0 ? users[0] : undefined;
638
+ });
639
+
640
+ const findUserByEmail = os.findUserByEmail.handler(async ({ input }) => {
641
+ const users = await internalDb
642
+ .select({ id: schema.user.id })
643
+ .from(schema.user)
644
+ .where(eq(schema.user.email, input.email))
645
+ .limit(1);
646
+
647
+ return users.length > 0 ? { id: users[0].id } : undefined;
648
+ });
649
+
650
+ const upsertExternalUser = os.upsertExternalUser.handler(
651
+ async ({ input, context }) => {
652
+ const { email, name, providerId, accountId, password, autoUpdateUser } =
653
+ input;
654
+
655
+ // Check if user exists
656
+ const existingUsers = await internalDb
657
+ .select({ id: schema.user.id })
658
+ .from(schema.user)
659
+ .where(eq(schema.user.email, email))
660
+ .limit(1);
661
+
662
+ if (existingUsers.length > 0) {
663
+ // User exists - update if autoUpdateUser is enabled
664
+ const userId = existingUsers[0].id;
665
+
666
+ if (autoUpdateUser) {
667
+ await internalDb
668
+ .update(schema.user)
669
+ .set({ name, updatedAt: new Date() })
670
+ .where(eq(schema.user.id, userId));
671
+ }
672
+
673
+ return { userId, created: false };
674
+ }
675
+
676
+ // Check if registration is allowed before creating new user
677
+ const registrationAllowed = await isRegistrationAllowed(configService);
678
+ if (!registrationAllowed) {
679
+ throw new ORPCError("FORBIDDEN", {
680
+ message: "Registration is disabled. Please contact an administrator.",
681
+ });
682
+ }
683
+
684
+ // Create new user and account in a transaction
685
+ const userId = crypto.randomUUID();
686
+ const accountEntryId = crypto.randomUUID();
687
+ const now = new Date();
688
+
689
+ await internalDb.transaction(async (tx) => {
690
+ // Create user
691
+ await tx.insert(schema.user).values({
692
+ id: userId,
693
+ email,
694
+ name,
695
+ emailVerified: false,
696
+ createdAt: now,
697
+ updatedAt: now,
698
+ });
699
+
700
+ // Create account
701
+ await tx.insert(schema.account).values({
702
+ id: accountEntryId,
703
+ accountId,
704
+ providerId,
705
+ userId,
706
+ password,
707
+ createdAt: now,
708
+ updatedAt: now,
709
+ });
710
+ });
711
+
712
+ context.logger.info(`Created new user from ${providerId}: ${email}`);
713
+
714
+ return { userId, created: true };
715
+ }
716
+ );
717
+
718
+ const createSession = os.createSession.handler(async ({ input }) => {
719
+ const { userId, token, expiresAt } = input;
720
+ const sessionId = crypto.randomUUID();
721
+ const now = new Date();
722
+
723
+ await internalDb.insert(schema.session).values({
724
+ id: sessionId,
725
+ userId,
726
+ token,
727
+ expiresAt,
728
+ createdAt: now,
729
+ updatedAt: now,
730
+ });
731
+
732
+ return { sessionId };
733
+ });
734
+
735
+ // ==========================================================================
736
+ // ADMIN USER CREATION (bypasses registration check)
737
+ // ==========================================================================
738
+
739
+ const createCredentialUser = os.createCredentialUser.handler(
740
+ async ({ input, context }) => {
741
+ const { email, name, password } = input;
742
+
743
+ // Validate password against platform's password schema
744
+ const passwordValidation = passwordSchema.safeParse(password);
745
+ if (!passwordValidation.success) {
746
+ throw new ORPCError("BAD_REQUEST", {
747
+ message: passwordValidation.error.issues
748
+ .map((issue) => issue.message)
749
+ .join(", "),
750
+ });
751
+ }
752
+
753
+ // Check if credential strategy is enabled
754
+ const credentialEnabled = await getStrategyEnabled(
755
+ "credential",
756
+ configService
757
+ );
758
+ if (!credentialEnabled) {
759
+ throw new ORPCError("BAD_REQUEST", {
760
+ message:
761
+ "Credential strategy is not enabled. Enable it in Authentication Settings first.",
762
+ });
763
+ }
764
+
765
+ // Check if user already exists
766
+ const existingUsers = await internalDb
767
+ .select({ id: schema.user.id })
768
+ .from(schema.user)
769
+ .where(eq(schema.user.email, email))
770
+ .limit(1);
771
+
772
+ if (existingUsers.length > 0) {
773
+ throw new ORPCError("CONFLICT", {
774
+ message: "A user with this email already exists.",
775
+ });
776
+ }
777
+
778
+ // Create user directly in database (bypasses registration check)
779
+ const userId = crypto.randomUUID();
780
+ const accountId = crypto.randomUUID();
781
+ const hashedPassword = await hashPassword(password);
782
+ const now = new Date();
783
+
784
+ await internalDb.transaction(async (tx) => {
785
+ // Create user
786
+ await tx.insert(schema.user).values({
787
+ id: userId,
788
+ email,
789
+ name,
790
+ emailVerified: true, // Admin-created users are pre-verified
791
+ createdAt: now,
792
+ updatedAt: now,
793
+ });
794
+
795
+ // Create credential account
796
+ await tx.insert(schema.account).values({
797
+ id: accountId,
798
+ accountId: email,
799
+ providerId: "credential",
800
+ userId,
801
+ password: hashedPassword,
802
+ createdAt: now,
803
+ updatedAt: now,
804
+ });
805
+
806
+ // Assign "users" role to new user
807
+ await tx.insert(schema.userRole).values({
808
+ userId,
809
+ roleId: "users",
810
+ });
811
+ });
812
+
813
+ context.logger.info(
814
+ `[auth-backend] Admin created credential user: ${email}`
815
+ );
816
+
817
+ return { userId };
818
+ }
819
+ );
820
+
821
+ // ==========================================================================
822
+ // APPLICATION MANAGEMENT
823
+ // External applications (API keys) with RBAC integration
824
+ // ==========================================================================
825
+
826
+ const getApplications = os.getApplications.handler(async () => {
827
+ const apps = await internalDb.select().from(schema.application);
828
+ if (apps.length === 0) return [];
829
+
830
+ const appRoles = await internalDb
831
+ .select()
832
+ .from(schema.applicationRole)
833
+ .where(
834
+ inArray(
835
+ schema.applicationRole.applicationId,
836
+ apps.map((a) => a.id)
837
+ )
838
+ );
839
+
840
+ return apps.map((app) => ({
841
+ id: app.id,
842
+ name: app.name,
843
+ description: app.description,
844
+ roles: appRoles
845
+ .filter((ar) => ar.applicationId === app.id)
846
+ .map((ar) => ar.roleId),
847
+ createdById: app.createdById,
848
+ createdAt: app.createdAt,
849
+ lastUsedAt: app.lastUsedAt,
850
+ }));
851
+ });
852
+
853
+ const createApplication = os.createApplication.handler(
854
+ async ({ input, context }) => {
855
+ const { name, description } = input;
856
+
857
+ const userId = isRealUser(context.user) ? context.user.id : undefined;
858
+ if (!userId) {
859
+ throw new ORPCError("UNAUTHORIZED", {
860
+ message: "User ID required to create application",
861
+ });
862
+ }
863
+
864
+ const id = crypto.randomUUID();
865
+ const secret = generateSecret();
866
+ // Hash with bcrypt via better-auth's hashPassword
867
+ const secretHash = await hashPassword(secret);
868
+ const now = new Date();
869
+
870
+ // Default role for all applications
871
+ const defaultRole = "applications";
872
+
873
+ await internalDb.transaction(async (tx) => {
874
+ // Create application
875
+ await tx.insert(schema.application).values({
876
+ id,
877
+ name,
878
+ description: description ?? undefined,
879
+ secretHash,
880
+ createdById: userId,
881
+ createdAt: now,
882
+ updatedAt: now,
883
+ });
884
+
885
+ // Assign default "applications" role
886
+ await tx.insert(schema.applicationRole).values({
887
+ applicationId: id,
888
+ roleId: defaultRole,
889
+ });
890
+ });
891
+
892
+ context.logger.info(
893
+ `[auth-backend] Created application: ${name} (${id})`
894
+ );
895
+
896
+ return {
897
+ application: {
898
+ id,
899
+ name,
900
+ description: description ?? undefined,
901
+ roles: [defaultRole],
902
+ createdById: userId,
903
+ createdAt: now,
904
+ },
905
+ secret: `ck_${id}_${secret}`, // Full secret - only shown once!
906
+ };
907
+ }
908
+ );
909
+
910
+ const updateApplication = os.updateApplication.handler(async ({ input }) => {
911
+ const { id, name, description, roles } = input;
912
+
913
+ // Check if application exists
914
+ const existing = await internalDb
915
+ .select()
916
+ .from(schema.application)
917
+ .where(eq(schema.application.id, id))
918
+ .limit(1);
919
+
920
+ if (existing.length === 0) {
921
+ throw new ORPCError("NOT_FOUND", {
922
+ message: `Application ${id} not found`,
923
+ });
924
+ }
925
+
926
+ await internalDb.transaction(async (tx) => {
927
+ // Update application fields
928
+ const updates: {
929
+ name?: string;
930
+ description?: string | null;
931
+ updatedAt: Date;
932
+ } = {
933
+ updatedAt: new Date(),
934
+ };
935
+ if (name !== undefined) updates.name = name;
936
+ if (description !== undefined) updates.description = description;
937
+
938
+ await tx
939
+ .update(schema.application)
940
+ .set(updates)
941
+ .where(eq(schema.application.id, id));
942
+
943
+ // Update roles if provided
944
+ if (roles !== undefined) {
945
+ // Delete existing role mappings
946
+ await tx
947
+ .delete(schema.applicationRole)
948
+ .where(eq(schema.applicationRole.applicationId, id));
949
+
950
+ // Insert new role mappings
951
+ if (roles.length > 0) {
952
+ await tx.insert(schema.applicationRole).values(
953
+ roles.map((roleId) => ({
954
+ applicationId: id,
955
+ roleId,
956
+ }))
957
+ );
958
+ }
959
+ }
960
+ });
961
+ });
962
+
963
+ const deleteApplication = os.deleteApplication.handler(
964
+ async ({ input: id, context }) => {
965
+ // Check if application exists
966
+ const existing = await internalDb
967
+ .select()
968
+ .from(schema.application)
969
+ .where(eq(schema.application.id, id))
970
+ .limit(1);
971
+
972
+ if (existing.length === 0) {
973
+ throw new ORPCError("NOT_FOUND", {
974
+ message: `Application ${id} not found`,
975
+ });
976
+ }
977
+
978
+ // Cascade delete is handled by FK constraint on applicationRole
979
+ // Just delete the application
980
+ await internalDb
981
+ .delete(schema.application)
982
+ .where(eq(schema.application.id, id));
983
+
984
+ context.logger.info(`[auth-backend] Deleted application: ${id}`);
985
+ }
986
+ );
987
+
988
+ const regenerateApplicationSecret = os.regenerateApplicationSecret.handler(
989
+ async ({ input: id, context }) => {
990
+ // Check if application exists
991
+ const existing = await internalDb
992
+ .select()
993
+ .from(schema.application)
994
+ .where(eq(schema.application.id, id))
995
+ .limit(1);
996
+
997
+ if (existing.length === 0) {
998
+ throw new ORPCError("NOT_FOUND", {
999
+ message: `Application ${id} not found`,
1000
+ });
1001
+ }
1002
+
1003
+ const secret = generateSecret();
1004
+ const secretHash = await hashPassword(secret);
1005
+
1006
+ await internalDb
1007
+ .update(schema.application)
1008
+ .set({ secretHash, updatedAt: new Date() })
1009
+ .where(eq(schema.application.id, id));
1010
+
1011
+ context.logger.info(
1012
+ `[auth-backend] Regenerated secret for application: ${id}`
1013
+ );
1014
+
1015
+ return { secret: `ck_${id}_${secret}` };
1016
+ }
1017
+ );
1018
+
1019
+ return os.router({
1020
+ getEnabledStrategies,
1021
+ permissions,
1022
+ getUsers,
1023
+ deleteUser,
1024
+ getRoles,
1025
+ getPermissions,
1026
+ createRole,
1027
+ updateRole,
1028
+ deleteRole,
1029
+ updateUserRoles,
1030
+ getStrategies,
1031
+ updateStrategy,
1032
+ reloadAuth,
1033
+ getRegistrationSchema,
1034
+ getRegistrationStatus,
1035
+ setRegistrationStatus,
1036
+ getAnonymousPermissions,
1037
+ getUserById,
1038
+ filterUsersByPermission,
1039
+ findUserByEmail,
1040
+ upsertExternalUser,
1041
+ createSession,
1042
+ createCredentialUser,
1043
+ getApplications,
1044
+ createApplication,
1045
+ updateApplication,
1046
+ deleteApplication,
1047
+ regenerateApplicationSecret,
1048
+ });
1049
+ };
1050
+
1051
+ export type AuthRouter = ReturnType<typeof createAuthRouter>;