@rebasepro/server-core 0.2.3 → 0.2.5

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 (116) hide show
  1. package/dist/common/src/collections/default-collections.d.ts +9 -0
  2. package/dist/common/src/collections/index.d.ts +1 -0
  3. package/dist/common/src/util/permissions.d.ts +1 -0
  4. package/dist/{index-BZoAtuqi.js → index-Cr1D21av.js} +11 -3
  5. package/dist/index-Cr1D21av.js.map +1 -0
  6. package/dist/index.es.js +2309 -370
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/index.umd.js +2298 -355
  9. package/dist/index.umd.js.map +1 -1
  10. package/dist/server-core/src/api/logs-routes.d.ts +37 -0
  11. package/dist/server-core/src/api/rest/api-generator.d.ts +6 -0
  12. package/dist/server-core/src/api/types.d.ts +6 -1
  13. package/dist/server-core/src/auth/adapter-middleware.d.ts +7 -3
  14. package/dist/server-core/src/auth/admin-routes.d.ts +3 -3
  15. package/dist/server-core/src/auth/api-keys/api-key-middleware.d.ts +39 -0
  16. package/dist/server-core/src/auth/api-keys/api-key-permission-guard.d.ts +32 -0
  17. package/dist/server-core/src/auth/api-keys/api-key-routes.d.ts +20 -0
  18. package/dist/server-core/src/auth/api-keys/api-key-store.d.ts +35 -0
  19. package/dist/server-core/src/auth/api-keys/api-key-types.d.ts +88 -0
  20. package/dist/server-core/src/auth/api-keys/index.d.ts +17 -0
  21. package/dist/server-core/src/auth/{auth-overrides.d.ts → auth-hooks.d.ts} +69 -11
  22. package/dist/server-core/src/auth/builtin-auth-adapter.d.ts +3 -3
  23. package/dist/server-core/src/auth/index.d.ts +5 -3
  24. package/dist/server-core/src/auth/interfaces.d.ts +93 -3
  25. package/dist/server-core/src/auth/jwt.d.ts +3 -1
  26. package/dist/server-core/src/auth/mfa.d.ts +49 -0
  27. package/dist/server-core/src/auth/middleware.d.ts +7 -0
  28. package/dist/server-core/src/auth/rate-limiter.d.ts +19 -0
  29. package/dist/server-core/src/auth/routes.d.ts +3 -3
  30. package/dist/server-core/src/env.d.ts +6 -0
  31. package/dist/server-core/src/index.d.ts +1 -0
  32. package/dist/server-core/src/init.d.ts +3 -3
  33. package/dist/server-core/src/services/webhook-service.d.ts +29 -0
  34. package/dist/server-core/src/storage/image-transform.d.ts +48 -0
  35. package/dist/server-core/src/storage/index.d.ts +3 -0
  36. package/dist/server-core/src/storage/tus-handler.d.ts +51 -0
  37. package/dist/types/src/controllers/auth.d.ts +4 -26
  38. package/dist/types/src/controllers/client.d.ts +25 -43
  39. package/dist/types/src/controllers/collection_registry.d.ts +1 -1
  40. package/dist/types/src/controllers/data.d.ts +4 -0
  41. package/dist/types/src/controllers/data_driver.d.ts +23 -0
  42. package/dist/types/src/controllers/registry.d.ts +5 -4
  43. package/dist/types/src/rebase_context.d.ts +1 -1
  44. package/dist/types/src/types/auth_adapter.d.ts +5 -60
  45. package/dist/types/src/types/backend.d.ts +2 -2
  46. package/dist/types/src/types/backend_hooks.d.ts +2 -17
  47. package/dist/types/src/types/collections.d.ts +0 -4
  48. package/dist/types/src/types/component_ref.d.ts +1 -1
  49. package/dist/types/src/types/cron.d.ts +1 -1
  50. package/dist/types/src/types/entity_views.d.ts +1 -0
  51. package/dist/types/src/types/export_import.d.ts +1 -1
  52. package/dist/types/src/types/formex.d.ts +2 -2
  53. package/dist/types/src/types/properties.d.ts +9 -7
  54. package/dist/types/src/types/translations.d.ts +28 -12
  55. package/dist/types/src/types/user_management_delegate.d.ts +22 -57
  56. package/dist/types/src/users/index.d.ts +0 -1
  57. package/dist/types/src/users/user.d.ts +0 -1
  58. package/package.json +8 -6
  59. package/src/api/ast-schema-editor.ts +4 -4
  60. package/src/api/errors.ts +16 -7
  61. package/src/api/logs-routes.ts +129 -0
  62. package/src/api/rest/api-generator.ts +42 -2
  63. package/src/api/rest/query-parser.ts +37 -1
  64. package/src/api/types.ts +6 -1
  65. package/src/auth/adapter-middleware.ts +20 -4
  66. package/src/auth/admin-routes.ts +36 -100
  67. package/src/auth/api-keys/api-key-middleware.ts +126 -0
  68. package/src/auth/api-keys/api-key-permission-guard.ts +64 -0
  69. package/src/auth/api-keys/api-key-routes.ts +183 -0
  70. package/src/auth/api-keys/api-key-store.ts +317 -0
  71. package/src/auth/api-keys/api-key-types.ts +94 -0
  72. package/src/auth/api-keys/index.ts +37 -0
  73. package/src/auth/{auth-overrides.ts → auth-hooks.ts} +81 -14
  74. package/src/auth/builtin-auth-adapter.ts +35 -73
  75. package/src/auth/custom-auth-adapter.ts +1 -1
  76. package/src/auth/index.ts +7 -3
  77. package/src/auth/interfaces.ts +111 -3
  78. package/src/auth/jwt.ts +19 -5
  79. package/src/auth/mfa.ts +160 -0
  80. package/src/auth/middleware.ts +20 -1
  81. package/src/auth/rate-limiter.ts +92 -0
  82. package/src/auth/routes.ts +455 -24
  83. package/src/cron/cron-loader.ts +5 -10
  84. package/src/cron/cron-scheduler.ts +11 -12
  85. package/src/cron/cron-store.ts +8 -7
  86. package/src/env.ts +2 -0
  87. package/src/functions/function-loader.ts +6 -9
  88. package/src/index.ts +1 -2
  89. package/src/init.ts +56 -8
  90. package/src/serve-spa.ts +5 -4
  91. package/src/services/webhook-service.ts +155 -0
  92. package/src/storage/image-transform.ts +202 -0
  93. package/src/storage/index.ts +3 -0
  94. package/src/storage/routes.ts +56 -3
  95. package/src/storage/tus-handler.ts +315 -0
  96. package/src/utils/dev-port.ts +14 -0
  97. package/src/utils/logging.ts +9 -7
  98. package/test/admin-routes.test.ts +62 -164
  99. package/test/api-generator.test.ts +0 -1
  100. package/test/api-key-permission-guard.test.ts +132 -0
  101. package/test/ast-schema-editor.test.ts +26 -0
  102. package/test/auth-routes.test.ts +1 -2
  103. package/test/backend-hooks-admin.test.ts +3 -29
  104. package/test/custom-auth-adapter.test.ts +2 -10
  105. package/test/email-templates.test.ts +169 -0
  106. package/test/function-loader.test.ts +124 -0
  107. package/test/jwt.test.ts +4 -2
  108. package/test/mfa.test.ts +197 -0
  109. package/test/middleware.test.ts +10 -5
  110. package/test/webhook-service.test.ts +249 -0
  111. package/vite.config.ts +3 -2
  112. package/dist/index-BZoAtuqi.js.map +0 -1
  113. package/dist/server-core/src/bootstrappers/index.d.ts +0 -0
  114. package/dist/types/src/users/roles.d.ts +0 -22
  115. package/src/bootstrappers/index.ts +0 -1
  116. package/src/singleton.test.ts +0 -28
