@rebasepro/server-core 0.6.1 → 0.8.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 (116) hide show
  1. package/dist/api/graphql/graphql-schema-generator.d.ts +9 -0
  2. package/dist/api/rest/api-generator.d.ts +2 -14
  3. package/dist/api/rest/query-parser.d.ts +2 -5
  4. package/dist/api/types.d.ts +8 -2
  5. package/dist/auth/adapter-middleware.d.ts +7 -1
  6. package/dist/auth/admin-roles-route.d.ts +18 -0
  7. package/dist/auth/admin-users-route.d.ts +28 -0
  8. package/dist/auth/api-keys/api-key-types.d.ts +8 -0
  9. package/dist/auth/auth-hooks.d.ts +17 -0
  10. package/dist/auth/builtin-auth-adapter.d.ts +3 -1
  11. package/dist/auth/index.d.ts +1 -0
  12. package/dist/auth/interfaces.d.ts +32 -2
  13. package/dist/auth/magic-link-routes.d.ts +30 -0
  14. package/dist/auth/mfa-crypto.d.ts +23 -0
  15. package/dist/auth/mfa-routes.d.ts +2 -1
  16. package/dist/auth/middleware.d.ts +9 -2
  17. package/dist/auth/routes.d.ts +3 -1
  18. package/dist/auth/session-routes.d.ts +2 -0
  19. package/dist/backend-CIxN4FVm.js.map +1 -1
  20. package/dist/base64-js-C_frYBkI.js +1687 -0
  21. package/dist/base64-js-C_frYBkI.js.map +1 -0
  22. package/dist/{dist-CZKP-Xz4.js → dist-DMO-zF6D.js} +2 -2
  23. package/dist/{dist-CZKP-Xz4.js.map → dist-DMO-zF6D.js.map} +1 -1
  24. package/dist/email/index.d.ts +2 -2
  25. package/dist/email/templates.d.ts +8 -0
  26. package/dist/email/types.d.ts +18 -0
  27. package/dist/functions/define-function.d.ts +50 -0
  28. package/dist/functions/index.d.ts +2 -0
  29. package/dist/index.d.ts +16 -18
  30. package/dist/index.es.js +1947 -2356
  31. package/dist/index.es.js.map +1 -1
  32. package/dist/index.umd.js +40615 -10203
  33. package/dist/index.umd.js.map +1 -1
  34. package/dist/init.d.ts +68 -12
  35. package/dist/jws-BqRRaK11.js +616 -0
  36. package/dist/jws-BqRRaK11.js.map +1 -0
  37. package/dist/{jwt-DHcQRGC3.js → jwt-BETC8a1J.js} +3 -611
  38. package/dist/jwt-BETC8a1J.js.map +1 -0
  39. package/dist/services/routed-realtime-service.d.ts +43 -0
  40. package/dist/{src-COaj0G3P.js → src-CB2PIpBe.js} +2 -2
  41. package/dist/{src-COaj0G3P.js.map → src-CB2PIpBe.js.map} +1 -1
  42. package/dist/src-DjzOT1kG.js +29746 -0
  43. package/dist/src-DjzOT1kG.js.map +1 -0
  44. package/dist/storage/GCSStorageController.d.ts +43 -0
  45. package/dist/storage/index.d.ts +5 -2
  46. package/dist/storage/routes.d.ts +33 -1
  47. package/dist/storage/tus-handler.d.ts +3 -1
  48. package/dist/storage/types.d.ts +28 -3
  49. package/dist/utils/dev-port.d.ts +2 -0
  50. package/package.json +13 -5
  51. package/src/api/errors.ts +16 -2
  52. package/src/api/graphql/graphql-schema-generator.ts +50 -15
  53. package/src/api/openapi-generator.ts +2 -2
  54. package/src/api/rest/api-generator.ts +44 -123
  55. package/src/api/rest/query-parser.ts +6 -23
  56. package/src/api/server.ts +10 -2
  57. package/src/api/types.ts +8 -2
  58. package/src/auth/adapter-middleware.ts +10 -2
  59. package/src/auth/admin-roles-route.ts +36 -0
  60. package/src/auth/admin-users-route.ts +302 -0
  61. package/src/auth/api-keys/api-key-middleware.ts +4 -3
  62. package/src/auth/api-keys/api-key-routes.ts +12 -2
  63. package/src/auth/api-keys/api-key-store.ts +83 -66
  64. package/src/auth/api-keys/api-key-types.ts +8 -0
  65. package/src/auth/apple-oauth.ts +2 -1
  66. package/src/auth/auth-hooks.ts +21 -0
  67. package/src/auth/bitbucket-oauth.ts +2 -1
  68. package/src/auth/builtin-auth-adapter.ts +29 -6
  69. package/src/auth/custom-auth-adapter.ts +2 -0
  70. package/src/auth/discord-oauth.ts +2 -1
  71. package/src/auth/facebook-oauth.ts +2 -1
  72. package/src/auth/github-oauth.ts +2 -1
  73. package/src/auth/gitlab-oauth.ts +2 -1
  74. package/src/auth/google-oauth.ts +8 -4
  75. package/src/auth/index.ts +2 -0
  76. package/src/auth/interfaces.ts +38 -2
  77. package/src/auth/linkedin-oauth.ts +2 -1
  78. package/src/auth/magic-link-routes.ts +167 -0
  79. package/src/auth/mfa-crypto.ts +91 -0
  80. package/src/auth/mfa-routes.ts +34 -10
  81. package/src/auth/microsoft-oauth.ts +2 -1
  82. package/src/auth/middleware.ts +14 -5
  83. package/src/auth/reset-password-admin.ts +17 -1
  84. package/src/auth/routes.ts +78 -10
  85. package/src/auth/session-routes.ts +15 -3
  86. package/src/auth/slack-oauth.ts +2 -1
  87. package/src/auth/spotify-oauth.ts +2 -1
  88. package/src/auth/twitter-oauth.ts +8 -1
  89. package/src/cron/cron-store.ts +25 -23
  90. package/src/email/index.ts +3 -2
  91. package/src/email/templates.ts +82 -0
  92. package/src/email/types.ts +16 -0
  93. package/src/functions/define-function.ts +59 -0
  94. package/src/functions/function-loader.ts +16 -0
  95. package/src/functions/index.ts +2 -0
  96. package/src/index.ts +70 -37
  97. package/src/init.ts +214 -25
  98. package/src/services/routed-realtime-service.ts +113 -0
  99. package/src/storage/GCSStorageController.ts +334 -0
  100. package/src/storage/index.ts +9 -3
  101. package/src/storage/routes.ts +191 -23
  102. package/src/storage/tus-handler.ts +16 -4
  103. package/src/storage/types.ts +25 -3
  104. package/src/utils/dev-port.ts +13 -7
  105. package/test/api-generator.test.ts +1 -1
  106. package/test/auth-config-types.test.ts +40 -0
  107. package/test/auth-routes.test.ts +54 -4
  108. package/test/custom-auth-adapter.test.ts +20 -1
  109. package/test/define-function.test.ts +45 -0
  110. package/test/env.test.ts +9 -19
  111. package/test/multi-datasource-routing.test.ts +113 -0
  112. package/test/routed-realtime-service.test.ts +86 -0
  113. package/test/storage-routes.test.ts +160 -0
  114. package/test/transform-auth-response.test.ts +305 -0
  115. package/dist/jwt-DHcQRGC3.js.map +0 -1
  116. package/test/backend-hooks-data.test.ts +0 -477
