@rebasepro/server-core 0.2.3 → 0.2.4

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 (109) hide show
  1. package/dist/common/src/collections/default-collections.d.ts +12 -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 +2166 -208
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/index.umd.js +2155 -193
  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 +2 -24
  38. package/dist/types/src/controllers/client.d.ts +0 -3
  39. package/dist/types/src/controllers/collection_registry.d.ts +1 -1
  40. package/dist/types/src/controllers/data_driver.d.ts +18 -0
  41. package/dist/types/src/controllers/registry.d.ts +5 -4
  42. package/dist/types/src/rebase_context.d.ts +1 -1
  43. package/dist/types/src/types/auth_adapter.d.ts +2 -4
  44. package/dist/types/src/types/collections.d.ts +0 -4
  45. package/dist/types/src/types/component_ref.d.ts +1 -1
  46. package/dist/types/src/types/cron.d.ts +1 -1
  47. package/dist/types/src/types/entity_views.d.ts +1 -0
  48. package/dist/types/src/types/export_import.d.ts +1 -1
  49. package/dist/types/src/types/formex.d.ts +2 -2
  50. package/dist/types/src/types/properties.d.ts +2 -2
  51. package/dist/types/src/types/translations.d.ts +28 -12
  52. package/dist/types/src/types/user_management_delegate.d.ts +6 -4
  53. package/dist/types/src/users/roles.d.ts +0 -8
  54. package/package.json +8 -6
  55. package/src/api/ast-schema-editor.ts +4 -4
  56. package/src/api/errors.ts +16 -7
  57. package/src/api/logs-routes.ts +129 -0
  58. package/src/api/rest/api-generator.ts +42 -2
  59. package/src/api/rest/query-parser.ts +37 -1
  60. package/src/api/types.ts +6 -1
  61. package/src/auth/adapter-middleware.ts +20 -4
  62. package/src/auth/admin-routes.ts +39 -14
  63. package/src/auth/api-keys/api-key-middleware.ts +126 -0
  64. package/src/auth/api-keys/api-key-permission-guard.ts +64 -0
  65. package/src/auth/api-keys/api-key-routes.ts +183 -0
  66. package/src/auth/api-keys/api-key-store.ts +317 -0
  67. package/src/auth/api-keys/api-key-types.ts +94 -0
  68. package/src/auth/api-keys/index.ts +37 -0
  69. package/src/auth/{auth-overrides.ts → auth-hooks.ts} +81 -14
  70. package/src/auth/builtin-auth-adapter.ts +31 -19
  71. package/src/auth/index.ts +7 -3
  72. package/src/auth/interfaces.ts +111 -3
  73. package/src/auth/jwt.ts +19 -5
  74. package/src/auth/mfa.ts +160 -0
  75. package/src/auth/middleware.ts +20 -1
  76. package/src/auth/rate-limiter.ts +92 -0
  77. package/src/auth/routes.ts +455 -24
  78. package/src/cron/cron-loader.ts +5 -10
  79. package/src/cron/cron-scheduler.ts +11 -12
  80. package/src/cron/cron-store.ts +8 -7
  81. package/src/env.ts +2 -0
  82. package/src/functions/function-loader.ts +6 -9
  83. package/src/index.ts +1 -2
  84. package/src/init.ts +37 -7
  85. package/src/serve-spa.ts +5 -4
  86. package/src/services/webhook-service.ts +155 -0
  87. package/src/storage/image-transform.ts +202 -0
  88. package/src/storage/index.ts +3 -0
  89. package/src/storage/routes.ts +56 -3
  90. package/src/storage/tus-handler.ts +315 -0
  91. package/src/utils/dev-port.ts +14 -0
  92. package/src/utils/logging.ts +9 -7
  93. package/test/admin-routes.test.ts +74 -7
  94. package/test/api-generator.test.ts +0 -1
  95. package/test/api-key-permission-guard.test.ts +132 -0
  96. package/test/ast-schema-editor.test.ts +26 -0
  97. package/test/auth-routes.test.ts +1 -2
  98. package/test/backend-hooks-admin.test.ts +3 -4
  99. package/test/email-templates.test.ts +169 -0
  100. package/test/function-loader.test.ts +124 -0
  101. package/test/jwt.test.ts +4 -2
  102. package/test/mfa.test.ts +197 -0
  103. package/test/middleware.test.ts +10 -5
  104. package/test/webhook-service.test.ts +249 -0
  105. package/vite.config.ts +3 -2
  106. package/dist/index-BZoAtuqi.js.map +0 -1
  107. package/dist/server-core/src/bootstrappers/index.d.ts +0 -0
  108. package/src/bootstrappers/index.ts +0 -1
  109. 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,6 +420,44 @@ displayName: "Updated" }),