@@ -43,8 +43,7 @@ function mockRole(id: string, isAdmin = false) {
43
43
  name: id.charAt(0).toUpperCase() + id.slice(1),
44
44
  isAdmin,
45
45
  defaultPermissions: null,
46
- collectionPermissions: null,
47
- config: null };
46
+ collectionPermissions: null };
48
47
  }
49
48
 
50
49
 
@@ -56,7 +55,7 @@ function createApp(opts: { defaultRole?: string } = {}) {
56
55
  getUserByIdentity: jest.fn().mockResolvedValue(null),
57
56
  linkUserIdentity: jest.fn().mockResolvedValue(undefined),
58
57
  getUserIdentities: jest.fn().mockResolvedValue([]),
59
- getUserById: jest.fn().mockResolvedValue(null),
58
+ getUserById: jest.fn().mockImplementation((id) => Promise.resolve(mockUser({ id }))),
60
59
  createUser: jest.fn().mockImplementation((data) =>
61
60
  Promise.resolve(mockUser({ email: data.email,
62
61
  displayName: data.displayName,
@@ -98,14 +97,12 @@ roles: [mockRole("editor")] };
98
97
  name: r.name,
99
98
  isAdmin: r.isAdmin || false,
100
99
  defaultPermissions: null,
101
- collectionPermissions: null,
102
- config: null })),
100
+ collectionPermissions: null })),
103
101
  updateRole: jest.fn().mockImplementation((id, r) => Promise.resolve({ id,
104
102
  name: r.name,
105
103
  isAdmin: r.isAdmin || false,
106
104
  defaultPermissions: null,
107
- collectionPermissions: null,
108
- config: null })),
105
+ collectionPermissions: null })),
109
106
  deleteRole: jest.fn().mockResolvedValue(undefined)