@@ -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
  });
@@ -0,0 +1,45 @@
1
+ import { Hono } from "hono";
2
+ import { defineFunction } from "../src/functions/define-function";
3
+
4
+ describe("defineFunction", () => {
5
+ it("returns a loader-compatible Hono app (has .fetch and .routes)", () => {
6
+ const app = defineFunction((a) => {
7
+ a.get("/ping", (c) => c.text("pong"));
8
+ });
9
+
10
+ // These are exactly the properties the loader duck-types on.
11
+ expect(typeof app.fetch).toBe("function");
12
+ expect(Array.isArray(app.routes)).toBe(true);
13
+ });
14
+
15
+ it("registers routes on the provided app", async () => {
16
+ const app = defineFunction((a) => {
17
+ a.get("/hello", (c) => c.text("hi"));
18
+ });
19
+
20
+ const res = await app.request("/hello");
21
+ expect(res.status).toBe(200);
22
+ expect(await res.text()).toBe("hi");
23
+ });
24
+
25
+ it("exposes the rebase singleton via the injected context", () => {
26
+ let received: unknown;
27
+ defineFunction((_a, ctx) => {
28
+ received = ctx.rebase;
29
+ });
30
+ // The singleton reference is passed without being dereferenced,
31
+ // so no "not initialized" error is thrown at define time.
32
+ expect(received).toBeDefined();
33
+ });
34
+
35
+ it("uses a returned Hono app when the definition returns one", async () => {
36
+ const custom = new Hono();
37
+ custom.get("/custom", (c) => c.text("custom"));
38
+
39
+ const app = defineFunction(() => custom);
40
+
41
+ const res = await app.request("/custom");
42
+ expect(res.status).toBe(200);
43
+ expect(await res.text()).toBe("custom");
44
+ });
45
+ });
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
+ });
@@ -13,6 +13,7 @@ import { Hono } from "hono";
13
13
  import { HonoEnv } from "../src/api/types";
