@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.
@@ -0,0 +1,13 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Meta-configuration schema for authentication strategies.
5
+ * Stores platform-level properties separate from strategy-specific config.
6
+ */
7
+ export const strategyMetaConfigV1 = z.object({
8
+ enabled: z.boolean(),
9
+ });
10
+
11
+ export type StrategyMetaConfig = z.infer<typeof strategyMetaConfigV1>;
12
+
13
+ export const STRATEGY_META_CONFIG_VERSION = 1;
@@ -0,0 +1,25 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Platform-level configuration for user registration.
5
+ * This meta-config controls whether new users can register via any authentication method.
6
+ */
7
+ export const platformRegistrationConfigV1 = z.object({
8
+ /**
9
+ * Whether new user registration is allowed.
10
+ * When false, only existing users can authenticate.
11
+ */
12
+ allowRegistration: z
13
+ .boolean()
14
+ .default(true)
15
+ .describe(
16
+ "When enabled, new users can create accounts. When disabled, only existing users can sign in."
17
+ ),
18
+ });
19
+
20
+ export type PlatformRegistrationConfig = z.infer<
21
+ typeof platformRegistrationConfigV1
22
+ >;
23
+
24
+ export const PLATFORM_REGISTRATION_CONFIG_VERSION = 1;
25
+ export const PLATFORM_REGISTRATION_CONFIG_ID = "platform.registration";
@@ -0,0 +1,440 @@
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import { createAuthRouter } from "./router";
3
+ import { createMockRpcContext } from "@checkstack/backend-api";
4
+ import { call } from "@orpc/server";
5
+ import { z } from "zod";
6
+ import * as schema from "./schema";
7
+
8
+ // Mock better-auth
9
+ const mockAuth: any = {
10
+ handler: mock(),
11
+ api: {
12
+ getSession: mock(),
13
+ },
14
+ };
15
+
16
+ describe("Auth Router", () => {
17
+ const mockUser = {
18
+ type: "user" as const,
19
+ id: "test-user",
20
+ permissions: ["*"],
21
+ roles: ["admin"],
22
+ } as any;
23
+
24
+ const createChain = (data: any = []) => {
25
+ const chain: any = {
26
+ where: mock(() => chain),
27
+ innerJoin: mock(() => chain),
28
+ limit: mock(() => chain),
29
+ offset: mock(() => chain),
30
+ orderBy: mock(() => chain),
31
+ onConflictDoUpdate: mock(() => Promise.resolve()),
32
+ then: (resolve: any) => Promise.resolve(resolve(data)),
33
+ };
34
+ return chain;
35
+ };
36
+
37
+ const mockDb: any = {
38
+ select: mock(() => ({
39
+ from: mock(() => createChain([])),
40
+ })),
41
+ insert: mock(() => ({
42
+ values: mock(() => createChain()),
43
+ })),
44
+ delete: mock(() => ({
45
+ where: mock(() => Promise.resolve()),
46
+ })),
47
+ transaction: mock((cb: any) => cb(mockDb)), // Updated reference to mockDb
48
+ };
49
+
50
+ const mockRegistry = {
51
+ getStrategies: () => [
52
+ {
53
+ id: "credential",
54
+ displayName: "Credentials",
55
+ description: "Email and password authentication",
56
+ configSchema: z.object({ enabled: z.boolean() }),
57
+ configVersion: 1,
58
+ migrations: [],
59
+ requiresManualRegistration: true,
60
+ },
61
+ ],
62
+ };
63
+
64
+ const mockConfigService: any = {
65
+ get: mock(() => Promise.resolve(undefined)),
66
+ getRedacted: mock(() => Promise.resolve({})),
67
+ set: mock(() => Promise.resolve()),
68
+ delete: mock(() => Promise.resolve()),
69
+ list: mock(() => Promise.resolve([])),
70
+ };
71
+
72
+ const mockPermissionRegistry = {
73
+ getPermissions: () => [
74
+ { id: "auth-backend.users.read", description: "List all users" },
75
+ { id: "auth-backend.users.manage", description: "Delete users" },
76
+ { id: "auth-backend.roles.read", description: "Read and list roles" },
77
+ ],
78
+ };
79
+
80
+ const router = createAuthRouter(
81
+ mockDb,
82
+ mockRegistry,
83
+ async () => {},
84
+ mockConfigService,
85
+ mockPermissionRegistry
86
+ );
87
+
88
+ it("getPermissions returns current user permissions", async () => {
89
+ const context = createMockRpcContext({ user: mockUser });
90
+ const result = await call(router.permissions, undefined, { context });
91
+ expect(result.permissions).toContain("*");
92
+ });
93
+
94
+ it("getUsers lists users with roles", async () => {
95
+ const context = createMockRpcContext({ user: mockUser });
96
+
97
+ mockDb.select.mockImplementationOnce(() => ({
98
+ from: mock(() =>
99
+ createChain([{ id: "1", email: "user1@test.com", name: "User 1" }])
100
+ ),
101
+ }));
102
+ mockDb.select.mockImplementationOnce(() => ({
103
+ from: mock(() => createChain([{ userId: "1", roleId: "admin" }])),
104
+ }));
105
+
106
+ const result = await call(router.getUsers, undefined, { context });
107
+ expect(result).toHaveLength(1);
108
+ expect(result[0].roles).toContain("admin");
109
+ });
110
+
111
+ it("deleteUser prevents deleting initial admin", async () => {
112
+ const context = createMockRpcContext({ user: mockUser });
113
+ expect(
114
+ call(router.deleteUser, "initial-admin-id", { context })
115
+ ).rejects.toThrow("Cannot delete initial admin");
116
+ });
117
+
118
+ it("deleteUser cascades to delete related records", async () => {
119
+ const context = createMockRpcContext({ user: mockUser });
120
+ const userId = "user-to-delete";
121
+
122
+ // Track which tables had delete called on them
123
+ const deletedTables: any[] = [];
124
+ const mockTx: any = {
125
+ delete: mock((table: any) => {
126
+ deletedTables.push(table); // Track table
127
+ return {
128
+ where: mock(() => Promise.resolve()),
129
+ };
130
+ }),
131
+ };
132
+
133
+ mockDb.transaction.mockImplementationOnce((cb: any) => cb(mockTx));
134
+
135
+ await call(router.deleteUser, userId, { context });
136
+
137
+ // Verify transaction was used
138
+ expect(mockDb.transaction).toHaveBeenCalled();
139
+
140
+ // Verify all related tables were deleted in order
141
+ expect(deletedTables).toHaveLength(4);
142
+ expect(deletedTables.includes(schema.userRole)).toBe(true);
143
+ expect(deletedTables.includes(schema.session)).toBe(true);
144
+ expect(deletedTables.includes(schema.account)).toBe(true);
145
+ expect(deletedTables.includes(schema.user)).toBe(true);
146
+ });
147
+
148
+ it("getRoles returns all roles with permissions", async () => {
149
+ const context = createMockRpcContext({ user: mockUser });
150
+ mockDb.select.mockImplementationOnce(() => ({
151
+ from: mock(() => createChain([{ id: "admin", name: "Admin" }])),
152
+ }));
153
+ mockDb.select.mockImplementationOnce(() => ({
154
+ from: mock(() =>
155
+ createChain([{ roleId: "admin", permissionId: "users.manage" }])
156
+ ),
157
+ }));
158
+
159
+ const result = await call(router.getRoles, undefined, { context });
160
+ expect(result).toHaveLength(1);
161
+ expect(result[0].id).toBe("admin");
162
+ expect(result[0].permissions).toContain("users.manage");
163
+ });
164
+
165
+ it("updateUserRoles updates user roles", async () => {
166
+ const context = createMockRpcContext({ user: mockUser });
167
+
168
+ const result = await call(
169
+ router.updateUserRoles,
170
+ { userId: "other-user", roles: ["admin"] },
171
+ { context }
172
+ );
173
+ // updateUserRoles returns void, so just check it completed
174
+ expect(result).toBeUndefined();
175
+ expect(mockDb.transaction).toHaveBeenCalled();
176
+ });
177
+
178
+ it("updateUserRoles prevents updating own roles", async () => {
179
+ const context = createMockRpcContext({ user: mockUser });
180
+
181
+ expect(
182
+ call(
183
+ router.updateUserRoles,
184
+ { userId: "test-user", roles: ["admin"] },
185
+ { context }
186
+ )
187
+ ).rejects.toThrow("Cannot update your own roles");
188
+ });
189
+
190
+ it("getStrategies returns available strategies", async () => {
191
+ const context = createMockRpcContext({ user: mockUser });
192
+
193
+ const result = await call(router.getStrategies, undefined, { context });
194
+ expect(result.some((s: any) => s.id === "credential")).toBe(true);
195
+ });
196
+
197
+ it("updateStrategy updates strategy enabled status", async () => {
198
+ const context = createMockRpcContext({ user: mockUser });
199
+
200
+ const result = await call(
201
+ router.updateStrategy,
202
+ { id: "credential", enabled: false },
203
+ { context }
204
+ );
205
+ expect(result.success).toBe(true);
206
+ expect(mockConfigService.set).toHaveBeenCalled();
207
+ });
208
+
209
+ it("getRegistrationStatus returns default true", async () => {
210
+ const context = createMockRpcContext({ user: undefined }); // Public endpoint
211
+ const result = await call(router.getRegistrationStatus, undefined, {
212
+ context,
213
+ });
214
+ expect(result.allowRegistration).toBe(true);
215
+ });
216
+
217
+ it("setRegistrationStatus updates flag and requires permission", async () => {
218
+ const context = createMockRpcContext({ user: mockUser });
219
+ const result = await call(
220
+ router.setRegistrationStatus,
221
+ { allowRegistration: false },
222
+ { context }
223
+ );
224
+ expect(result.success).toBe(true);
225
+ expect(mockConfigService.set).toHaveBeenCalledWith(
226
+ "platform.registration",
227
+ expect.anything(),
228
+ 1,
229
+ { allowRegistration: false }
230
+ );
231
+ });
232
+
233
+ // ==========================================================================
234
+ // SERVICE-TO-SERVICE TESTS
235
+ // ==========================================================================
236
+
237
+ const mockServiceUser = {
238
+ type: "service" as const,
239
+ pluginId: "auth-ldap-backend",
240
+ } as any;
241
+
242
+ it("findUserByEmail returns user when found", async () => {
243
+ const context = createMockRpcContext({ user: mockServiceUser });
244
+
245
+ mockDb.select.mockImplementationOnce(() => ({
246
+ from: mock(() => createChain([{ id: "user-123" }])),
247
+ }));
248
+
249
+ const result = await call(
250
+ router.findUserByEmail,
251
+ { email: "test@example.com" },
252
+ { context }
253
+ );
254
+ expect(result).toEqual({ id: "user-123" });
255
+ });
256
+
257
+ it("findUserByEmail returns undefined when not found", async () => {
258
+ const context = createMockRpcContext({ user: mockServiceUser });
259
+
260
+ mockDb.select.mockImplementationOnce(() => ({
261
+ from: mock(() => createChain([])),
262
+ }));
263
+
264
+ const result = await call(
265
+ router.findUserByEmail,
266
+ { email: "nonexistent@example.com" },
267
+ { context }
268
+ );
269
+ expect(result).toBeUndefined();
270
+ });
271
+
272
+ it("upsertExternalUser creates new user and account", async () => {
273
+ const context = createMockRpcContext({ user: mockServiceUser });
274
+
275
+ // Mock user not found (empty result)
276
+ mockDb.select.mockImplementationOnce(() => ({
277
+ from: mock(() => createChain([])),
278
+ }));
279
+
280
+ // Mock registration allowed
281
+ mockConfigService.get.mockResolvedValueOnce({ allowRegistration: true });
282
+
283
+ const result = await call(
284
+ router.upsertExternalUser,
285
+ {
286
+ email: "ldap-user@example.com",
287
+ name: "LDAP User",
288
+ providerId: "ldap",
289
+ accountId: "ldapuser",
290
+ password: "hashed-password",
291
+ },
292
+ { context }
293
+ );
294
+
295
+ expect(result.created).toBe(true);
296
+ expect(result.userId).toBeDefined();
297
+ expect(mockDb.transaction).toHaveBeenCalled();
298
+ });
299
+
300
+ it("upsertExternalUser updates existing user when autoUpdateUser is true", async () => {
301
+ const context = createMockRpcContext({ user: mockServiceUser });
302
+
303
+ // Mock existing user found
304
+ mockDb.select.mockImplementationOnce(() => ({
305
+ from: mock(() => createChain([{ id: "existing-user-id" }])),
306
+ }));
307
+
308
+ // Mock update chain
309
+ mockDb.update = mock(() => ({
310
+ set: mock(() => ({
311
+ where: mock(() => Promise.resolve()),
312
+ })),
313
+ }));
314
+
315
+ const result = await call(
316
+ router.upsertExternalUser,
317
+ {
318
+ email: "existing@example.com",
319
+ name: "Updated Name",
320
+ providerId: "ldap",
321
+ accountId: "existinguser",
322
+ password: "hashed-password",
323
+ autoUpdateUser: true,
324
+ },
325
+ { context }
326
+ );
327
+
328
+ expect(result.created).toBe(false);
329
+ expect(result.userId).toBe("existing-user-id");
330
+ expect(mockDb.update).toHaveBeenCalled();
331
+ });
332
+
333
+ it("createSession creates session record", async () => {
334
+ const context = createMockRpcContext({ user: mockServiceUser });
335
+
336
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
337
+
338
+ const result = await call(
339
+ router.createSession,
340
+ {
341
+ userId: "user-123",
342
+ token: "session-token",
343
+ expiresAt,
344
+ },
345
+ { context }
346
+ );
347
+
348
+ expect(result.sessionId).toBeDefined();
349
+ expect(mockDb.insert).toHaveBeenCalled();
350
+ });
351
+
352
+ // ==========================================================================
353
+ // ADMIN USER CREATION TESTS
354
+ // ==========================================================================
355
+
356
+ it("createCredentialUser creates user with valid data", async () => {
357
+ const context = createMockRpcContext({ user: mockUser });
358
+
359
+ // Mock credential strategy enabled
360
+ mockConfigService.get.mockResolvedValueOnce({ enabled: true });
361
+
362
+ // Mock user not found (empty result for email check)
363
+ mockDb.select.mockImplementationOnce(() => ({
364
+ from: mock(() => createChain([])),
365
+ }));
366
+
367
+ const result = await call(
368
+ router.createCredentialUser,
369
+ {
370
+ email: "newuser@example.com",
371
+ name: "New User",
372
+ password: "ValidPass123",
373
+ },
374
+ { context }
375
+ );
376
+
377
+ expect(result.userId).toBeDefined();
378
+ expect(mockDb.transaction).toHaveBeenCalled();
379
+ });
380
+
381
+ it("createCredentialUser rejects weak password", async () => {
382
+ const context = createMockRpcContext({ user: mockUser });
383
+
384
+ // Weak password - no uppercase
385
+ expect(
386
+ call(
387
+ router.createCredentialUser,
388
+ {
389
+ email: "test@example.com",
390
+ name: "Test User",
391
+ password: "weakpass1",
392
+ },
393
+ { context }
394
+ )
395
+ ).rejects.toThrow("uppercase");
396
+ });
397
+
398
+ it("createCredentialUser rejects duplicate email", async () => {
399
+ const context = createMockRpcContext({ user: mockUser });
400
+
401
+ // Mock credential strategy enabled
402
+ mockConfigService.get.mockResolvedValueOnce({ enabled: true });
403
+
404
+ // Mock user already exists
405
+ mockDb.select.mockImplementationOnce(() => ({
406
+ from: mock(() => createChain([{ id: "existing-user" }])),
407
+ }));
408
+
409
+ expect(
410
+ call(
411
+ router.createCredentialUser,
412
+ {
413
+ email: "existing@example.com",
414
+ name: "Existing User",
415
+ password: "ValidPass123",
416
+ },
417
+ { context }
418
+ )
419
+ ).rejects.toThrow("already exists");
420
+ });
421
+
422
+ it("createCredentialUser rejects when credential strategy disabled", async () => {
423
+ const context = createMockRpcContext({ user: mockUser });
424
+
425
+ // Mock credential strategy disabled
426
+ mockConfigService.get.mockResolvedValueOnce({ enabled: false });
427
+
428
+ expect(
429
+ call(
430
+ router.createCredentialUser,
431
+ {
432
+ email: "test@example.com",
433
+ name: "Test User",
434
+ password: "ValidPass123",
435
+ },
436
+ { context }
437
+ )
438
+ ).rejects.toThrow("not enabled");
439
+ });
440
+ });