421
420
  expect(mockAuthRepo.setUserRoles).toHaveBeenCalledWith("u1", ["admin"]);
422
421
  });
423
422
 
423
+ it("prevents demoting the last admin", async () => {
424
+ const app = createApp();
425
+ mockAuthRepo.getUserById.mockResolvedValueOnce(mockUser({ id: "u1" }));
426
+ mockAuthRepo.getUserRoleIds.mockResolvedValueOnce(["admin"]);
427
+ mockAuthRepo.listUsersPaginated.mockResolvedValueOnce({ total: 1 });
428
+
429
+ const res = await app.request("/admin/users/u1", {
430
+ method: "PUT",
431
+ headers: { "Content-Type": "application/json",
432
+ ...adminAuth() },
433
+ body: JSON.stringify({ roles: ["editor"] })
434
+ });
435
+ expect(res.status).toBe(403);
436
+ const body = await res.json() as any;
437
+ expect(body.error.code).toBe("LAST_ADMIN");
438
+ expect(mockAuthRepo.setUserRoles).not.toHaveBeenCalled();
439
+ });
440
+
441
+ it("allows demoting an admin if there are other admins", async () => {
442
+ const app = createApp();
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")]
449
+ });
450
+
451
+ const res = await app.request("/admin/users/u1", {
452
+ method: "PUT",
453
+ headers: { "Content-Type": "application/json",
454
+ ...adminAuth() },
455
+ body: JSON.stringify({ roles: ["editor"] })
456
+ });
457
+ expect(res.status).toBe(200);
458
+ expect(mockAuthRepo.setUserRoles).toHaveBeenCalledWith("u1", ["editor"]);
459
+ });
460
+
424
461
  it("returns 404 for non-existent user", async () => {
425
462
  const app = createApp();
426
463
  mockAuthRepo.getUserById.mockResolvedValueOnce(null);
@@ -460,6 +497,36 @@ displayName: "Updated" }),
460
497
  expect(body.error.code).toBe("SELF_DELETE");
461
498
  });
462
499
 