14
14
  import { errorHandler } from "../src/api/errors";
15
15
  import { LocalStorageController } from "../src/storage/LocalStorageController";
16
+ import { DefaultStorageRegistry } from "../src/storage/storage-registry";
16
17
  import { createStorageRoutes, extractWildcardPath } from "../src/storage/routes";
17
18
 
18
19
  // ──────────────────────────────────────────────────────────────────────
@@ -191,6 +192,38 @@ force: true });
191
192
  });
192
193
  });
193
194
 
195
+ describe("GET /sources", () => {
196
+ it("returns the default source in single-controller mode", async () => {
197
+ const res = await app.fetch(new Request("http://localhost/api/storage/sources"));
198
+ expect(res.status).toBe(200);
199
+ const body = await res.json() as { success: boolean; data: Array<{ key: string; engine: string; transport: string }> };
200
+ expect(body.success).toBe(true);
201
+ expect(body.data).toEqual([
202
+ { key: "(default)", engine: "local", transport: "server" }
203
+ ]);
204
+ });
205
+
206
+ it("merges declared definitions, including direct sources and labels", async () => {
207
+ const declaredApp = new Hono<HonoEnv>();
208
+ declaredApp.onError(errorHandler);
209
+ const routes = createStorageRoutes({
210
+ controller,
211
+ requireAuth: false,
212
+ sources: [
213
+ { key: "(default)", engine: "local", transport: "server", label: "Local" },
214
+ { key: "firebase", engine: "firebase", transport: "direct", label: "Firebase Storage" }
215
+ ]
216
+ });
217
+ declaredApp.route("/api/storage", routes);
218
+
219
+ const res = await declaredApp.fetch(new Request("http://localhost/api/storage/sources"));
220
+ expect(res.status).toBe(200);
221
+ const body = await res.json() as { success: boolean; data: Array<{ key: string; engine: string; transport: string; label?: string }> };
222
+ expect(body.data).toContainEqual({ key: "(default)", engine: "local", transport: "server", label: "Local" });
223
+ expect(body.data).toContainEqual({ key: "firebase", engine: "firebase", transport: "direct", label: "Firebase Storage" });
224
+ });
225
+ });
226
+
194
227
  describe("DELETE /file/*", () => {
195
228
  it("should delete an existing file", async () => {
196
229
  // Upload another file to delete
@@ -220,3 +253,130 @@ key: "photos/deleteme.txt" });
220
253
  });
221
254
  });
222
255
  });
