@rebasepro/server-core 0.6.0 → 0.7.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 (168) hide show
  1. package/package.json +14 -14
  2. package/src/api/errors.ts +16 -2
  3. package/src/api/graphql/graphql-schema-generator.ts +45 -10
  4. package/src/api/rest/api-generator.ts +19 -2
  5. package/src/api/server.ts +10 -2
  6. package/src/api/types.ts +6 -0
  7. package/src/auth/adapter-middleware.ts +10 -2
  8. package/src/auth/admin-roles-route.ts +36 -0
  9. package/src/auth/admin-users-route.ts +302 -0
  10. package/src/auth/api-keys/api-key-middleware.ts +4 -3
  11. package/src/auth/api-keys/api-key-routes.ts +12 -2
  12. package/src/auth/api-keys/api-key-store.ts +83 -66
  13. package/src/auth/api-keys/api-key-types.ts +8 -0
  14. package/src/auth/apple-oauth.ts +2 -1
  15. package/src/auth/auth-hooks.ts +21 -0
  16. package/src/auth/bitbucket-oauth.ts +2 -1
  17. package/src/auth/builtin-auth-adapter.ts +28 -4
  18. package/src/auth/custom-auth-adapter.ts +2 -0
  19. package/src/auth/discord-oauth.ts +2 -1
  20. package/src/auth/facebook-oauth.ts +2 -1
  21. package/src/auth/github-oauth.ts +2 -1
  22. package/src/auth/gitlab-oauth.ts +2 -1
  23. package/src/auth/google-oauth.ts +8 -4
  24. package/src/auth/index.ts +2 -0
  25. package/src/auth/interfaces.ts +27 -0
  26. package/src/auth/linkedin-oauth.ts +2 -1
  27. package/src/auth/magic-link-routes.ts +167 -0
  28. package/src/auth/mfa-crypto.ts +91 -0
  29. package/src/auth/mfa-routes.ts +34 -10
  30. package/src/auth/microsoft-oauth.ts +2 -1
  31. package/src/auth/middleware.ts +10 -1
  32. package/src/auth/reset-password-admin.ts +17 -1
  33. package/src/auth/routes.ts +77 -8
  34. package/src/auth/session-routes.ts +15 -3
  35. package/src/auth/slack-oauth.ts +2 -1
  36. package/src/auth/spotify-oauth.ts +2 -1
  37. package/src/auth/twitter-oauth.ts +8 -1
  38. package/src/cron/cron-store.ts +25 -23
  39. package/src/email/index.ts +3 -2
  40. package/src/email/templates.ts +82 -0
  41. package/src/email/types.ts +16 -0
  42. package/src/init.ts +137 -5
  43. package/src/services/routed-realtime-service.ts +113 -0
  44. package/src/storage/routes.ts +4 -0
  45. package/src/utils/dev-port.ts +13 -7
  46. package/test/auth-routes.test.ts +54 -4
  47. package/test/custom-auth-adapter.test.ts +20 -1
  48. package/test/env.test.ts +9 -19
  49. package/test/multi-datasource-routing.test.ts +113 -0
  50. package/test/routed-realtime-service.test.ts +86 -0
  51. package/test/transform-auth-response.test.ts +305 -0
  52. package/vite.config.ts +5 -0
  53. package/build-errors.txt +0 -52
  54. package/dist/api/ast-schema-editor.d.ts +0 -21
  55. package/dist/api/collections_for_test/callbacks_test_collection.d.ts +0 -2
  56. package/dist/api/errors.d.ts +0 -53
  57. package/dist/api/graphql/graphql-schema-generator.d.ts +0 -35
  58. package/dist/api/graphql/index.d.ts +0 -1
  59. package/dist/api/index.d.ts +0 -9
  60. package/dist/api/logs-routes.d.ts +0 -37
  61. package/dist/api/openapi-generator.d.ts +0 -16
  62. package/dist/api/rest/api-generator.d.ts +0 -88
  63. package/dist/api/rest/index.d.ts +0 -1
  64. package/dist/api/rest/query-parser.d.ts +0 -11
  65. package/dist/api/schema-editor-routes.d.ts +0 -3
  66. package/dist/api/server.d.ts +0 -40
  67. package/dist/api/types.d.ts +0 -98
  68. package/dist/auth/adapter-middleware.d.ts +0 -37
  69. package/dist/auth/admin-user-ops.d.ts +0 -79
  70. package/dist/auth/api-keys/api-key-middleware.d.ts +0 -39
  71. package/dist/auth/api-keys/api-key-permission-guard.d.ts +0 -32
  72. package/dist/auth/api-keys/api-key-routes.d.ts +0 -20
  73. package/dist/auth/api-keys/api-key-store.d.ts +0 -35
  74. package/dist/auth/api-keys/api-key-types.d.ts +0 -88
  75. package/dist/auth/api-keys/index.d.ts +0 -17
  76. package/dist/auth/apple-oauth.d.ts +0 -30
  77. package/dist/auth/auth-hooks.d.ts +0 -225
  78. package/dist/auth/bitbucket-oauth.d.ts +0 -11
  79. package/dist/auth/builtin-auth-adapter.d.ts +0 -49
  80. package/dist/auth/crypto-utils.d.ts +0 -16
  81. package/dist/auth/custom-auth-adapter.d.ts +0 -39
  82. package/dist/auth/discord-oauth.d.ts +0 -14
  83. package/dist/auth/facebook-oauth.d.ts +0 -14
  84. package/dist/auth/github-oauth.d.ts +0 -15
  85. package/dist/auth/gitlab-oauth.d.ts +0 -13
  86. package/dist/auth/google-oauth.d.ts +0 -47
  87. package/dist/auth/index.d.ts +0 -36
  88. package/dist/auth/interfaces.d.ts +0 -401
  89. package/dist/auth/jwt.d.ts +0 -55
  90. package/dist/auth/linkedin-oauth.d.ts +0 -18
  91. package/dist/auth/mfa-routes.d.ts +0 -6
  92. package/dist/auth/mfa.d.ts +0 -49
  93. package/dist/auth/microsoft-oauth.d.ts +0 -16
  94. package/dist/auth/middleware.d.ts +0 -106
  95. package/dist/auth/password.d.ts +0 -22
  96. package/dist/auth/rate-limiter.d.ts +0 -50
  97. package/dist/auth/reset-password-admin.d.ts +0 -29
  98. package/dist/auth/rls-scope.d.ts +0 -31
  99. package/dist/auth/routes.d.ts +0 -33
  100. package/dist/auth/session-routes.d.ts +0 -25
  101. package/dist/auth/slack-oauth.d.ts +0 -12
  102. package/dist/auth/spotify-oauth.d.ts +0 -12
  103. package/dist/auth/twitter-oauth.d.ts +0 -18
  104. package/dist/backend-CIxN4FVm.js +0 -15
  105. package/dist/backend-CIxN4FVm.js.map +0 -1
  106. package/dist/chunk-Dze3rakg.js +0 -42
  107. package/dist/collections/BackendCollectionRegistry.d.ts +0 -13
  108. package/dist/collections/loader.d.ts +0 -5
  109. package/dist/cron/cron-loader.d.ts +0 -17
  110. package/dist/cron/cron-routes.d.ts +0 -14
  111. package/dist/cron/cron-scheduler.d.ts +0 -106
  112. package/dist/cron/cron-store.d.ts +0 -32
  113. package/dist/cron/index.d.ts +0 -6
  114. package/dist/db/interfaces.d.ts +0 -18
  115. package/dist/dist-CZKP-Xz4.js +0 -832
  116. package/dist/dist-CZKP-Xz4.js.map +0 -1
  117. package/dist/email/index.d.ts +0 -6
  118. package/dist/email/smtp-email-service.d.ts +0 -25
  119. package/dist/email/templates.d.ts +0 -42
  120. package/dist/email/types.d.ts +0 -108
  121. package/dist/env.d.ts +0 -102
  122. package/dist/from-VbwD7xRf.js +0 -3849
  123. package/dist/from-VbwD7xRf.js.map +0 -1
  124. package/dist/functions/function-loader.d.ts +0 -17
  125. package/dist/functions/function-routes.d.ts +0 -10
  126. package/dist/functions/index.d.ts +0 -3
  127. package/dist/history/history-routes.d.ts +0 -23
  128. package/dist/history/index.d.ts +0 -1
  129. package/dist/index.d.ts +0 -33
  130. package/dist/index.es.js +0 -61726
  131. package/dist/index.es.js.map +0 -1
  132. package/dist/index.umd.js +0 -73216
  133. package/dist/index.umd.js.map +0 -1
  134. package/dist/init/docs.d.ts +0 -4
  135. package/dist/init/health.d.ts +0 -2
  136. package/dist/init/middlewares.d.ts +0 -10
  137. package/dist/init/shutdown.d.ts +0 -11
  138. package/dist/init/storage.d.ts +0 -5
  139. package/dist/init.d.ts +0 -258
  140. package/dist/jwt-DHcQRGC3.js +0 -4168
  141. package/dist/jwt-DHcQRGC3.js.map +0 -1
  142. package/dist/logger-BYU66ENZ.js +0 -94
  143. package/dist/logger-BYU66ENZ.js.map +0 -1
  144. package/dist/ms-BeBSuOXN.js +0 -125
  145. package/dist/ms-BeBSuOXN.js.map +0 -1
  146. package/dist/multipart-parser-CedBDOeC.js +0 -299
  147. package/dist/multipart-parser-CedBDOeC.js.map +0 -1
  148. package/dist/serve-spa.d.ts +0 -30
  149. package/dist/services/driver-registry.d.ts +0 -78
  150. package/dist/services/webhook-service.d.ts +0 -29
  151. package/dist/singleton.d.ts +0 -35
  152. package/dist/src-COaj0G3P.js +0 -1182
  153. package/dist/src-COaj0G3P.js.map +0 -1
  154. package/dist/storage/LocalStorageController.d.ts +0 -46
  155. package/dist/storage/S3StorageController.d.ts +0 -36
  156. package/dist/storage/image-transform.d.ts +0 -50
  157. package/dist/storage/index.d.ts +0 -28
  158. package/dist/storage/routes.d.ts +0 -38
  159. package/dist/storage/storage-registry.d.ts +0 -78
  160. package/dist/storage/tus-handler.d.ts +0 -51
  161. package/dist/storage/types.d.ts +0 -103
  162. package/dist/types/index.d.ts +0 -11
  163. package/dist/utils/dev-port.d.ts +0 -35
  164. package/dist/utils/logger.d.ts +0 -31
  165. package/dist/utils/logging.d.ts +0 -9
  166. package/dist/utils/request-id.d.ts +0 -4
  167. package/dist/utils/request-logger.d.ts +0 -19
  168. package/dist/utils/sql.d.ts +0 -27