500
+ it("prevents last-admin deletion", async () => {
501
+ const app = createApp();
502
+ mockAuthRepo.getUserById.mockResolvedValueOnce(mockUser({ id: "u2" }));
503
+ mockAuthRepo.getUserRoleIds.mockResolvedValueOnce(["admin"]);
504
+ mockAuthRepo.listUsersPaginated.mockResolvedValueOnce({ total: 1 });
505
+
506
+ const res = await app.request("/admin/users/u2", {
507
+ method: "DELETE",
508
+ headers: { ...adminAuth("admin-1") }
509
+ });
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();
514
+ });
515
+
516
+ it("allows deleting an admin if there are other admins", async () => {
517
+ const app = createApp();
518
+ mockAuthRepo.getUserById.mockResolvedValueOnce(mockUser({ id: "u2" }));
519
+ mockAuthRepo.getUserRoleIds.mockResolvedValueOnce(["admin"]);
520
+ mockAuthRepo.listUsersPaginated.mockResolvedValueOnce({ total: 2 });
521
+
522
+ const res = await app.request("/admin/users/u2", {
523
+ method: "DELETE",
524
+ headers: { ...adminAuth("admin-1") }
525
+ });
526
+ expect(res.status).toBe(200);
527
+ expect(mockAuthRepo.deleteUser).toHaveBeenCalledWith("u2");
528
+ });
529
+
463
530
  it("returns 404 for non-existent user", async () => {
464
531
  const app = createApp();
465
532
  mockAuthRepo.getUserById.mockResolvedValueOnce(null);
@@ -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
  };
@@ -0,0 +1,169 @@
1
+ import {
2
+ getPasswordResetTemplate,
3
+ getEmailVerificationTemplate,
4
+ getUserInvitationTemplate,
5
+ getWelcomeEmailTemplate
6
+ } from "../src/email/templates";
7
+
8
+ describe("getPasswordResetTemplate", () => {
9
+ const user = { email: "john@example.com", displayName: "John Doe" };
10
+ const resetUrl = "https://example.com/reset?token=abc123";
11
+
12
+ it("returns subject, html, and text", () => {
13
+ const result = getPasswordResetTemplate(resetUrl, user);
14
+ expect(result.subject).toBeTruthy();
15
+ expect(result.html).toBeTruthy();
16
+ expect(result.text).toBeTruthy();
17
+ });
18
+
19
+ it("subject includes default app name", () => {
20
+ const result = getPasswordResetTemplate(resetUrl, user);
21
+ expect(result.subject).toContain("Rebase");
22
+ });
23
+
24
+ it("subject includes custom app name", () => {
25
+ const result = getPasswordResetTemplate(resetUrl, user, "MyApp");
26
+ expect(result.subject).toContain("MyApp");
27
+ });
28
+
29
+ it("html contains the reset URL", () => {
30
+ const result = getPasswordResetTemplate(resetUrl, user);
31
+ expect(result.html).toContain(resetUrl);
32
+ });
33
+
34
+ it("text contains the reset URL", () => {
35
+ const result = getPasswordResetTemplate(resetUrl, user);
36
+ expect(result.text).toContain(resetUrl);
37
+ });
38
+
39
+ it("uses displayName for greeting", () => {
40
+ const result = getPasswordResetTemplate(resetUrl, user);
41
+ expect(result.html).toContain("John Doe");
42
+ expect(result.text).toContain("John Doe");
43
+ });
44
+
45
+ it("falls back to email prefix when displayName is null", () => {
46
+ const userNoName = { email: "jane@example.com", displayName: null };
47
+ const result = getPasswordResetTemplate(resetUrl, userNoName);
48
+ expect(result.html).toContain("jane");
49
+ expect(result.text).toContain("jane");
50
+ });
51
+
52
+ it("html contains DOCTYPE and body tags", () => {
53
+ const result = getPasswordResetTemplate(resetUrl, user);
54
+ expect(result.html).toContain("<!DOCTYPE html>");
55
+ expect(result.html).toContain("<body");
56
+ expect(result.html).toContain("</body>");
57
+ });
58
+ });
59
+
60
+ describe("getEmailVerificationTemplate", () => {
61
+ const user = { email: "alice@example.com", displayName: "Alice" };
62
+ const verifyUrl = "https://example.com/verify?token=xyz789";
63
+
64
+ it("returns subject, html, and text", () => {
65
+ const result = getEmailVerificationTemplate(verifyUrl, user);
66
+ expect(result.subject).toBeTruthy();
67
+ expect(result.html).toBeTruthy();
68
+ expect(result.text).toBeTruthy();
69
+ });
70
+
71
+ it("subject mentions email verification", () => {
72
+ const result = getEmailVerificationTemplate(verifyUrl, user);
73
+ expect(result.subject.toLowerCase()).toContain("verify");
74
+ });
75
+
76
+ it("html contains the verification URL", () => {
77
+ const result = getEmailVerificationTemplate(verifyUrl, user);
78
+ expect(result.html).toContain(verifyUrl);
79
+ });
80
+
81
+ it("text contains the verification URL", () => {
82
+ const result = getEmailVerificationTemplate(verifyUrl, user);
83
+ expect(result.text).toContain(verifyUrl);
84
+ });
85
+
86
+ it("uses displayName when available", () => {
87
+ const result = getEmailVerificationTemplate(verifyUrl, user);
88
+ expect(result.html).toContain("Alice");
89
+ });
90
+
91
+ it("uses custom app name", () => {
92
+ const result = getEmailVerificationTemplate(verifyUrl, user, "CustomApp");
93
+ expect(result.subject).toContain("CustomApp");
94
+ expect(result.html).toContain("CustomApp");
95
+ });
96
+ });
97
+
98
+ describe("getUserInvitationTemplate", () => {
99
+ const user = { email: "bob@example.com", displayName: "Bob" };
100
+ const setPasswordUrl = "https://example.com/set-password?token=def456";
101
+
102
+ it("returns subject, html, and text", () => {
103
+ const result = getUserInvitationTemplate(setPasswordUrl, user);
104
+ expect(result.subject).toBeTruthy();
105
+ expect(result.html).toBeTruthy();
106
+ expect(result.text).toBeTruthy();
107
+ });
108
+
109
+ it("subject mentions invitation", () => {
110
+ const result = getUserInvitationTemplate(setPasswordUrl, user);
111
+ expect(result.subject.toLowerCase()).toContain("invited");
112
+ });
113
+
114
+ it("html contains the set password URL", () => {
115
+ const result = getUserInvitationTemplate(setPasswordUrl, user);
116
+ expect(result.html).toContain(setPasswordUrl);
117
+ });
118
+
119
+ it("text contains the set password URL", () => {
120
+ const result = getUserInvitationTemplate(setPasswordUrl, user);
121
+ expect(result.text).toContain(setPasswordUrl);
122
+ });
123
+
124
+ it("falls back to email prefix when no displayName", () => {
125
+ const userNoName = { email: "charlie@example.com" };
126
+ const result = getUserInvitationTemplate(setPasswordUrl, userNoName);
127
+ expect(result.html).toContain("charlie");
128
+ });
129
+ });
130
+
131
+ describe("getWelcomeEmailTemplate", () => {
132
+ const user = { email: "dave@example.com", displayName: "Dave" };
133
+
134
+ it("returns subject, html, and text", () => {
135
+ const result = getWelcomeEmailTemplate(user);
136
+ expect(result.subject).toBeTruthy();
137
+ expect(result.html).toBeTruthy();
138
+ expect(result.text).toBeTruthy();
139
+ });
140
+
141
+ it("includes the app name in subject", () => {
142
+ const result = getWelcomeEmailTemplate(user, "SuperApp");
143
+ expect(result.subject).toContain("SuperApp");
144
+ });
145
+
146
+ it("uses displayName for greeting", () => {
147
+ const result = getWelcomeEmailTemplate(user);
148
+ expect(result.html).toContain("Dave");
149
+ });
150
+
151
+ it("includes login URL when provided", () => {
152
+ const loginUrl = "https://example.com/login";
153
+ const result = getWelcomeEmailTemplate(user, "Rebase", loginUrl);
154
+ expect(result.html).toContain(loginUrl);
155
+ expect(result.text).toContain(loginUrl);
156
+ });
157
+
158
+ it("handles missing login URL gracefully", () => {
159
+ const result = getWelcomeEmailTemplate(user);
160
+ expect(result.html).toBeTruthy();
161
+ expect(result.text).toBeTruthy();
162
+ });
163
+
164
+ it("html contains proper structure", () => {
165
+ const result = getWelcomeEmailTemplate(user);
166
+ expect(result.html).toContain("<!DOCTYPE html>");
167
+ expect(result.html).toContain("<body");
168
+ });
169
+ });
@@ -0,0 +1,124 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+ import { Hono } from "hono";
5
+ import { loadFunctionsFromDirectory } from "../src/functions/function-loader";
6
+ import { createFunctionRoutes } from "../src/functions/function-routes";
7
+
8
+ describe("Function Loader & Routes", () => {
9
+ let tempDir: string;
10
+
11
+ beforeAll(() => {
12
+ // Create temporary directory in OS temp folder
13
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "rebase-functions-test-"));
14
+
15
+ // 1. Write a valid Hono app default export using CommonJS
16
+ fs.writeFileSync(
17
+ path.join(tempDir, "valid-app.js"),
18
+ `
19
+ const { Hono } = require("hono");
20
+ const app = new Hono();
21
+ app.get("/hello", (c) => c.text("hello from valid-app"));
22
+ module.exports = app;
23
+ `
24
+ );
25
+
26
+ // 2. Write a valid Hono app factory default export using CommonJS
27
+ fs.writeFileSync(
28
+ path.join(tempDir, "valid-factory.js"),
29
+ `
30
+ const { Hono } = require("hono");
31
+ module.exports = () => {
32
+ const app = new Hono();
33
+ app.post("/hello", (c) => c.text("hello from valid-factory"));
34
+ return app;
35
+ };
36
+ `
37
+ );
38
+
39
+ // 3. Write an invalid default export
40
+ fs.writeFileSync(
41
+ path.join(tempDir, "invalid-export.js"),
42
+ `
43
+ module.exports = { foo: "bar" };
44
+ `
45
+ );
46
+
47
+ // 4. Write a file with no default export (exports is an object but no module.exports default value)
48
+ fs.writeFileSync(
49
+ path.join(tempDir, "no-default.js"),
50
+ `
51
+ exports.other = "value";
52
+ `
53
+ );
54
+
55
+ // 5. Write an ignored non-js/ts file
56
+ fs.writeFileSync(
57
+ path.join(tempDir, "ignored.txt"),
58
+ "some random text"
59
+ );
60
+ });
61
+
62
+ afterAll(() => {
63
+ // Clean up temp directory
64
+ if (tempDir && fs.existsSync(tempDir)) {
65
+ fs.rmSync(tempDir, { recursive: true, force: true });
66
+ }
67
+ });
68
+
69
+ describe("loadFunctionsFromDirectory", () => {
70
+ it("should return empty array if directory does not exist", async () => {
71
+ const result = await loadFunctionsFromDirectory(path.join(tempDir, "non-existent-folder"));
72
+ expect(result).toEqual([]);
73
+ });
74
+
75
+ it("should load valid apps and factories while ignoring invalid files", async () => {
76
+ const loaded = await loadFunctionsFromDirectory(tempDir);
77
+
78
+ // We expect exactly 2 functions to be loaded: "valid-app" and "valid-factory"
79
+ expect(loaded).toHaveLength(2);
80
+
81
+ const names = loaded.map(f => f.name);
82
+ expect(names).toContain("valid-app");
83
+ expect(names).toContain("valid-factory");
84
+
85
+ // Verify they both look like Hono instances
86
+ const validApp = loaded.find(f => f.name === "valid-app")!;
87
+ expect(typeof validApp.app.fetch).toBe("function");
88
+ expect(Array.isArray(validApp.app.routes)).toBe(true);
89
+
90
+ const validFactory = loaded.find(f => f.name === "valid-factory")!;
91
+ expect(typeof validFactory.app.fetch).toBe("function");
92
+ expect(Array.isArray(validFactory.app.routes)).toBe(true);
93
+ });
94
+ });
95
+
96
+ describe("createFunctionRoutes", () => {
97
+ it("should mount and route correctly", async () => {
98
+ const loaded = await loadFunctionsFromDirectory(tempDir);
99
+ const routes = createFunctionRoutes(loaded);
100
+
101
+ // 1. Verify GET / lists loaded functions
102
+ const listRes = await routes.request("/");
103
+ expect(listRes.status).toBe(200);
104
+
105
+ const listData = await listRes.json();
106
+ expect(listData).toEqual({
107
+ functions: [
108
+ { name: "valid-app", endpoint: "/functions/valid-app" },
109
+ { name: "valid-factory", endpoint: "/functions/valid-factory" }
110
+ ]
111
+ });
112
+
113
+ // 2. Verify routing to valid-app
114
+ const appRes = await routes.request("/valid-app/hello");
115
+ expect(appRes.status).toBe(200);
116
+ expect(await appRes.text()).toBe("hello from valid-app");
117
+
118
+ // 3. Verify routing to valid-factory
119
+ const factoryRes = await routes.request("/valid-factory/hello", { method: "POST" });
120
+ expect(factoryRes.status).toBe(200);
121
+ expect(await factoryRes.text()).toBe("hello from valid-factory");
122
+ });
123
+ });
124
+ });
package/test/jwt.test.ts CHANGED
@@ -56,7 +56,8 @@ accessExpiresIn: "2h" });
56
56
  const payload = verifyAccessToken(token);
57
57
  expect(payload).toEqual({
58
58
  userId: "user-456",
59
- roles: ["viewer"]
59
+ roles: ["viewer"],
60
+ aal: "aal1"
60
61
  });
61
62
  });
62
63
 
@@ -73,7 +74,8 @@ accessExpiresIn: "2h" });
73
74
  const payload = verifyAccessToken(token);
74
75
  expect(payload).toEqual({
75
76
  userId: "user-123",
76
- roles: ["admin"]
77
+ roles: ["admin"],
78
+ aal: "aal1"
77
79
  });
78
80
  });
79
81