256
+
257
+ // ──────────────────────────────────────────────────────────────────────
258
+ // Multi-backend routing through the StorageRegistry
259
+ // ──────────────────────────────────────────────────────────────────────
260
+ describe("Storage routes — multi-backend routing (registry)", () => {
261
+ let app: Hono<HonoEnv>;
262
+ let defaultDir: string;
263
+ let secondaryDir: string;
264
+
265
+ beforeEach(async () => {
266
+ defaultDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "rebase-default-"));
267
+ secondaryDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "rebase-secondary-"));
268
+
269
+ const registry = DefaultStorageRegistry.create({
270
+ "(default)": new LocalStorageController({ basePath: defaultDir }),
271
+ secondary: new LocalStorageController({ basePath: secondaryDir })
272
+ });
273
+
274
+ app = new Hono<HonoEnv>();
275
+ app.onError(errorHandler);
276
+ app.route("/api/storage", createStorageRoutes({
277
+ registry,
278
+ requireAuth: false,
279
+ sources: [
280
+ { key: "(default)", engine: "local", transport: "server" },
281
+ { key: "secondary", engine: "local", transport: "server", label: "Secondary" }
282
+ ]
283
+ }));
284
+ });
285
+
286
+ afterEach(async () => {
287
+ await fs.promises.rm(defaultDir, { recursive: true, force: true });
288
+ await fs.promises.rm(secondaryDir, { recursive: true, force: true });
289
+ });
290
+
291
+ async function upload(storageId: string | undefined, key: string, content: string) {
292
+ const fd = new FormData();
293
+ fd.append("file", new File([Buffer.from(content)], "file.txt", { type: "text/plain" }));
294
+ fd.append("key", key);
295
+ if (storageId) fd.append("storageId", storageId);
296
+ return app.fetch(new Request("http://localhost/api/storage/upload", { method: "POST", body: fd }));
297
+ }
298
+
299
+ it("routes an upload to the backend named by storageId", async () => {
300
+ const res = await upload("secondary", "docs/secret.txt", "in-secondary");
301
+ expect(res.ok).toBe(true);
302
+
303
+ // It physically landed in the secondary backend, not the default one.
304
+ const inSecondary = await app.fetch(
305
+ new Request("http://localhost/api/storage/file/default/docs/secret.txt?storageId=secondary")
306
+ );
307
+ expect(inSecondary.status).toBe(200);
308
+ expect(await inSecondary.text()).toBe("in-secondary");
309
+
310
+ // The default backend does not have it.
311
+ const inDefault = await app.fetch(
312
+ new Request("http://localhost/api/storage/file/default/docs/secret.txt")
313
+ );
314
+ expect(inDefault.status).toBe(404);
315
+ });
316
+
317
+ it("keeps the same key isolated per backend", async () => {
318
+ await upload(undefined, "shared.txt", "from-default");
319
+ await upload("secondary", "shared.txt", "from-secondary");
320
+
321
+ const d = await app.fetch(new Request("http://localhost/api/storage/file/default/shared.txt"));
322
+ const s = await app.fetch(new Request("http://localhost/api/storage/file/default/shared.txt?storageId=secondary"));
323
+ expect(await d.text()).toBe("from-default");
324
+ expect(await s.text()).toBe("from-secondary");
325
+ });
326
+
327
+ it("serves metadata from the backend named by storageId", async () => {
328
+ await upload("secondary", "m.txt", "x");
329
+ const res = await app.fetch(
330
+ new Request("http://localhost/api/storage/metadata/default/m.txt?storageId=secondary")
331
+ );
332
+ expect(res.status).toBe(200);
333
+ const body = await res.json() as { success: boolean; data: { contentType: string } };
334
+ expect(body.success).toBe(true);
335
+ expect(body.data.contentType).toBe("text/plain");
336
+ });
337
+
338
+ it("lists only the targeted backend's contents", async () => {
339
+ await upload(undefined, "only-default.txt", "a");
340
+ await upload("secondary", "only-secondary.txt", "b");
341
+
342
+ const res = await app.fetch(new Request("http://localhost/api/storage/list?storageId=secondary"));
343
+ const body = await res.json() as { data: { items: Array<{ name: string }> } };
344
+ const names = body.data.items.map((i) => i.name);
345
+ expect(names).toContain("only-secondary.txt");
346
+ expect(names).not.toContain("only-default.txt");
347
+ });
348
+
349
+ it("deletes from the backend named by storageId", async () => {
350
+ await upload("secondary", "del.txt", "bye");
351
+
352
+ const del = await app.fetch(
353
+ new Request("http://localhost/api/storage/file/default/del.txt?storageId=secondary", { method: "DELETE" })
354
+ );
355
+ expect(del.status).toBe(200);
356
+
357
+ const after = await app.fetch(
358
+ new Request("http://localhost/api/storage/file/default/del.txt?storageId=secondary")
359
+ );
360
+ expect(after.status).toBe(404);
361
+ });
362
+
363
+ it("falls back to the default backend for an unknown storageId", async () => {
364
+ const res = await upload("does-not-exist", "fallback.txt", "fell-back");
365
+ expect(res.ok).toBe(true);
366
+
367
+ // Unknown id falls back to default, so the file is in the default backend.
368
+ const inDefault = await app.fetch(
369
+ new Request("http://localhost/api/storage/file/default/fallback.txt")
370
+ );
371
+ expect(inDefault.status).toBe(200);
372
+ expect(await inDefault.text()).toBe("fell-back");
373
+ });
374
+
375
+ it("lists both backends via GET /sources", async () => {
376
+ const res = await app.fetch(new Request("http://localhost/api/storage/sources"));
377
+ const body = await res.json() as { data: Array<{ key: string; engine: string; label?: string }> };
378
+ const keys = body.data.map((s) => s.key);
379
+ expect(keys).toEqual(expect.arrayContaining(["(default)", "secondary"]));
380
+ expect(body.data.find((s) => s.key === "secondary")?.label).toBe("Secondary");
381
+ });
382
+ });