@@ -37,6 +37,8 @@ export function listenWithPortRetry(
37
37
  maxAttempts?: number;
38
38
  /** Absolute path to write the resolved port file into. Defaults to `process.cwd()`. */
39
39
  portFileDir?: string;
40
+ /** Service key to include in the state file for MCP server auto-discovery. */
41
+ serviceKey?: string;
40
42
  }
41
43
  ): Promise<number> {
42
44
  const host = options?.host ?? "0.0.0.0";
@@ -122,7 +124,7 @@ export function listenWithPortRetry(
122
124
 
123
125
  // Write .rebase/state.json so external scripts can discover
124
126
  // the running server port, URL, etc.
125
- writeStateFile(portFileDir, port);
127
+ writeStateFile(portFileDir, port, options?.serviceKey);
126
128
  }
127
129
 
128
130
  resolve(port);
@@ -159,10 +161,11 @@ export function cleanupDevPortFile(dir: string): void {
159
161
  * Write `.rebase/state.json` with runtime info for external scripts.
160
162
  *
161
163
  * Scripts can read this file to discover:
162
- * - `port` — the actual port the backend is listening on
163
- * - `baseUrl` — full URL including protocol and port
164
- * - `pid` — the backend process ID
165
- * - `startedAt` — ISO timestamp of when the server started
164
+ * - `port` — the actual port the backend is listening on
165
+ * - `baseUrl` — full URL including protocol and port
166
+ * - `pid` — the backend process ID
167
+ * - `startedAt` — ISO timestamp of when the server started
168
+ * - `serviceKey` — (dev only) the REBASE_SERVICE_KEY for MCP auto-discovery
166
169
  *
167
170
  * @example Reading from a script:
168
171
  * ```ts
@@ -170,19 +173,22 @@ export function cleanupDevPortFile(dir: string): void {
170
173
  * const apiUrl = state.baseUrl; // "http://localhost:3519"
171
174
  * ```
172
175
  */
173
- function writeStateFile(projectRoot: string, port: number): void {
176
+ function writeStateFile(projectRoot: string, port: number, serviceKey?: string): void {
174
177
  try {
175
178
  const rebaseDir = path.join(projectRoot, ".rebase");
176
179
  if (!fs.existsSync(rebaseDir)) {
177
180
  fs.mkdirSync(rebaseDir, { recursive: true });
178
181
  }
179
182
  const stateFile = path.join(rebaseDir, "state.json");
180
- const state = {
183
+ const state: Record<string, unknown> = {
181
184
  port,
182
185
  baseUrl: `http://localhost:${port}`,
183
186
  pid: process.pid,
184
187
  startedAt: new Date().toISOString()
185
188
  };
189
+ if (serviceKey) {
190
+ state.serviceKey = serviceKey;
191
+ }
186
192
  fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), "utf-8");
187
193
  } catch {
188
194
  // Non-fatal
@@ -17,6 +17,18 @@ import { configureJwt, generateAccessToken, hashRefreshToken } from "../src/auth
17
17
 
18
18
  jest.mock("../src/auth/password");
19
19
 
20
+ jest.mock("../src/utils/logger", () => {
21
+ return {
22
+ logger: {
23
+ info: jest.fn(),
24
+ warn: jest.fn(),
25
+ error: jest.fn(),
26
+ debug: jest.fn(),
27
+ child: jest.fn().mockReturnThis()
28
+ }
29
+ };
30
+ });
31
+
20
32
  // Bypass rate limiters — they share state across tests and cause 429s
21
33
  jest.mock("../src/auth/rate-limiter", () => {
22
34
  const passthrough = async (_c: unknown, next: () => Promise<void>) => next();
@@ -28,6 +40,7 @@ jest.mock("../src/auth/rate-limiter", () => {
28
40
  });
29
41
 
30
42
  import { hashPassword, verifyPassword, validatePasswordStrength } from "../src/auth/password";
43
+ import { logger } from "../src/utils/logger";
31
44
  import { z } from "zod";
32
45
 
33
46
  // ── Helpers ─────────────────────────────────────────────────────────────────
@@ -139,15 +152,18 @@ verifyEmailUrl: "https://app.test" } : undefined,
139
152
  if (idToken === "link-token") return { providerId: "g-456",
140
153
  email: "existing@test.com",
141
154
  displayName: "Existing",
142
- photoUrl: null };
155
+ photoUrl: null,
156
+ emailVerified: true };
143
157
  if (idToken === "returning-token") return { providerId: "g-789",
144
158
  email: "returning@test.com",
145
159
  displayName: "Updated Name",
146
- photoUrl: "https://new-photo.url" };
160
+ photoUrl: "https://new-photo.url",
161
+ emailVerified: true };
147
162
  if (idToken === "valid-token") return { providerId: "g-123",
148
163
  email: "google@test.com",
149
164
  displayName: "Google User",
150
- photoUrl: "https://photo.url" };
165
+ photoUrl: "https://photo.url",
166
+ emailVerified: true };
151
167
  return null;
152
168
  }
153
169
  }