110
107
  } as unknown as jest.Mocked<AuthRepository>;
111
108
 
@@ -407,6 +404,8 @@ displayName: "Updated" }),
407
404
  it("updates roles when specified", async () => {
408
405
  const app = createApp();
409
406
  mockAuthRepo.getUserById.mockResolvedValueOnce(mockUser({ id: "u1" }));
407
+ mockAuthRepo.getUserRoleIds.mockResolvedValueOnce(["admin"]);
408
+ mockAuthRepo.listUsersPaginated.mockResolvedValueOnce({ total: 2 });
410
409
  mockAuthRepo.getUserWithRoles.mockResolvedValueOnce({
411
410
  user: mockUser({ id: "u1" }),
412
411
  roles: [mockRole("admin")]
@@ -421,221 +420,118 @@ displayName: "Updated" }),
421
420
  expect(mockAuthRepo.setUserRoles).toHaveBeenCalledWith("u1", ["admin"]);
422
421
  });
423
422
 
424
- it("returns 404 for non-existent user", async () => {
423
+ it("prevents demoting the last admin", async () => {
425
424
  const app = createApp();
426
- mockAuthRepo.getUserById.mockResolvedValueOnce(null);
425
+ mockAuthRepo.getUserById.mockResolvedValueOnce(mockUser({ id: "u1" }));
426
+ mockAuthRepo.getUserRoleIds.mockResolvedValueOnce(["admin"]);
427
+ mockAuthRepo.listUsersPaginated.mockResolvedValueOnce({ total: 1 });
427
428
 
428
- const res = await app.request("/admin/users/missing", {
429
+ const res = await app.request("/admin/users/u1", {
429
430
  method: "PUT",
430
431
  headers: { "Content-Type": "application/json",
431
432
  ...adminAuth() },
432
- body: JSON.stringify({ displayName: "Updated" })
433
- });
434
- expect(res.status).toBe(404);
435
- });
436
- });
437
-
438
- describe("DELETE /admin/users/:userId", () => {
439
- it("deletes a user", async () => {
440
- const app = createApp();
441
- mockAuthRepo.getUserById.mockResolvedValueOnce(mockUser({ id: "u1" }));
442
-
443
- const res = await app.request("/admin/users/u1", {
444
- method: "DELETE",
445
- headers: { ...adminAuth("admin-1") }
446
- });
447
- expect(res.status).toBe(200);
448
- expect(mockAuthRepo.deleteUser).toHaveBeenCalledWith("u1");
449
- });
450
-
451
- it("prevents self-deletion", async () => {
452
- const app = createApp();
453
-
454
- const res = await app.request("/admin/users/admin-1", {
455
- method: "DELETE",
456
- headers: { ...adminAuth("admin-1") }
433
+ body: JSON.stringify({ roles: ["editor"] })
457
434
  });
458
- expect(res.status).toBe(400);
435
+ expect(res.status).toBe(403);
459
436
  const body = await res.json() as any;
460
- expect(body.error.code).toBe("SELF_DELETE");
437
+ expect(body.error.code).toBe("LAST_ADMIN");
438
+ expect(mockAuthRepo.setUserRoles).not.toHaveBeenCalled();
461
439
  });
462
440
 
463
- it("returns 404 for non-existent user", async () => {
464
- const app = createApp();
465
- mockAuthRepo.getUserById.mockResolvedValueOnce(null);
466
-
467
- const res = await app.request("/admin/users/missing", {
468
- method: "DELETE",
469
- headers: { ...adminAuth() }
470
- });
471
- expect(res.status).toBe(404);
472
- });
473
- });
474
- });
475
-
476
- // ── Role CRUD ───────────────────────────────────────────────────────
477
- describe("Role Management", () => {
478
- describe("GET /admin/roles", () => {
479
- it("returns list of roles", async () => {
441
+ it("allows demoting an admin if there are other admins", async () => {
480
442
  const app = createApp();
481
- mockAuthRepo.listRoles.mockResolvedValueOnce([
482
- mockRole("admin", true),
483
- mockRole("editor"),
484
- mockRole("viewer")
485
- ]);
486
-
487
- const res = await app.request("/admin/roles", { headers: { ...adminAuth() } });
488
- expect(res.status).toBe(200);
489
- const body = await res.json() as any;
490
- expect(body.roles).toHaveLength(3);
491
- expect(body.roles[0].isAdmin).toBe(true);
492
- });
493
- });
494
-
495
- describe("GET /admin/roles/:roleId", () => {
496
- it("returns role by ID", async () => {
497
- const app = createApp();
498
- mockAuthRepo.getRoleById.mockResolvedValueOnce(mockRole("admin", true));
499
-
500
- const res = await app.request("/admin/roles/admin", { headers: { ...adminAuth() } });
501
- expect(res.status).toBe(200);
502
- const body = await res.json() as any;
503
- expect(body.role.id).toBe("admin");
504
- expect(body.role.isAdmin).toBe(true);
505
- });
506
-
507
- it("returns 404 for non-existent role", async () => {
508
- const app = createApp();
509
- mockAuthRepo.getRoleById.mockResolvedValueOnce(null);
510
-
511
- const res = await app.request("/admin/roles/missing", { headers: { ...adminAuth() } });
512
- expect(res.status).toBe(404);
513
- });
514
- });
515
-
516
- describe("POST /admin/roles", () => {
517
- it("creates a new role", async () => {
518
- const app = createApp();
519
-
520
- const res = await app.request("/admin/roles", {
521
- ...json({ id: "custom",
522
- name: "Custom Role" }),
523
- headers: { ...json({}).headers,
524
- ...adminAuth() }
525
- });
526
- expect(res.status).toBe(201);
527
- const body = await res.json() as any;
528
- expect(body.role.id).toBe("custom");
529
- });
530
-
531
- it("returns 400 for missing id or name", async () => {
532
- const app = createApp();
533
-
534
- const res = await app.request("/admin/roles", {
535
- ...json({ id: "nope" }),
536
- headers: { ...json({}).headers,
537
- ...adminAuth() }
538
- });
539
- expect(res.status).toBe(400);
540
- });
541
-
542
- it("returns 409 when role already exists", async () => {
543
- const app = createApp();
544
- mockAuthRepo.getRoleById.mockResolvedValueOnce(mockRole("custom"));
545
-
546
- const res = await app.request("/admin/roles", {
547
- ...json({ id: "custom",
548
- name: "Dup" }),
549
- headers: { ...json({}).headers,
550
- ...adminAuth() }
443
+ mockAuthRepo.getUserById.mockResolvedValueOnce(mockUser({ id: "u1" }));
444
+ mockAuthRepo.getUserRoleIds.mockResolvedValueOnce(["admin"]);
445
+ mockAuthRepo.listUsersPaginated.mockResolvedValueOnce({ total: 2 });
446
+ mockAuthRepo.getUserWithRoles.mockResolvedValueOnce({
447
+ user: mockUser({ id: "u1" }),
448
+ roles: [mockRole("editor")]
551
449
  });
552
- expect(res.status).toBe(409);
553
- const body = await res.json() as any;
554
- expect(body.error.code).toBe("ROLE_EXISTS");
555
- });
556
- });
557
-
558
- describe("PUT /admin/roles/:roleId", () => {
559
- it("updates an existing role", async () => {
560
- const app = createApp();
561
- mockAuthRepo.getRoleById.mockResolvedValueOnce(mockRole("editor"));
562
450
 
563
- const res = await app.request("/admin/roles/editor", {
451
+ const res = await app.request("/admin/users/u1", {
564
452
  method: "PUT",
565
453
  headers: { "Content-Type": "application/json",
566
454
  ...adminAuth() },
567
- body: JSON.stringify({ name: "Super Editor" })
455
+ body: JSON.stringify({ roles: ["editor"] })
568
456
  });
569
457
  expect(res.status).toBe(200);
570
- expect(mockAuthRepo.updateRole).toHaveBeenCalledWith("editor", expect.objectContaining({
571
- name: "Super Editor"
572
- }));
458
+ expect(mockAuthRepo.setUserRoles).toHaveBeenCalledWith("u1", ["editor"]);
573
459
  });
574
460
 
575
- it("returns 404 for non-existent role", async () => {
461
+ it("returns 404 for non-existent user", async () => {
576
462
  const app = createApp();
577
- mockAuthRepo.getRoleById.mockResolvedValueOnce(null);
463
+ mockAuthRepo.getUserById.mockResolvedValueOnce(null);
578
464
 
579
- const res = await app.request("/admin/roles/missing", {
465
+ const res = await app.request("/admin/users/missing", {
580
466
  method: "PUT",
581
467
  headers: { "Content-Type": "application/json",
582
468
  ...adminAuth() },
583
- body: JSON.stringify({ name: "Nope" })
469
+ body: JSON.stringify({ displayName: "Updated" })
584
470
  });
585
471
  expect(res.status).toBe(404);
586
472
  });
587
473
  });
588
474
 
589
- describe("DELETE /admin/roles/:roleId", () => {
590
- it("deletes a custom role", async () => {
475
+ describe("DELETE /admin/users/:userId", () => {
476
+ it("deletes a user", async () => {
591
477
  const app = createApp();
592
- mockAuthRepo.getRoleById.mockResolvedValueOnce(mockRole("custom"));
478
+ mockAuthRepo.getUserById.mockResolvedValueOnce(mockUser({ id: "u1" }));
593
479
 
594
- const res = await app.request("/admin/roles/custom", {
480
+ const res = await app.request("/admin/users/u1", {
595
481
  method: "DELETE",
596
- headers: { ...adminAuth() }
482
+ headers: { ...adminAuth("admin-1") }
597
483
  });
598
484
  expect(res.status).toBe(200);
599
- expect(mockAuthRepo.deleteRole).toHaveBeenCalledWith("custom");
485
+ expect(mockAuthRepo.deleteUser).toHaveBeenCalledWith("u1");
600
486
  });
601
487
 
602
- it("prevents deletion of built-in admin role", async () => {
488
+ it("prevents self-deletion", async () => {
603
489
  const app = createApp();
604
490
 
605
- const res = await app.request("/admin/roles/admin", {
491
+ const res = await app.request("/admin/users/admin-1", {
606
492
  method: "DELETE",
607
- headers: { ...adminAuth() }
493
+ headers: { ...adminAuth("admin-1") }
608
494
  });
609
495
  expect(res.status).toBe(400);
610
496
  const body = await res.json() as any;
611
- expect(body.error.code).toBe("BUILTIN_ROLE");
497
+ expect(body.error.code).toBe("SELF_DELETE");
612
498
  });
613
499
 
614
- it("prevents deletion of built-in editor role", async () => {
500
+ it("prevents last-admin deletion", async () => {
615
501
  const app = createApp();
502
+ mockAuthRepo.getUserById.mockResolvedValueOnce(mockUser({ id: "u2" }));
503
+ mockAuthRepo.getUserRoleIds.mockResolvedValueOnce(["admin"]);
504
+ mockAuthRepo.listUsersPaginated.mockResolvedValueOnce({ total: 1 });
616
505
 
617
- const res = await app.request("/admin/roles/editor", {
506
+ const res = await app.request("/admin/users/u2", {
618
507
  method: "DELETE",
619
- headers: { ...adminAuth() }
508
+ headers: { ...adminAuth("admin-1") }
620
509
  });
621
- expect(res.status).toBe(400);
510
+ expect(res.status).toBe(403);
511
+ const body = await res.json() as any;
512
+ expect(body.error.code).toBe("LAST_ADMIN");
513
+ expect(mockAuthRepo.deleteUser).not.toHaveBeenCalled();
622
514
  });
623
515
 
624
- it("prevents deletion of built-in viewer role", async () => {
516
+ it("allows deleting an admin if there are other admins", async () => {
625
517
  const app = createApp();
518
+ mockAuthRepo.getUserById.mockResolvedValueOnce(mockUser({ id: "u2" }));
519
+ mockAuthRepo.getUserRoleIds.mockResolvedValueOnce(["admin"]);
520
+ mockAuthRepo.listUsersPaginated.mockResolvedValueOnce({ total: 2 });
626
521
 
627
- const res = await app.request("/admin/roles/viewer", {
522
+ const res = await app.request("/admin/users/u2", {
628
523
  method: "DELETE",
629
- headers: { ...adminAuth() }
524
+ headers: { ...adminAuth("admin-1") }
630
525
  });
631
- expect(res.status).toBe(400);
526
+ expect(res.status).toBe(200);
527
+ expect(mockAuthRepo.deleteUser).toHaveBeenCalledWith("u2");
632
528
  });
633
529
 
634
- it("returns 404 for non-existent role", async () => {
530
+ it("returns 404 for non-existent user", async () => {
635
531
  const app = createApp();
636
- mockAuthRepo.getRoleById.mockResolvedValueOnce(null);
532
+ mockAuthRepo.getUserById.mockResolvedValueOnce(null);
637
533
 
638
- const res = await app.request("/admin/roles/ghost", {
534
+ const res = await app.request("/admin/users/missing", {
639
535
  method: "DELETE",
640
536
  headers: { ...adminAuth() }
641
537
  });
@@ -643,4 +539,6 @@ name: "Dup" }),
643
539
  });
644
540
  });
645
541
  });
542
+
543
+
646
544
  });
@@ -168,7 +168,6 @@ values: {} } as any;
168
168
  );
169
169
  });
170
170
  });
171
-
172
171
  describe("Subcollection Routes", () => {
173
172
  /**
174
173
  * These tests use the flat app (no prefix nesting) to avoid
@@ -0,0 +1,132 @@
1
+ import {
2
+ httpMethodToOperation,
3
+ isOperationAllowed,
4
+ ApiKeyOperation
5
+ } from "../src/auth/api-keys/api-key-permission-guard";
6
+ import type { ApiKeyPermission } from "../src/auth/api-keys/api-key-types";
7
+
8
+ describe("httpMethodToOperation", () => {
9
+ it("maps GET to read", () => {
10
+ expect(httpMethodToOperation("GET")).toBe("read");
11
+ });
12
+
13
+ it("maps HEAD to read", () => {
14
+ expect(httpMethodToOperation("HEAD")).toBe("read");
15
+ });
16
+
17
+ it("maps OPTIONS to read", () => {
18
+ expect(httpMethodToOperation("OPTIONS")).toBe("read");
19
+ });
20
+
21
+ it("maps POST to write", () => {
22
+ expect(httpMethodToOperation("POST")).toBe("write");
23
+ });
24
+
25
+ it("maps PUT to write", () => {
26
+ expect(httpMethodToOperation("PUT")).toBe("write");
27
+ });
28
+
29
+ it("maps PATCH to write", () => {
30
+ expect(httpMethodToOperation("PATCH")).toBe("write");
31
+ });
32
+
33
+ it("maps DELETE to delete", () => {
34
+ expect(httpMethodToOperation("DELETE")).toBe("delete");
35
+ });
36
+
37
+ it("is case-insensitive", () => {
38
+ expect(httpMethodToOperation("get")).toBe("read");
39
+ expect(httpMethodToOperation("post")).toBe("write");
40
+ expect(httpMethodToOperation("delete")).toBe("delete");
41
+ expect(httpMethodToOperation("Patch")).toBe("write");
42
+ });
43
+
44
+ it("defaults unknown methods to read", () => {
45
+ expect(httpMethodToOperation("CONNECT")).toBe("read");
46
+ expect(httpMethodToOperation("TRACE")).toBe("read");
47
+ expect(httpMethodToOperation("UNKNOWN")).toBe("read");
48
+ });
49
+ });
50
+
51
+ describe("isOperationAllowed", () => {
52
+ it("allows operation with exact collection match", () => {
53
+ const permissions: ApiKeyPermission[] = [
54
+ { collection: "users", operations: ["read", "write"] }
55
+ ];
56
+ expect(isOperationAllowed(permissions, "users", "read")).toBe(true);
57
+ expect(isOperationAllowed(permissions, "users", "write")).toBe(true);
58
+ });
59
+
60
+ it("denies operation not in the operations list", () => {
61
+ const permissions: ApiKeyPermission[] = [
62
+ { collection: "users", operations: ["read"] }
63
+ ];
64
+ expect(isOperationAllowed(permissions, "users", "write")).toBe(false);
65
+ expect(isOperationAllowed(permissions, "users", "delete")).toBe(false);
66
+ });
67
+
68
+ it("denies operation for non-matching collection", () => {
69
+ const permissions: ApiKeyPermission[] = [
70
+ { collection: "users", operations: ["read", "write", "delete"] }
71
+ ];
72
+ expect(isOperationAllowed(permissions, "posts", "read")).toBe(false);
73
+ });
74
+
75
+ it("allows wildcard collection '*' to match any collection", () => {
76
+ const permissions: ApiKeyPermission[] = [
77
+ { collection: "*", operations: ["read"] }
78
+ ];
79
+ expect(isOperationAllowed(permissions, "users", "read")).toBe(true);
80
+ expect(isOperationAllowed(permissions, "posts", "read")).toBe(true);
81
+ expect(isOperationAllowed(permissions, "anything", "read")).toBe(true);
82
+ });
83
+
84
+ it("wildcard collection still checks operations", () => {
85
+ const permissions: ApiKeyPermission[] = [
86
+ { collection: "*", operations: ["read"] }
87
+ ];
88
+ expect(isOperationAllowed(permissions, "users", "write")).toBe(false);
89
+ expect(isOperationAllowed(permissions, "users", "delete")).toBe(false);
90
+ });
91
+
92
+ it("returns false for empty permissions array", () => {
93
+ expect(isOperationAllowed([], "users", "read")).toBe(false);
94
+ });
95
+
96
+ it("checks all permission entries and grants if any match", () => {
97
+ const permissions: ApiKeyPermission[] = [
98
+ { collection: "users", operations: ["read"] },
99
+ { collection: "posts", operations: ["write"] },
100
+ { collection: "comments", operations: ["read", "write", "delete"] }
101
+ ];
102
+ expect(isOperationAllowed(permissions, "users", "read")).toBe(true);
103
+ expect(isOperationAllowed(permissions, "posts", "write")).toBe(true);
104
+ expect(isOperationAllowed(permissions, "comments", "delete")).toBe(true);
105
+ expect(isOperationAllowed(permissions, "users", "write")).toBe(false);
106
+ expect(isOperationAllowed(permissions, "posts", "read")).toBe(false);
107
+ });
108
+
109
+ it("handles overlapping permissions correctly", () => {
110
+ const permissions: ApiKeyPermission[] = [
111
+ { collection: "users", operations: ["read"] },
112
+ { collection: "users", operations: ["write"] }
113
+ ];
114
+ expect(isOperationAllowed(permissions, "users", "read")).toBe(true);
115
+ expect(isOperationAllowed(permissions, "users", "write")).toBe(true);
116
+ expect(isOperationAllowed(permissions, "users", "delete")).toBe(false);
117
+ });
118
+
119
+ it("handles mixed wildcard and specific permissions", () => {
120
+ const permissions: ApiKeyPermission[] = [
121
+ { collection: "*", operations: ["read"] },
122
+ { collection: "users", operations: ["write", "delete"] }
123
+ ];
124
+ // Wildcard read for everything
125
+ expect(isOperationAllowed(permissions, "posts", "read")).toBe(true);
126
+ // Specific write/delete for users
127
+ expect(isOperationAllowed(permissions, "users", "write")).toBe(true);
128
+ expect(isOperationAllowed(permissions, "users", "delete")).toBe(true);
129
+ // No write for non-users
130
+ expect(isOperationAllowed(permissions, "posts", "write")).toBe(false);
131
+ });
132
+ });
@@ -60,4 +60,30 @@ description: "Product name" },
60
60
  expect(newContent).toContain("target: () => categoriesCollection");
61
61
  expect(newContent).toContain('description: "Product name"');
62
62
  });
63
+
64
+ it("should safely escape string values containing quotes and prevent code injection", async () => {
65
+ // Setup initial file
66
+ const fileContent = `import { EntityCollection } from "@rebasepro/types";
67
+ const productsCollection: EntityCollection = {
68
+ name: "Products",
69
+ slug: "products",
70
+ properties: {}
71
+ };
72
+ export default productsCollection;
73
+ `;
74
+ fs.writeFileSync(path.join(testDir, "products.ts"), fileContent);
75
+
76
+ // Payload attempting breakout
77
+ const maliciousData = {
78
+ name: 'Products", description: "Injected description", breakout: "true',
79
+ slug: "products",
80
+ properties: {}
81
+ };
82
+
83
+ await editor.saveCollection("products", maliciousData);
84
+
85
+ const newContent = fs.readFileSync(path.join(testDir, "products.ts"), "utf-8");
86
+ // Verify that the quote was safely escaped and did not break out to create a new property
87
+ expect(newContent).toContain('name: "Products\\", description: \\"Injected description\\", breakout: \\"true"');
88
+ });
63
89
  });
@@ -54,8 +54,7 @@ function mockRole(id: string, isAdmin = false) {
54
54
  name: id.charAt(0).toUpperCase() + id.slice(1),
55
55
  isAdmin,
56
56
  defaultPermissions: null,
57
- collectionPermissions: null,
58
- config: null };
57
+ collectionPermissions: null };
59
58
  }
60
59
 
61
60
  let mockAuthRepo: jest.Mocked<AuthRepository>;
@@ -49,8 +49,7 @@ function mockRole(id: string, isAdmin = false) {
49
49
  name: id.charAt(0).toUpperCase() + id.slice(1),
50
50
  isAdmin,
51
51
  defaultPermissions: null,
52
- collectionPermissions: null,
53
- config: null
52
+ collectionPermissions: null
54
53
  };
55
54
  }
56
55
 
@@ -98,11 +97,11 @@ function createApp(hooks?: BackendHooks) {
98
97
  getRoleById: jest.fn().mockResolvedValue(null),
99
98
  createRole: jest.fn().mockImplementation(r => Promise.resolve({
100
99
  id: r.id, name: r.name, isAdmin: r.isAdmin || false,
101
- defaultPermissions: null, collectionPermissions: null, config: null
100
+ defaultPermissions: null, collectionPermissions: null
102
101
  })),
103
102
  updateRole: jest.fn().mockImplementation((id, r) => Promise.resolve({
104
103
  id, name: r.name, isAdmin: r.isAdmin || false,
105
- defaultPermissions: null, collectionPermissions: null, config: null
104
+ defaultPermissions: null, collectionPermissions: null
106
105
  })),
107
106
  deleteRole: jest.fn().mockResolvedValue(undefined)
108
107
  };
@@ -363,32 +362,7 @@ describe("BackendHooks — Admin Routes", () => {
363
362
  });
364
363
  });
365
364
 
366
- // ── roles.afterRead ─────────────────────────────────────────────────
367
- describe("roles.afterRead", () => {
368
- it("filters out roles from GET /admin/roles", async () => {
369
- const hooks: BackendHooks = {
370
- roles: {
371
- afterRead(role) {
372
- // Hide internal roles
373
- if (role.id === "internal") return null;
374
- return role;
375
- }
376
- }
377
- };
378
- const app = createApp(hooks);
379
- mockAuthRepo.listRoles.mockResolvedValueOnce([
380
- mockRole("admin", true),
381
- mockRole("internal"),
382
- mockRole("editor")
383
- ]);
384
365
 
385
- const res = await app.request("/admin/roles", { headers: { ...adminAuth() } });
386
- expect(res.status).toBe(200);
387
- const body = await res.json() as any;
388
- expect(body.roles).toHaveLength(2);
389
- expect(body.roles.map((r: any) => r.id)).toEqual(["admin", "editor"]);
390
- });
391
- });
392
366
 
393
367
  // ── no hooks (passthrough) ──────────────────────────────────────────
394
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
  });