@rebasepro/server-core 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +62 -25
  2. package/dist/common/src/collections/default-collections.d.ts +5 -8
  3. package/dist/common/src/data/query_builder.d.ts +6 -2
  4. package/dist/common/src/util/permissions.d.ts +14 -6
  5. package/dist/index.es.js +393 -315
  6. package/dist/index.es.js.map +1 -1
  7. package/dist/index.umd.js +393 -315
  8. package/dist/index.umd.js.map +1 -1
  9. package/dist/server-core/src/api/errors.d.ts +15 -0
  10. package/dist/server-core/src/api/rest/query-parser.d.ts +2 -0
  11. package/dist/server-core/src/api/types.d.ts +2 -1
  12. package/dist/server-core/src/auth/jwt.d.ts +10 -0
  13. package/dist/server-core/src/email/types.d.ts +1 -0
  14. package/dist/server-core/src/init.d.ts +31 -1
  15. package/dist/types/src/controllers/auth.d.ts +2 -2
  16. package/dist/types/src/controllers/client.d.ts +25 -40
  17. package/dist/types/src/controllers/data.d.ts +21 -3
  18. package/dist/types/src/controllers/data_driver.d.ts +5 -0
  19. package/dist/types/src/controllers/email.d.ts +2 -0
  20. package/dist/types/src/types/auth_adapter.d.ts +3 -56
  21. package/dist/types/src/types/backend.d.ts +38 -3
  22. package/dist/types/src/types/backend_hooks.d.ts +2 -17
  23. package/dist/types/src/types/collections.d.ts +30 -6
  24. package/dist/types/src/types/entity_views.d.ts +19 -28
  25. package/dist/types/src/types/properties.d.ts +9 -15
  26. package/dist/types/src/types/user_management_delegate.d.ts +16 -53
  27. package/dist/types/src/users/index.d.ts +0 -1
  28. package/dist/types/src/users/user.d.ts +0 -1
  29. package/package.json +5 -5
  30. package/src/api/errors.ts +20 -1
  31. package/src/api/openapi-generator.ts +1 -1
  32. package/src/api/rest/api-generator.ts +82 -24
  33. package/src/api/rest/query-parser.ts +184 -63
  34. package/src/api/server.ts +1 -1
  35. package/src/api/types.ts +2 -1
  36. package/src/auth/admin-routes.ts +2 -91
  37. package/src/auth/builtin-auth-adapter.ts +9 -70
  38. package/src/auth/custom-auth-adapter.ts +1 -1
  39. package/src/auth/jwt.ts +10 -0
  40. package/src/auth/routes.ts +5 -9
  41. package/src/email/smtp-email-service.ts +31 -0
  42. package/src/email/types.ts +1 -0
  43. package/src/init.ts +135 -31
  44. package/src/storage/image-transform.ts +2 -1
  45. package/test/admin-routes.test.ts +0 -169
  46. package/test/backend-hooks-admin.test.ts +0 -25
  47. package/test/custom-auth-adapter.test.ts +2 -10
  48. package/test/smtp-email-service.test.ts +169 -0
  49. package/dist/types/src/users/roles.d.ts +0 -14
  50. package/test.ts +0 -6
@@ -1,6 +1,18 @@
1
1
  import { createTransport, Transporter } from "nodemailer";
2
2
  import { EmailService, EmailSendOptions, EmailConfig } from "./types";
3
3
 
4
+ /**
5
+ * Safely parse a hostname from a URL string
6
+ */
7
+ function getHostname(urlStr: string): string | undefined {
8
+ try {
9
+ const url = new URL(urlStr.includes("://") ? urlStr : `https://${urlStr}`);
10
+ return url.hostname;
11
+ } catch {
12
+ return undefined;
13
+ }
14
+ }
15
+
4
16
  /**
5
17
  * SMTP Email Service implementation using Nodemailer
6
18
  */