@@ -361,9 +377,43 @@ password: "Any1" }));
361
377
  mockAuthRepo.getUserByEmail.mockResolvedValueOnce(mockUser());
362
378
 
363
379
  await app.request("/auth/login", json({ email: "test@example.com",
364
- password: "ValidPass1" }));
380
+ password: "ValidPass1" }));
365
381
  expect(mockAuthRepo.createRefreshToken).toHaveBeenCalledTimes(1);
366
382
  });
383
+
384
+ it("emits a structured security audit log on successful login", async () => {
385
+ const app = createApp();
386
+ const user = mockUser({ passwordHash: "salt:hash" });
387
+ mockAuthRepo.getUserByEmail.mockResolvedValueOnce(user);
388
+
389
+ await app.request("/auth/login", json({ email: "test@example.com", password: "ValidPass1" }));
390
+
391
+ expect(logger.info).toHaveBeenCalledWith(
392
+ expect.stringContaining("[Security Audit] Auth login success"),
393
+ expect.objectContaining({
394
+ userId: "user-1",
395
+ email: "test@example.com",
396
+ eventType: "auth.login.success"
397
+ })
398
+ );
399
+ });
400
+
401
+ it("emits a structured security audit log on failed login (wrong password)", async () => {
402
+ const app = createApp();
403
+ mockAuthRepo.getUserByEmail.mockResolvedValueOnce(mockUser());
404
+ (verifyPassword as jest.Mock).mockResolvedValueOnce(false);
405
+
406
+ await app.request("/auth/login", json({ email: "test@example.com", password: "WrongPassword" }));
407
+
408
+ expect(logger.warn).toHaveBeenCalledWith(
409
+ expect.stringContaining("[Security Audit] Auth login failure"),
410
+ expect.objectContaining({
411
+ email: "test@example.com",
412
+ userId: "user-1",
413
+ eventType: "auth.login.failure"
414
+ })
415
+ );
416
+ });
367
417
  });
368
418
 
369
419
  // ── Google OAuth ────────────────────────────────────────────────────
@@ -1,5 +1,5 @@
1
1
  import { createCustomAuthAdapter } from "../src/auth/custom-auth-adapter";
2
- import type { AuthenticatedUser, CustomAuthAdapterOptions } from "@rebasepro/types";
2
+ import type { AuthenticatedUser, AuthResponsePayload, CustomAuthAdapterOptions } from "@rebasepro/types";
3
3
 
4
4
  const TEST_USER: AuthenticatedUser = {
5
5
  uid: "user-123",
@@ -118,6 +118,7 @@ describe("createCustomAuthAdapter", () => {
118
118
  sessionManagement: false,
119
119
  profileUpdate: false,
120
120
  emailVerification: false,
121
+ magicLink: false,
121
122
  enabledProviders: []
122
123
  });
123
124
  });
@@ -167,4 +168,22 @@ registration: true }
167
168
  });
168
169
  expect(adapter.userManagement).toBeUndefined();
169
170
  });
171
+
172
+ // ── transformAuthResponse ────────────────────────────────────────────
173
+
174
+ it("passes through transformAuthResponse when provided", () => {
175
+ const transformFn = jest.fn(async (response: AuthResponsePayload) => response);
176
+ const adapter = createCustomAuthAdapter({
177
+ verifyRequest: async () => null,
178
+ transformAuthResponse: transformFn
179
+ });
180
+ expect(adapter.transformAuthResponse).toBe(transformFn);
181
+ });
182
+
183
+ it("omits transformAuthResponse when not provided", () => {
184
+ const adapter = createCustomAuthAdapter({
185
+ verifyRequest: async () => null
186
+ });
187
+ expect(adapter.transformAuthResponse).toBeUndefined();
188
+ });
170
189
  });