@@ -12,7 +24,26 @@ export class SMTPEmailService implements EmailService {
12
24
  this.config = config;
13
25
 
14
26
  if (config.smtp) {
27
+ let smtpName = config.smtp.name;
28
+ if (!smtpName) {
29
+ const urlsToTry = [
30
+ process.env.FRONTEND_URL,
31
+ config.resetPasswordUrl,
32
+ config.verifyEmailUrl
33
+ ];
34
+ for (const urlStr of urlsToTry) {
35
+ if (urlStr) {
36
+ const hostname = getHostname(urlStr);
37
+ if (hostname) {
38
+ smtpName = hostname;
39
+ break;
40
+ }
41
+ }
42
+ }
43
+ }
44
+
15
45
  this.transporter = createTransport({
46
+ name: smtpName,
16
47
  host: config.smtp.host,
17
48
  port: config.smtp.port,
18
49
  secure: config.smtp.secure ?? (config.smtp.port === 465),
@@ -22,6 +22,7 @@ export interface SMTPConfig {
22
22
  user: string;
23
23
  pass: string;
24
24
  };
25
+ name?: string;
25
26
  }
26
27
 
27
28
  /**
package/src/init.ts CHANGED
@@ -1,7 +1,21 @@
1
- import { DataDriver, EntityCollection, BackendBootstrapper, BootstrappedAuth, RealtimeProvider, HealthCheckResult, InitializedDriver, isSQLAdmin, BackendHooks, AuthAdapter, DatabaseAdapter } from "@rebasepro/types";
1
+ import {
2
+ AuthAdapter,
3
+ BackendBootstrapper,
4
+ BackendHooks,
5
+ BootstrappedAuth,
6
+ DatabaseAdapter,
7
+ DataDriver,
8
+ EntityCollection,
9
+ HealthCheckResult,
10
+ InitializedDriver,
11
+ isPostgresCollection,
12
+ isSQLAdmin,
13
+ RealtimeProvider,
14
+ SecurityRule
15
+ } from "@rebasepro/types";
2
16
  import { BackendCollectionRegistry } from "./collections/BackendCollectionRegistry";
3
17
  import { loadCollectionsFromDirectory } from "./collections/loader";
4
- import { DriverRegistry, DEFAULT_DRIVER_ID, DefaultDriverRegistry } from "./services/driver-registry";
18
+ import { DEFAULT_DRIVER_ID, DefaultDriverRegistry, DriverRegistry } from "./services/driver-registry";
5
19
  import { Server } from "http";
6
20
 
7
21
  import { RestApiGenerator } from "./api/rest/api-generator";
@@ -16,21 +30,46 @@ import { HonoEnv } from "./api/types";
16
30
  import { configureLogLevel } from "./utils/logging";
17
31
  import { logger } from "./utils/logger";
18
32
  import { requestLogger } from "./utils/request-logger";
19
- import { createAdminRoutes, createAuthRoutes, requireAuth, requireAdmin, configureJwt } from "./auth";
20
- import { createStorageController, createStorageRoutes, DEFAULT_STORAGE_ID, DefaultStorageRegistry, BackendStorageConfig, StorageController, StorageRegistry } from "./storage";
33
+ import { configureJwt, requireAdmin, requireAuth } from "./auth";
34
+ import {
35
+ BackendStorageConfig,
36
+ createStorageController,
37
+ createStorageRoutes,
38
+ DEFAULT_STORAGE_ID,
39
+ DefaultStorageRegistry,
40
+ StorageController,
41
+ StorageRegistry
42
+ } from "./storage";
43
+ import type { ApiKeyStore } from "./auth/api-keys/api-key-store";
21
44
  import { createApiKeyStore } from "./auth/api-keys/api-key-store";
22
45
  import { createApiKeyRoutes } from "./auth/api-keys/api-key-routes";
23
- import type { ApiKeyStore } from "./auth/api-keys/api-key-store";
24
46
  import { createApiKeyRateLimiter } from "./auth/rate-limiter";
25
47
  import { createRebaseClient } from "@rebasepro/client";
48
+
26
49
  import { createHistoryRoutes } from "./history";
27
- import { EmailConfig, createEmailService } from "./email";
28
50
  import type { EmailService } from "./email";
51
+ import { createEmailService, EmailConfig } from "./email";
29
52
  import type { OAuthProvider } from "./auth/interfaces";
30
53
  import type { AuthHooks } from "./auth/auth-hooks";
31
54
  import { _initRebase } from "./singleton";
32
55
 
33
56
  export interface RebaseAuthConfig {
57
+ /**
58
+ * The collection that represents auth users.
59
+ *
60
+ * When provided, this collection's underlying database table is used
61
+ * for all auth operations (login, registration, password reset, etc.).
62
+ *
63
+ * Import the built-in default:
64
+ * ```ts
65
+ * import { defaultUsersCollection } from "@rebasepro/common";
66
+ * auth: { collection: defaultUsersCollection, jwtSecret: "..." }
67
+ * ```
68
+ *
69
+ * Or pass your own collection with the required auth fields
70
+ * (email, passwordHash, displayName, etc.).
71
+ */
72
+ collection?: EntityCollection;
34
73
  jwtSecret?: string;
35
74
  accessExpiresIn?: string;
36
75
  refreshExpiresIn?: string;
@@ -83,6 +122,7 @@ export interface RebaseAuthConfig {
83
122
  * ```
84
123
  */
85
124
  hooks?: AuthHooks;
125
+
86
126
  [key: string]: unknown;
87
127
  }
88
128
 
@@ -135,6 +175,20 @@ export interface RebaseBackendConfig {
135
175
  */
136
176
  storage?: BackendStorageConfig | StorageController | Record<string, BackendStorageConfig | StorageController>;
137
177
  history?: unknown;
178
+ /**
179
+ * Default security rules applied to any collection that does not define
180
+ * its own `securityRules`. Opt-in — if not set, collections without
181
+ * explicit rules remain unrestricted (beyond `requireAuth`).
182
+ *
183
+ * @example
184
+ * ```ts
185
+ * defaultSecurityRules: [
186
+ * { operation: "select", access: "public" },
187
+ * { operations: ["insert", "update", "delete"], roles: ["admin"] }
188
+ * ]
189
+ * ```
190
+ */
191
+ defaultSecurityRules?: SecurityRule[];
138
192
  enableSwagger?: boolean;
139
193
  functionsDir?: string;
140
194
  cronsDir?: string;
@@ -218,11 +272,13 @@ export interface RebaseBackendInstance {
218
272
  storageController?: StorageController;
219
273
  collectionRegistry: BackendCollectionRegistry;
220
274
  cronScheduler?: import("./cron").CronScheduler;
275
+
221
276
  /**
222
277
  * Deep health check that verifies database connectivity.
223
278
  * Returns latency and component status.
224
279
  */
225
280
  healthCheck(): Promise<HealthCheckResult>;
281
+
226
282
  /**
227
283
  * Graceful shutdown helper for the BaaS instance.
228
284
  * Stops the cron scheduler and closes the HTTP server, allowing
@@ -288,8 +344,20 @@ async function _initializeRebaseBackend(config: RebaseBackendConfig): Promise<Re
288
344
  let activeCollections = config.collections || [];
289
345
  if (config.collectionsDir && activeCollections.length === 0) {
290
346
  activeCollections = await loadCollectionsFromDirectory(config.collectionsDir);
291
- logger.info("Auto-discovered collections", { count: activeCollections.length,
292
- dir: config.collectionsDir });
347
+ logger.info("Auto-discovered collections", {
348
+ count: activeCollections.length,
349
+ dir: config.collectionsDir
350
+ });
351
+ }
352
+
353
+ // Apply default security rules to collections that don't define their own
354
+ if (config.defaultSecurityRules?.length) {
355
+ for (const collection of activeCollections) {
356
+ if (isPostgresCollection(collection) && (!collection.securityRules || collection.securityRules.length === 0)) {
357
+ collection.securityRules = config.defaultSecurityRules;
358
+ }
359
+ }
360
+ logger.info("Default security rules applied to collections without explicit rules");
293
361
  }
294
362
 
295
363
  const realtimeServices: Record<string, RealtimeProvider> = {};
@@ -327,14 +395,16 @@ dir: config.collectionsDir });
327
395
 
328
396
  // 1. Initialize all drivers
329
397
  for (const bootstrapper of bootstrappers) {
330
- const b = bootstrapper as BackendBootstrapper & { id?: string; isDefault?: boolean };
398
+ const b = bootstrapper;
331
399
  logger.info("Running bootstrapper for driver", { driverId: b.id || bootstrapper.type });
332
400
  if (b.isDefault) {
333
401
  defaultDriverId = b.id || bootstrapper.type;
334
402
  }
335
403
 
336
- const driverResult = await bootstrapper.initializeDriver({ collections: activeCollections,
337
- collectionRegistry });
404
+ const driverResult = await bootstrapper.initializeDriver({
405
+ collections: activeCollections,
406
+ collectionRegistry
407
+ });
338
408
  delegates[b.id || bootstrapper.type] = driverResult.driver;
339
409
 
340
410
  if ((b.id || bootstrapper.type) === defaultDriverId || !defaultDriverResult) {
@@ -343,7 +413,9 @@ collectionRegistry });
343
413
 
344
414
  if (bootstrapper.initializeRealtime) {
345
415
  const realtime = await bootstrapper.initializeRealtime({}, driverResult);
346
- realtimeServices[b.id || bootstrapper.type] = realtime as RealtimeProvider;
416
+ if (realtime) {
417
+ realtimeServices[b.id || bootstrapper.type] = realtime;
418
+ }
347
419
  }
348
420
  }
349
421
 
@@ -354,7 +426,7 @@ collectionRegistry });
354
426
  if (!defaultDriver || !defaultDriverResult) {
355
427
  throw new Error("Default driver not initialized by bootstrappers");
356
428
  }
357
- const defaultBootstrapper = bootstrappers.find(b => (b as BackendBootstrapper & { id?: string }).id === defaultDriverId || b.type === defaultDriverId) || bootstrappers[0];
429
+ const defaultBootstrapper = bootstrappers.find(b => b.id === defaultDriverId || b.type === defaultDriverId) || bootstrappers[0];
358
430
  const defaultRealtimeService = defaultDriverResult.realtimeProvider;
359
431
 
360
432
  // 2. Initialize Auth & History via the default driver's bootstrapper
@@ -378,7 +450,6 @@ collectionRegistry });
378
450
  // (the return type still exposes `auth?: BootstrappedAuth`)
379
451
  authConfigResult = {
380
452
  userService: authAdapter.userManagement ?? {},
381
- roleService: authAdapter.roleManagement ?? {},
382
453
  };
383
454
  } else {
384
455
  // ── RebaseAuthConfig — wrap in built-in adapter ──
@@ -520,7 +591,10 @@ collectionRegistry });
520
591
 
521
592
  if (safeAuthConfig.linkedin?.clientId && safeAuthConfig.linkedin?.clientSecret) {
522
593
  const { createLinkedinProvider } = await import("./auth");
523
- oauthProviders.push(createLinkedinProvider(safeAuthConfig.linkedin as { clientId: string; clientSecret: string }));
594
+ oauthProviders.push(createLinkedinProvider(safeAuthConfig.linkedin as {
595
+ clientId: string;
596
+ clientSecret: string
597
+ }));
524
598
  }
525
599
 
526
600
  if (safeAuthConfig.github?.clientId && safeAuthConfig.github?.clientSecret) {
@@ -647,7 +721,7 @@ collectionRegistry });
647
721
  // maxFileSize (default 50MB), which overrides the global API body limit.
648
722
  const storageMaxSize = (
649
723
  config.storage && typeof config.storage === "object" && "type" in config.storage
650
- ? (config.storage as BackendStorageConfig & { maxFileSize?: number }).maxFileSize
724
+ ? (config.storage as BackendStorageConfig).maxFileSize
651
725
  : undefined
652
726
  ) ?? 50 * 1024 * 1024;
653
727
 
@@ -790,6 +864,29 @@ collectionRegistry });
790
864
  if (emailService) {
791
865
  Object.assign(serverClient, { email: emailService });
792
866
  logger.info("Email service attached to singleton", { configured: emailService.isConfigured() });
867
+
868
+ if (emailService.isConfigured() && typeof emailService.verifyConnection === "function") {
869
+ emailService.verifyConnection().then((success) => {
870
+ if (!success) {
871
+ logger.warn("Warning: SMTP connection verification failed. Email delivery may fail.");
872
+ } else {
873
+ logger.info("SMTP connection verified successfully.");
874
+ }
875
+ }).catch((err) => {
876
+ logger.warn("Warning: SMTP connection verification failed. Email delivery may fail.", { error: err });
877
+ });
878
+ }
879
+ }
880
+
881
+ // Attach raw SQL capability when the driver supports it (Postgres, MySQL).
882
+ // Document databases (MongoDB, Firestore) won't have this.
883
+ const driverAdmin = defaultBootstrapper.getAdmin?.(defaultDriverResult);
884
+ if (isSQLAdmin(driverAdmin)) {
885
+ Object.assign(serverClient, {
886
+ sql: (query: string, options?: { database?: string; role?: string }) =>
887
+ driverAdmin.executeSql(query, options)
888
+ });
889
+ logger.info("SQL capability attached to singleton");
793
890
  }
794
891
 
795
892
  _initRebase(serverClient);
@@ -842,8 +939,10 @@ collectionRegistry });
842
939
  const fnRoutes = createFunctionRoutes(loadedFunctions);
843
940
  functionsRouter.route("/", fnRoutes);
844
941
  config.app.route(`${basePath}/functions`, functionsRouter);
845
- logger.info("Mounted custom functions", { count: loadedFunctions.length,
846
- path: `${basePath}/functions` });
942
+ logger.info("Mounted custom functions", {
943
+ count: loadedFunctions.length,
944
+ path: `${basePath}/functions`
945
+ });
847
946
  }
848
947
  }
849
948
 
@@ -892,13 +991,15 @@ path: `${basePath}/functions` });
892
991
  // Start the scheduler
893
992
  cronScheduler.start();
894
993
 
895
- logger.info("Mounted cron jobs", { count: loadedCronJobs.length,
896
- path: `${basePath}/cron` });
994
+ logger.info("Mounted cron jobs", {
995
+ count: loadedCronJobs.length,
996
+ path: `${basePath}/cron`
997
+ });
897
998
  }
898
999
  }
899
1000
 
900
- if ((defaultBootstrapper as BackendBootstrapper & { initializeWebsockets?: (...args: unknown[]) => unknown }).initializeWebsockets) {
901
- await (defaultBootstrapper as BackendBootstrapper & { initializeWebsockets: (...args: unknown[]) => unknown }).initializeWebsockets(config.server, defaultRealtimeService, defaultDriver, config.auth, authAdapter);
1001
+ if (defaultBootstrapper.initializeWebsockets && defaultRealtimeService) {
1002
+ await defaultBootstrapper.initializeWebsockets(config.server, defaultRealtimeService, defaultDriver, config.auth, authAdapter);
902
1003
  }
903
1004
 
904
1005
  logger.info("Rebase Backend Initialized");
@@ -913,12 +1014,16 @@ path: `${basePath}/cron` });
913
1014
  await admin.executeSql("SELECT 1");
914
1015
  } else {
915
1016
  // Fallback: try a lightweight fetch to confirm driver is responsive
916
- await defaultDriver.fetchCollection({ path: "__health_check_nonexistent__",
917
- limit: 1 });
1017
+ await defaultDriver.fetchCollection({
1018
+ path: "__health_check_nonexistent__",
1019
+ limit: 1
1020
+ });
918
1021
  }
919
1022
  const latencyMs = Math.round(performance.now() - start);
920
- return { healthy: true,
921
- latencyMs };
1023
+ return {
1024
+ healthy: true,
1025
+ latencyMs
1026
+ };
922
1027
  } catch (error: unknown) {
923
1028
  const latencyMs = Math.round(performance.now() - start);
924
1029
  logger.error("Health check failed", {
@@ -952,12 +1057,11 @@ latencyMs };
952
1057
  // timer callbacks don't fire against a closed pool.
953
1058
  for (const [key, rt] of Object.entries(realtimeServices)) {
954
1059
  try {
955
- const rtWithLifecycle = rt as RealtimeProvider & { destroy?: () => Promise<void>; stopListening?: () => Promise<void> };
956
- if (typeof rtWithLifecycle.destroy === "function") {
957
- await rtWithLifecycle.destroy();
1060
+ if (typeof rt.destroy === "function") {
1061
+ await rt.destroy();
958
1062
  logger.info(`Realtime service "${key}" destroyed`);
959
- } else if (typeof rtWithLifecycle.stopListening === "function") {
960
- await rtWithLifecycle.stopListening();
1063
+ } else if (typeof rt.stopListening === "function") {
1064
+ await rt.stopListening();
961
1065
  logger.info(`Realtime service "${key}" LISTEN client stopped`);
962
1066
  }
963
1067
  } catch (err) {
@@ -6,9 +6,10 @@
6
6
  * in-memory cache to avoid redundant processing.
7
7
  */
8
8
 
9
- // Lazy-load sharp to avoid crashing when it isn't installed (e.g. in tests)
9
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
10
  let sharpFactory: ((input: Buffer | Uint8Array) => any) | undefined;
11
11
 
12
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
12
13
  async function getSharp(): Promise<(input: Buffer | Uint8Array) => any> {
13
14
  if (!sharpFactory) {
14
15
  try {
@@ -540,174 +540,5 @@ displayName: "Updated" }),
540
540
  });
541
541
  });
542
542
 
543
- // ── Role CRUD ───────────────────────────────────────────────────────
544
- describe("Role Management", () => {
545
- describe("GET /admin/roles", () => {
546
- it("returns list of roles", async () => {
547
- const app = createApp();
548
- mockAuthRepo.listRoles.mockResolvedValueOnce([
549
- mockRole("admin", true),
550
- mockRole("editor"),
551
- mockRole("viewer")
552
- ]);
553
-
554
- const res = await app.request("/admin/roles", { headers: { ...adminAuth() } });
555
- expect(res.status).toBe(200);
556
- const body = await res.json() as any;
557
- expect(body.roles).toHaveLength(3);
558
- expect(body.roles[0].isAdmin).toBe(true);
559
- });
560
- });
561
-
562
- describe("GET /admin/roles/:roleId", () => {
563
- it("returns role by ID", async () => {
564
- const app = createApp();
565
- mockAuthRepo.getRoleById.mockResolvedValueOnce(mockRole("admin", true));
566
-
567
- const res = await app.request("/admin/roles/admin", { headers: { ...adminAuth() } });
568
- expect(res.status).toBe(200);
569
- const body = await res.json() as any;
570
- expect(body.role.id).toBe("admin");
571
- expect(body.role.isAdmin).toBe(true);
572
- });
573
-
574
- it("returns 404 for non-existent role", async () => {
575
- const app = createApp();
576
- mockAuthRepo.getRoleById.mockResolvedValueOnce(null);
577
-
578
- const res = await app.request("/admin/roles/missing", { headers: { ...adminAuth() } });
579
- expect(res.status).toBe(404);
580
- });
581
- });
582
-
583
- describe("POST /admin/roles", () => {
584
- it("creates a new role", async () => {
585
- const app = createApp();
586
-
587
- const res = await app.request("/admin/roles", {
588
- ...json({ id: "custom",
589
- name: "Custom Role" }),
590
- headers: { ...json({}).headers,
591
- ...adminAuth() }
592
- });
593
- expect(res.status).toBe(201);
594
- const body = await res.json() as any;
595
- expect(body.role.id).toBe("custom");
596
- });
597
543
 
598
- it("returns 400 for missing id or name", async () => {
599
- const app = createApp();
600
-
601
- const res = await app.request("/admin/roles", {
602
- ...json({ id: "nope" }),
603
- headers: { ...json({}).headers,
604
- ...adminAuth() }
605
- });
606
- expect(res.status).toBe(400);
607
- });
608
-
609
- it("returns 409 when role already exists", async () => {
610
- const app = createApp();
611
- mockAuthRepo.getRoleById.mockResolvedValueOnce(mockRole("custom"));
612
-
613
- const res = await app.request("/admin/roles", {
614
- ...json({ id: "custom",
615
- name: "Dup" }),
616
- headers: { ...json({}).headers,
617
- ...adminAuth() }
618
- });
619
- expect(res.status).toBe(409);
620
- const body = await res.json() as any;
621
- expect(body.error.code).toBe("ROLE_EXISTS");
622
- });
623
- });
624
-
625
- describe("PUT /admin/roles/:roleId", () => {
626
- it("updates an existing role", async () => {
627
- const app = createApp();
628
- mockAuthRepo.getRoleById.mockResolvedValueOnce(mockRole("editor"));
629
-
630
- const res = await app.request("/admin/roles/editor", {
631
- method: "PUT",
632
- headers: { "Content-Type": "application/json",
633
- ...adminAuth() },
634
- body: JSON.stringify({ name: "Super Editor" })
635
- });
636
- expect(res.status).toBe(200);
637
- expect(mockAuthRepo.updateRole).toHaveBeenCalledWith("editor", expect.objectContaining({
638
- name: "Super Editor"
639
- }));
640
- });
641
-
642
- it("returns 404 for non-existent role", async () => {
643
- const app = createApp();
644
- mockAuthRepo.getRoleById.mockResolvedValueOnce(null);
645
-
646
- const res = await app.request("/admin/roles/missing", {
647
- method: "PUT",
648
- headers: { "Content-Type": "application/json",
649
- ...adminAuth() },
650
- body: JSON.stringify({ name: "Nope" })
651
- });
652
- expect(res.status).toBe(404);
653
- });
654
- });
655
-
656
- describe("DELETE /admin/roles/:roleId", () => {
657
- it("deletes a custom role", async () => {
658
- const app = createApp();
659
- mockAuthRepo.getRoleById.mockResolvedValueOnce(mockRole("custom"));
660
-
661
- const res = await app.request("/admin/roles/custom", {
662
- method: "DELETE",
663
- headers: { ...adminAuth() }
664
- });
665
- expect(res.status).toBe(200);
666
- expect(mockAuthRepo.deleteRole).toHaveBeenCalledWith("custom");
667
- });
668
-
669
- it("prevents deletion of built-in admin role", async () => {
670
- const app = createApp();
671
-
672
- const res = await app.request("/admin/roles/admin", {
673
- method: "DELETE",
674
- headers: { ...adminAuth() }
675
- });
676
- expect(res.status).toBe(400);
677
- const body = await res.json() as any;
678
- expect(body.error.code).toBe("BUILTIN_ROLE");
679
- });
680
-
681
- it("prevents deletion of built-in editor role", async () => {
682
- const app = createApp();
683
-
684
- const res = await app.request("/admin/roles/editor", {
685
- method: "DELETE",
686
- headers: { ...adminAuth() }
687
- });
688
- expect(res.status).toBe(400);
689
- });
690
-
691
- it("prevents deletion of built-in viewer role", async () => {
692
- const app = createApp();
693
-
694
- const res = await app.request("/admin/roles/viewer", {
695
- method: "DELETE",
696
- headers: { ...adminAuth() }
697
- });
698
- expect(res.status).toBe(400);
699
- });
700
-
701
- it("returns 404 for non-existent role", async () => {
702
- const app = createApp();
703
- mockAuthRepo.getRoleById.mockResolvedValueOnce(null);
704
-
705
- const res = await app.request("/admin/roles/ghost", {
706
- method: "DELETE",
707
- headers: { ...adminAuth() }
708
- });
709
- expect(res.status).toBe(404);
710
- });
711
- });
712
- });
713
544
  });
@@ -362,32 +362,7 @@ describe("BackendHooks — Admin Routes", () => {
362
362
  });
363
363
  });
364
364
 
365
- // ── roles.afterRead ─────────────────────────────────────────────────
366
- describe("roles.afterRead", () => {
367
- it("filters out roles from GET /admin/roles", async () => {
368
- const hooks: BackendHooks = {
369
- roles: {
370
- afterRead(role) {
371
- // Hide internal roles
372
- if (role.id === "internal") return null;
373
- return role;
374
- }
375
- }
376
- };
377
- const app = createApp(hooks);
378
- mockAuthRepo.listRoles.mockResolvedValueOnce([
379
- mockRole("admin", true),
380
- mockRole("internal"),
381
- mockRole("editor")
382
- ]);
383
365
 
384
- const res = await app.request("/admin/roles", { headers: { ...adminAuth() } });
385
- expect(res.status).toBe(200);
386
- const body = await res.json() as any;
387
- expect(body.roles).toHaveLength(2);
388
- expect(body.roles.map((r: any) => r.id)).toEqual(["admin", "editor"]);
389
- });
390
- });
391
366
 
392
367
  // ── no hooks (passthrough) ──────────────────────────────────────────
393
368
  describe("no hooks configured", () => {
@@ -143,7 +143,7 @@ describe("createCustomAuthAdapter", () => {
143
143
  expect(adapter.serviceKey).toBe("sk_live_123");
144
144
  });
145
145
 
146
- it("passes through userManagement and roleManagement when provided", () => {
146
+ it("passes through userManagement when provided", () => {
147
147
  const userMgmt = {
148
148
  getUser: jest.fn(),
149
149
  listUsers: jest.fn(),
@@ -151,27 +151,19 @@ describe("createCustomAuthAdapter", () => {
151
151
  updateUser: jest.fn(),
152
152
  deleteUser: jest.fn(),
153
153
  };
154
- const roleMgmt = {
155
- listRoles: jest.fn(),
156
- getUserRoles: jest.fn(),
157
- setUserRoles: jest.fn(),
158
- };
159
154
 
160
155
  const adapter = createCustomAuthAdapter({
161
156
  verifyRequest: async () => null,
162
157
  userManagement: userMgmt as unknown as CustomAuthAdapterOptions["userManagement"],
163
- roleManagement: roleMgmt as unknown as CustomAuthAdapterOptions["roleManagement"],
164
158
  });
165
159
 
166
160
  expect(adapter.userManagement).toBe(userMgmt);
167
- expect(adapter.roleManagement).toBe(roleMgmt);
168
161
  });
169
162
 
170
- it("omits userManagement and roleManagement when not provided", () => {
163
+ it("omits userManagement when not provided", () => {
171
164
  const adapter = createCustomAuthAdapter({
172
165
  verifyRequest: async () => null,
173
166
  });
174
167
  expect(adapter.userManagement).toBeUndefined();
175
- expect(adapter.roleManagement).toBeUndefined();
176
168
  });
177
169
  });