package/test/env.test.ts CHANGED
@@ -1,32 +1,22 @@
1
- import { z } from "zod";
2
1
  import { loadEnv } from "../src/env";
2
+ import { z } from "zod";
3
3
 
4
4
  describe("env configuration and localhost validation", () => {
5
- let originalEnv: NodeJS.ProcessEnv;
5
+ const originalEnv = { ...process.env };
6
6
 
7
7
  beforeEach(() => {
8
- // Save a backup of the original process.env
9
- originalEnv = { ...process.env };
10
- // Clear env vars that might interfere with tests
11
- delete process.env.NODE_ENV;
12
- delete process.env.DATABASE_URL;
13
- delete process.env.ADMIN_CONNECTION_STRING;
14
- delete process.env.JWT_SECRET;
15
- delete process.env.FRONTEND_URL;
16
- delete process.env.CORS_ORIGINS;
17
- delete process.env.ALLOW_LOCALHOST_IN_PRODUCTION;
8
+ // Clear process.env to ensure fresh test runs
9
+ process.env = {};
10
+ // Add basic fallback requirements to prevent bootstrap crashes
11
+ process.env.DATABASE_URL = "postgresql://localhost:5432/rebase";
12
+ process.env.JWT_SECRET = "super-secret-jwt-key-must-be-long-long-long";
18
13
  });
19
14
 
20
15
  afterEach(() => {
21
- // Restore process.env after each test
22
- process.env = originalEnv;
16
+ process.env = { ...originalEnv };
23
17
  });
24
18
 
25
- it("should allow localhost URLs in development mode", () => {
26
- process.env.NODE_ENV = "development";
27
- process.env.DATABASE_URL = "postgresql://localhost:5432/rebase";
28
- process.env.JWT_SECRET = "12345678901234567890123456789012";
29
-
19
+ it("should parse default env variables in development mode", () => {
30
20
  expect(() => loadEnv()).not.toThrow();
31
21
  const env = loadEnv();
32
22
  expect(env.DATABASE_URL).toBe("postgresql://localhost:5432/rebase");
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect, beforeAll } from "@jest/globals";
2
+ import { Hono, Context } from "hono";
3
+ import { configureJwt, generateAccessToken } from "../src/auth/jwt";
4
+ import { createAuthMiddleware } from "../src/auth/middleware";
5
+ import { createAdapterAuthMiddleware } from "../src/auth/adapter-middleware";
6
+ import type { HonoEnv } from "../src/api/types";
7
+ import type { DataDriver, AuthAdapter } from "@rebasepro/types";
8
+
9
+ const TEST_SECRET = "test-secret-key-for-multi-datasource-routing-1234567890";
10
+
11
+ // A mock driver that tags itself so we can assert which one was scoped into
12
+ // the request context. The Postgres-like one implements withAuth (RLS), the
13
+ // Mongo-like one does not (scopeDataDriver must no-op for it).
14
+ function mockDriver(key: string, withRls: boolean): DataDriver {
15
+ const base: Record<string, unknown> = {
16
+ key,
17
+ fetchCollection: async () => [],
18
+ fetchEntity: async () => undefined,
19
+ saveEntity: async () => ({ id: "x", path: key, values: {} }),
20
+ deleteEntity: async () => undefined
21
+ };
22
+ if (withRls) {
23
+ // Returns a scoped clone that keeps the same key tag.
24
+ base.withAuth = async () => ({ ...base, scoped: true });
25
+ }
26
+ return base as unknown as DataDriver;
27
+ }
28
+
29
+ describe("Multi-data-source request routing (middleware)", () => {
30
+
31
+ beforeAll(() => {
32
+ configureJwt({ secret: TEST_SECRET, accessExpiresIn: "1h" });
33
+ });
34
+
35
+ const pg = mockDriver("postgres", true);
36
+ const mongo = mockDriver("mongodb", false);
37
+
38
+ // Route by first path segment: /products → pg, /events → mongo.
39
+ const resolveDriver = (c: Context<HonoEnv>): DataDriver => {
40
+ const slug = c.req.path.replace(/^\/+/, "").split("/")[0];
41
+ return slug === "events" ? mongo : pg;
42
+ };
43
+
44
+ function createApp() {
45
+ const app = new Hono<HonoEnv>();
46
+ app.use("/*", createAuthMiddleware({ driver: pg, resolveDriver, requireAuth: false }));
47
+ app.get("/:slug", (c: Context<HonoEnv>) => {
48
+ const driver = c.get("driver") as DataDriver | undefined;
49
+ return c.json({ key: driver?.key });
50
+ });
51
+ return app;
52
+ }
53
+
54
+ it("scopes the Postgres delegate for a Postgres collection path", async () => {
55
+ const token = generateAccessToken("u1", ["admin"]);
56
+ const res = await createApp().request("/products", { headers: { Authorization: `Bearer ${token}` } });
57
+ const body = await res.json() as { key: string };
58
+ expect(body.key).toBe("postgres");
59
+ });
60
+
61
+ it("scopes the Mongo delegate for a Mongo collection path", async () => {
62
+ const token = generateAccessToken("u1", ["admin"]);
63
+ const res = await createApp().request("/events", { headers: { Authorization: `Bearer ${token}` } });
64
+ const body = await res.json() as { key: string };
65
+ expect(body.key).toBe("mongodb");
66
+ });
67
+
68
+ it("routes per request based on the resolver, not a fixed driver", async () => {
69
+ const token = generateAccessToken("u1", ["admin"]);
70
+ const app = createApp();
71
+ const a = await (await app.request("/events", { headers: { Authorization: `Bearer ${token}` } })).json() as { key: string };
72
+ const b = await (await app.request("/products", { headers: { Authorization: `Bearer ${token}` } })).json() as { key: string };
73
+ expect(a.key).toBe("mongodb");
74
+ expect(b.key).toBe("postgres");
75
+ });
76
+
77
+ it("falls back to the base driver when no resolver is provided", async () => {
78
+ const app = new Hono<HonoEnv>();
79
+ app.use("/*", createAuthMiddleware({ driver: mongo, requireAuth: false }));
80
+ app.get("/:slug", (c: Context<HonoEnv>) => c.json({ key: (c.get("driver") as DataDriver | undefined)?.key }));
81
+ const res = await app.request("/anything");
82
+ const body = await res.json() as { key: string };
83
+ expect(body.key).toBe("mongodb");
84
+ });
85
+
86
+ it("routes a nested/subcollection path by its top-level segment", async () => {
87
+ const token = generateAccessToken("u1", ["admin"]);
88
+ const app = new Hono<HonoEnv>();
89
+ app.use("/*", createAuthMiddleware({ driver: pg, resolveDriver, requireAuth: false }));
90
+ app.get("/events/:id/attendees", (c: Context<HonoEnv>) =>
91
+ c.json({ key: (c.get("driver") as DataDriver | undefined)?.key }));
92
+ const res = await app.request("/events/e1/attendees", { headers: { Authorization: `Bearer ${token}` } });
93
+ const body = await res.json() as { key: string };
94
+ expect(body.key).toBe("mongodb");
95
+ });
96
+
97
+ it("routes per request through the adapter auth middleware too", async () => {
98
+ const adapter: AuthAdapter = {
99
+ id: "test-adapter",
100
+ serviceKey: undefined,
101
+ verifyRequest: async () => ({ uid: "u1", roles: ["admin"] })
102
+ } as unknown as AuthAdapter;
103
+
104
+ const app = new Hono<HonoEnv>();
105
+ app.use("/*", createAdapterAuthMiddleware({ adapter, driver: pg, resolveDriver, requireAuth: false }));
106
+ app.get("/:slug", (c: Context<HonoEnv>) => c.json({ key: (c.get("driver") as DataDriver | undefined)?.key }));
107
+
108
+ const a = await (await app.request("/events")).json() as { key: string };
109
+ const b = await (await app.request("/products")).json() as { key: string };
110
+ expect(a.key).toBe("mongodb");
111
+ expect(b.key).toBe("postgres");
112
+ });
113
+ });
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect, jest, beforeEach } from "@jest/globals";
2
+ import { createRoutedRealtimeService } from "../src/services/routed-realtime-service";
3
+ import type { RealtimeProvider } from "@rebasepro/types";
4
+
5
+ function mockProvider() {
6
+ return {
7
+ addClient: jest.fn(),
8
+ handleClientMessage: jest.fn(async () => {}),
9
+ subscribeToCollection: jest.fn(),
10
+ subscribeToEntity: jest.fn(),
11
+ unsubscribe: jest.fn(),
12
+ notifyEntityUpdate: jest.fn(async () => {}),
13
+ onServerReady: jest.fn(),
14
+ destroy: jest.fn(async () => {}),
15
+ stopListening: jest.fn(async () => {})
16
+ };
17
+ }
18
+
19
+ describe("createRoutedRealtimeService", () => {
20
+ let pg: ReturnType<typeof mockProvider>;
21
+ let mongo: ReturnType<typeof mockProvider>;
22
+ let routed: ReturnType<typeof createRoutedRealtimeService>;
23
+
24
+ beforeEach(() => {
25
+ pg = mockProvider();
26
+ mongo = mockProvider();
27
+ routed = createRoutedRealtimeService({
28
+ providers: { "(default)": pg as unknown as RealtimeProvider, mongo: mongo as unknown as RealtimeProvider },
29
+ defaultKey: "(default)",
30
+ resolveKey: (path) => (path.split("/")[0] === "events" ? "mongo" : "(default)")
31
+ });
32
+ });
33
+
34
+ it("routes subscribe_collection to the provider owning the collection", async () => {
35
+ await routed.handleClientMessage("c1", { type: "subscribe_collection", payload: { path: "events", subscriptionId: "s1" } });
36
+ expect(mongo.handleClientMessage).toHaveBeenCalledTimes(1);
37
+ expect(pg.handleClientMessage).not.toHaveBeenCalled();
38
+ });
39
+
40
+ it("routes subscribe_entity by path", async () => {
41
+ await routed.handleClientMessage("c1", { type: "subscribe_entity", payload: { path: "products", subscriptionId: "s2" } });
42
+ expect(pg.handleClientMessage).toHaveBeenCalledTimes(1);
43
+ expect(mongo.handleClientMessage).not.toHaveBeenCalled();
44
+ });
45
+
46
+ it("forwards unsubscribe to all providers (owner acts, others no-op)", async () => {
47
+ await routed.handleClientMessage("c1", { type: "unsubscribe", subscriptionId: "s1" });
48
+ expect(pg.handleClientMessage).toHaveBeenCalledTimes(1);
49
+ expect(mongo.handleClientMessage).toHaveBeenCalledTimes(1);
50
+ });
51
+
52
+ it("sends channel/presence/broadcast to the default provider only", async () => {
53
+ await routed.handleClientMessage("c1", { type: "broadcast", payload: { channel: "room", event: "x" } });
54
+ await routed.handleClientMessage("c1", { type: "join_channel", payload: { channel: "room" } });
55
+ await routed.handleClientMessage("c1", { type: "presence_track", payload: { channel: "room" } });
56
+ expect(pg.handleClientMessage).toHaveBeenCalledTimes(3);
57
+ expect(mongo.handleClientMessage).not.toHaveBeenCalled();
58
+ });
59
+
60
+ it("addClient is forwarded to every provider", () => {
61
+ const ws = {};
62
+ routed.addClient("c1", ws);
63
+ expect(pg.addClient).toHaveBeenCalledWith("c1", ws);
64
+ expect(mongo.addClient).toHaveBeenCalledWith("c1", ws);
65
+ });
66
+
67
+ it("notifyEntityUpdate routes by path", async () => {
68
+ await routed.notifyEntityUpdate("events", "e1", null);
69
+ expect(mongo.notifyEntityUpdate).toHaveBeenCalledTimes(1);
70
+ expect(pg.notifyEntityUpdate).not.toHaveBeenCalled();
71
+ });
72
+
73
+ it("unknown path falls back to the default provider", async () => {
74
+ await routed.handleClientMessage("c1", { type: "subscribe_collection", payload: { path: "mystery", subscriptionId: "s9" } });
75
+ expect(pg.handleClientMessage).toHaveBeenCalledTimes(1);
76
+ });
77
+
78
+ it("lifecycle methods fan out to all providers", async () => {
79
+ routed.onServerReady?.({ port: 3000 });
80
+ await routed.destroy?.();
81
+ expect(pg.onServerReady).toHaveBeenCalledTimes(1);
82
+ expect(mongo.onServerReady).toHaveBeenCalledTimes(1);
83
+ expect(pg.destroy).toHaveBeenCalledTimes(1);
84
+ expect(mongo.destroy).toHaveBeenCalledTimes(1);
85
+ });
86
+ });