@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,13 +1,20 @@
1
1
  /**
2
2
  * Storage REST API routes using Hono
3
+ *
4
+ * Supports multi-backend routing via `StorageRegistry`. Each endpoint
5
+ * accepts an optional `storageId` parameter (query string or form field)
6
+ * to target a named storage backend. When omitted, the default backend
7
+ * is used.
3
8
  */
4
9
 
5
- import { Hono } from "hono";
10
+ import { Hono, type MiddlewareHandler } from "hono";
6
11
  import fs from "node:fs";
7
12
  import fsp from "node:fs/promises";
8
13
  import { StorageController } from "./types";
9
14
  import { LocalStorageController } from "./LocalStorageController";
10
- import { requireAuth as jwtRequireAuth, optionalAuth } from "../auth/middleware";
15
+ import type { StorageRegistry } from "./storage-registry";
16
+ import { DEFAULT_STORAGE_SOURCE_KEY, type StorageSourceDefinition, type AuthAdapter } from "@rebasepro/types";
17
+ import { requireAuth as jwtRequireAuth, optionalAuth as jwtOptionalAuth } from "../auth/middleware";
11
18
  import { ApiError, errorHandler } from "../api/errors";
12
19
  import { HonoEnv } from "../api/types";
13
20
  import { parseTransformOptions, transformImage, isTransformableImage, TransformCache } from "./image-transform";
@@ -17,7 +24,25 @@ import { TusHandler } from "./tus-handler";
17
24
  const transformCache = new TransformCache();
18
25
 
19
26
  export interface StorageRoutesConfig {
20
- controller: StorageController;
27
+ /**
28
+ * Single storage controller (backward-compatible).
29
+ * Used as fallback when no `registry` is provided.
30
+ */
31
+ controller?: StorageController;
32
+ /**
33
+ * Full storage registry for multi-backend routing.
34
+ * When provided, endpoints resolve the controller from `storageId`
35
+ * parameter. Takes precedence over `controller`.
36
+ */
37
+ registry?: StorageRegistry;
38
+ /**
39
+ * Declared storage sources, surfaced by `GET /sources` so the client can
40
+ * bootstrap its registry. Carries the frontend `transport` (server vs
41
+ * direct) and human-readable labels. Server-transport sources are also
42
+ * derived from the registry; `direct` sources (e.g. Firebase Storage) only
43
+ * exist here since the backend does not proxy them.
44
+ */
45
+ sources?: StorageSourceDefinition[];
21
46
  /** Base path for storage routes (default: '/api/storage') */
22
47
  basePath?: string;
23
48
  /** Require authentication for write operations (default: true) */
@@ -25,6 +50,13 @@ export interface StorageRoutesConfig {
25
50
  /** Allow unauthenticated read access to stored files (default: false).
26
51
  * When false and requireAuth is true, reads also require authentication. */
27
52
  publicRead?: boolean;
53
+ /**
54
+ * When provided, storage routes delegate auth to this adapter instead
55
+ * of the built-in JWT module. This mirrors how data routes use
56
+ * `createAdapterAuthMiddleware()` and avoids the "JWT secret not
57
+ * configured" crash when `configureJwt()` was never called.
58
+ */
59
+ authAdapter?: AuthAdapter;
28
60
  }
29
61
 
30
62
  /**
@@ -66,19 +98,98 @@ function sanitizeStorageKey(key: string): string {
66
98
  return sanitized;
67
99
  }
68
100
 
101
+ /**
102
+ * Build adapter-aware auth middleware for storage routes.
103
+ *
104
+ * When an `AuthAdapter` is provided, token verification is delegated to the
105
+ * adapter instead of the built-in JWT module. This mirrors how data routes
106
+ * use `createAdapterAuthMiddleware()`, but without RLS driver scoping (storage
107
+ * routes don't interact with the DataDriver).
108
+ *
109
+ * Returns both a "write" middleware (enforces auth when `requireAuth` is true)
110
+ * and a "read" middleware (enforces auth unless `publicRead` is set).
111
+ */
112
+ function buildAdapterAuthMiddleware(
113
+ adapter: AuthAdapter,
114
+ requireAuth: boolean,
115
+ publicRead: boolean
116
+ ): { writeAuthMiddleware: MiddlewareHandler<HonoEnv>; readAuthMiddleware: MiddlewareHandler<HonoEnv> } {
117
+ /**
118
+ * Core middleware: verifies the request via the adapter. When `enforce`
119
+ * is true, returns 401 if no authenticated user is resolved.
120
+ */
121
+ const createMiddleware = (enforce: boolean): MiddlewareHandler<HonoEnv> => {
122
+ return async (c, next) => {
123
+ let authenticatedUser = null;
124
+ try {
125
+ authenticatedUser = await adapter.verifyRequest(c.req.raw);
126
+ } catch {
127
+ return c.json({ error: { message: "Unauthorized", code: "UNAUTHORIZED" } }, 401);
128
+ }
129
+
130
+ if (authenticatedUser) {
131
+ c.set("user", {
132
+ userId: authenticatedUser.uid,
133
+ email: authenticatedUser.email,
134
+ roles: authenticatedUser.roles
135
+ });
136
+ }
137
+
138
+ if (enforce && !authenticatedUser) {
139
+ return c.json({ error: { message: "Unauthorized: Authentication required", code: "UNAUTHORIZED" } }, 401);
140
+ }
141
+
142
+ return next();
143
+ };
144
+ };
145
+
146
+ return {
147
+ writeAuthMiddleware: createMiddleware(requireAuth),
148
+ readAuthMiddleware: createMiddleware(!publicRead && requireAuth)
149
+ };
150
+ }
151
+
69
152
  /**
70
153
  * Create storage REST API routes
71
154
  */
72
155
  export function createStorageRoutes(config: StorageRoutesConfig): Hono<HonoEnv> {
73
156
  const router = new Hono<HonoEnv>();
74
157
  router.onError(errorHandler);
75
- const { controller, requireAuth = true, publicRead = false } = config;
158
+ const { controller, registry, sources: declaredSources, requireAuth = true, publicRead = false, authAdapter } = config;
76
159
 
77
- // Use actual JWT auth middleware from auth module
78
- const writeAuthMiddleware = requireAuth ? jwtRequireAuth : optionalAuth;
160
+ /**
161
+ * Resolve the storage controller for a request.
162
+ * Looks up by `storageId` in the registry, falls back to the single
163
+ * controller, and finally to the registry default.
164
+ */
165
+ const resolveController = (storageId?: string | null): StorageController => {
166
+ if (registry) {
167
+ return registry.getOrDefault(storageId);
168
+ }
169
+ if (controller) {
170
+ return controller;
171
+ }
172
+ throw new Error("No storage controller or registry available");
173
+ };
174
+
175
+ /** Get the default controller (used for TUS and base-path derivation). */
176
+ const getDefaultController = (): StorageController => {
177
+ if (registry) return registry.getDefault();
178
+ if (controller) return controller;
179
+ throw new Error("No storage controller or registry available");
180
+ };
79
181
 
80
- // For read operations: respect publicRead config.
81
- const readAuthMiddleware = (publicRead || !requireAuth) ? optionalAuth : jwtRequireAuth;
182
+ // ── Auth middleware selection ────────────────────────────────────────
183
+ // When an AuthAdapter is available, delegate token verification to it
184
+ // (mirroring the data-routes pattern). This avoids calling the JWT
185
+ // module which may not have been configured (e.g. custom auth).
186
+ // When no adapter is present, fall back to the built-in JWT middleware.
187
+ const { writeAuthMiddleware, readAuthMiddleware } = authAdapter
188
+ ? buildAdapterAuthMiddleware(authAdapter, requireAuth, publicRead)
189
+ : {
190
+ writeAuthMiddleware: requireAuth ? jwtRequireAuth : jwtOptionalAuth,
191
+ readAuthMiddleware: (publicRead || !requireAuth) ? jwtOptionalAuth : jwtRequireAuth
192
+ };
82
193
 
83
194
  /**
84
195
  * Parse bucket and path from a combined file path.
@@ -116,6 +227,7 @@ export function createStorageRoutes(config: StorageRoutesConfig): Hono<HonoEnv>
116
227
 
117
228
  const key = typeof body["key"] === "string" ? body["key"] : "";
118
229
  const bucket = typeof body["bucket"] === "string" ? body["bucket"] : undefined;
230
+ const storageId = typeof body["storageId"] === "string" ? body["storageId"] : c.req.query("storageId");
119
231
 
120
232
  const finalKey = sanitizeStorageKey(key || uploadedFile.name || "unnamed");
121
233
 
@@ -127,7 +239,8 @@ export function createStorageRoutes(config: StorageRoutesConfig): Hono<HonoEnv>
127
239
  }
128
240
  }
129
241
 
130
- const result = await controller.putObject({
242
+ const resolved = resolveController(storageId);
243
+ const result = await resolved.putObject({
131
244
  file: uploadedFile,
132
245
  key: finalKey,
133
246
  metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
@@ -155,13 +268,15 @@ export function createStorageRoutes(config: StorageRoutesConfig): Hono<HonoEnv>
155
268
  }
156
269
 
157
270
  const filePath = decodeURIComponent(rawPath);
271
+ const storageId = c.req.query("storageId");
272
+ const resolved = resolveController(storageId);
158
273
 
159
274
  // Parse image transform query params (e.g. ?width=300&format=webp)
160
275
  const transformOpts = parseTransformOptions(c.req.query() as Record<string, string>);
161
276
 
162
277
  // For local storage, serve the file directly from disk
163
- if (controller.getType() === "local") {
164
- const localController = controller as LocalStorageController;
278
+ if (resolved.getType() === "local") {
279
+ const localController = resolved as LocalStorageController;
165
280
  const { bucket, resolvedPath } = parseBucketAndPath(filePath);
166
281
 
167
282
  const absolutePath = localController.getAbsolutePath(resolvedPath, bucket);
@@ -208,7 +323,7 @@ export function createStorageRoutes(config: StorageRoutesConfig): Hono<HonoEnv>
208
323
  // 1. Mixed-content (HTTPS page → HTTP MinIO) is blocked by browsers
209
324
  // 2. Internal IPs / VPC endpoints are unreachable from the browser
210
325
  const { bucket: parsedBucket, resolvedPath: parsedPath } = parseBucketAndPath(filePath);
211
- const fileObject = await controller.getObject(parsedPath, parsedBucket);
326
+ const fileObject = await resolved.getObject(parsedPath, parsedBucket);
212
327
  if (!fileObject) {
213
328
  throw ApiError.notFound("File not found");
214
329
  }
@@ -249,9 +364,11 @@ export function createStorageRoutes(config: StorageRoutesConfig): Hono<HonoEnv>
249
364
  }
250
365
 
251
366
  const filePath = decodeURIComponent(rawPath);
367
+ const storageId = c.req.query("storageId");
368
+ const resolved = resolveController(storageId);
252
369
  const { bucket, resolvedPath } = parseBucketAndPath(filePath);
253
370
 
254
- const downloadConfig = await controller.getSignedUrl(resolvedPath, bucket);
371
+ const downloadConfig = await resolved.getSignedUrl(resolvedPath, bucket);
255
372
 
256
373
  if (downloadConfig.fileNotFound) {
257
374
  throw ApiError.notFound("File not found");
@@ -274,9 +391,11 @@ message: "No file to delete" });
274
391
  }
275
392
 
276
393
  const filePath = decodeURIComponent(rawPath);
394
+ const storageId = c.req.query("storageId");
395
+ const resolved = resolveController(storageId);
277
396
  const { bucket, resolvedPath } = parseBucketAndPath(filePath);
278
397
 
279
- await controller.deleteObject(resolvedPath, bucket);
398
+ await resolved.deleteObject(resolvedPath, bucket);
280
399
 
281
400
  return c.json({
282
401
  success: true,
@@ -293,11 +412,13 @@ message: "No file to delete" });
293
412
  const bucket = c.req.query("bucket");
294
413
  const maxResults = c.req.query("maxResults");
295
414
  const pageToken = c.req.query("pageToken");
415
+ const storageId = c.req.query("storageId");
416
+ const resolved = resolveController(storageId);
296
417
 
297
- const result = await controller.listObjects(
418
+ const result = await resolved.listObjects(
298
419
  storagePrefix,
299
420
  {
300
- bucket: bucket ?? (controller.getType() === "local" ? "default" : undefined),
421
+ bucket: bucket ?? (resolved.getType() === "local" ? "default" : undefined),
301
422
  maxResults: maxResults ? parseInt(maxResults, 10) : undefined,
302
423
  pageToken
303
424
  }
@@ -316,27 +437,29 @@ message: "No file to delete" });
316
437
  router.post("/folder", writeAuthMiddleware, async (c) => {
317
438
  const body = await c.req.json();
318
439
  const folderPath = body.path;
440
+ const storageId = typeof body.storageId === "string" ? body.storageId : c.req.query("storageId");
319
441
 
320
442
  if (!folderPath || typeof folderPath !== "string") {
321
443
  throw ApiError.badRequest("Folder path is required");
322
444
  }
323
445
 
446
+ const resolved = resolveController(storageId);
324
447
  const { bucket, resolvedPath } = parseBucketAndPath(folderPath);
325
448
 
326
449
  if (!resolvedPath || resolvedPath.trim() === "") {
327
450
  throw ApiError.badRequest("Invalid folder path");
328
451
  }
329
452
 
330
- if (controller.getType() === "local") {
453
+ if (resolved.getType() === "local") {
331
454
  // For local storage, create the directory
332
- const localController = controller as LocalStorageController;
455
+ const localController = resolved as LocalStorageController;
333
456
  const absolutePath = localController.getAbsolutePath(resolvedPath, bucket);
334
457
  fs.mkdirSync(absolutePath, { recursive: true });
335
458
  } else {
336
- // For S3-compatible storage, create a zero-byte marker object with trailing slash
459
+ // For S3/GCS-compatible storage, create a zero-byte marker object with trailing slash
337
460
  const key = resolvedPath.endsWith("/") ? resolvedPath : resolvedPath + "/";
338
461
  const emptyFile = new File([], key, { type: "application/x-directory" });
339
- await controller.putObject({
462
+ await resolved.putObject({
340
463
  file: emptyFile,
341
464
  key
342
465
  });
@@ -352,10 +475,11 @@ message: "No file to delete" });
352
475
  // TUS Resumable Uploads
353
476
  // -----------------------------------------------------------------------
354
477
 
355
- const tusBaseDir = controller.getType() === "local"
356
- ? (controller as LocalStorageController).getBasePath()
478
+ const defaultCtrl = getDefaultController();
479
+ const tusBaseDir = defaultCtrl.getType() === "local"
480
+ ? (defaultCtrl as LocalStorageController).getBasePath()
357
481
  : (process.env.STORAGE_PATH || "./uploads");
358
- const tusHandler = new TusHandler(tusBaseDir, controller);
482
+ const tusHandler = new TusHandler(tusBaseDir, defaultCtrl, registry);
359
483
  tusHandler.startCleanup();
360
484
 
361
485
  router.options("/tus", (_c) => tusHandler.options());
@@ -364,5 +488,49 @@ message: "No file to delete" });
364
488
  router.patch("/tus/:id", writeAuthMiddleware, async (c) => tusHandler.patch(c, c.req.param("id")));
365
489
  router.delete("/tus/:id", writeAuthMiddleware, async (c) => tusHandler.delete(c, c.req.param("id")));
366
490
 
491
+ // -----------------------------------------------------------------------
492
+ // Storage Sources Discovery
493
+ // -----------------------------------------------------------------------
494
+
495
+ /**
496
+ * GET /sources — list all registered storage backends.
497
+ * The client can bootstrap its StorageSourceRegistry from this endpoint.
498
+ */
499
+ router.get("/sources", (c) => {
500
+ const byKey = new Map<string, { key: string; engine: string; transport: "server" | "direct"; label?: string }>();
501
+
502
+ // 1. Server-backed sources derived from the registry (source of truth
503
+ // for the actual engine type), or the single controller.
504
+ if (registry) {
505
+ for (const key of registry.list()) {
506
+ byKey.set(key, {
507
+ key,
508
+ engine: registry.get(key)?.getType() ?? "unknown",
509
+ transport: "server",
510
+ });
511
+ }
512
+ } else {
513
+ byKey.set(DEFAULT_STORAGE_SOURCE_KEY, {
514
+ key: DEFAULT_STORAGE_SOURCE_KEY,
515
+ engine: defaultCtrl.getType(),
516
+ transport: "server",
517
+ });
518
+ }
519
+
520
+ // 2. Overlay declared definitions: adds `direct` sources the backend
521
+ // does not proxy, plus labels and explicit transport/engine.
522
+ for (const def of declaredSources ?? []) {
523
+ const existing = byKey.get(def.key);
524
+ byKey.set(def.key, {
525
+ key: def.key,
526
+ engine: def.engine ?? existing?.engine ?? "unknown",
527
+ transport: def.transport ?? existing?.transport ?? "server",
528
+ label: def.label ?? existing?.label,
529
+ });
530
+ }
531
+
532
+ return c.json({ success: true, data: Array.from(byKey.values()) });
533
+ });
534
+
367
535
  return router;
368
536
  }
@@ -14,6 +14,7 @@ import { existsSync } from "fs";
14
14
  import { join } from "path";
15
15
  import type { Context } from "hono";
16
16
  import type { StorageController } from "./types";
17
+ import type { StorageRegistry } from "./storage-registry";
17
18
  import { logger } from "../utils/logger.js";
18
19
 
19
20
  /** Metadata for an in-progress resumable upload. */
@@ -57,7 +58,8 @@ export class TusHandler {
57
58
 
58
59
  constructor(
59
60
  storageBaseDir: string,
60
- private storageController?: StorageController
61
+ private storageController?: StorageController,
62
+ private storageRegistry?: StorageRegistry
61
63
  ) {
62
64
  this.tusDir = join(storageBaseDir, ".tus-uploads");
63
65
  }
@@ -283,7 +285,17 @@ export class TusHandler {
283
285
  private async finalize(upload: TusUpload): Promise<void> {
284
286
  upload.completed = true;
285
287
 
286
- if (!this.storageController) {
288
+ // Resolve the target controller: prefer storageId from TUS metadata,
289
+ // then fall back to the registry default, then the single controller.
290
+ const storageId = upload.metadata.storageId;
291
+ let targetController = this.storageController;
292
+ if (this.storageRegistry) {
293
+ targetController = storageId
294
+ ? this.storageRegistry.getOrDefault(storageId)
295
+ : this.storageRegistry.getDefault();
296
+ }
297
+
298
+ if (!targetController) {
287
299
  // No controller — leave temp file in place
288
300
  logger.warn("[TUS] Upload completed but no StorageController configured. Temp file remains:", { filePath: upload.filePath });
289
301
  return;
@@ -297,7 +309,7 @@ export class TusHandler {
297
309
 
298
310
  const file = new File([data], fileName, { type: mimeType });
299
311
 
300
- await this.storageController.putObject({
312
+ await targetController.putObject({
301
313
  file,
302
314
  key: fileName,
303
315
  bucket: upload.bucket
@@ -307,7 +319,7 @@ export class TusHandler {
307
319
  try { await unlink(upload.filePath); } catch { /* ok */ }
308
320
  this.uploads.delete(upload.id);
309
321
 
310
- logger.info(`[TUS] Upload ${upload.id} finalized → ${fileName}`);
322
+ logger.info(`[TUS] Upload ${upload.id} finalized → ${fileName}`, storageId ? { storageId } : {});
311
323
  } catch (err) {
312
324
  logger.error(`[TUS] Failed to finalize upload ${upload.id}`, { error: err });
313
325
  }
@@ -45,18 +45,40 @@ export interface S3StorageConfig {
45
45
  }
46
46
 
47
47
  /**
48
- * Storage configuration local filesystem or S3-compatible.
48
+ * Google Cloud Storage configuration (works with GCS and Firebase Storage)
49
+ */
50
+ export interface GCSStorageConfig {
51
+ type: "gcs";
52
+ /** GCS bucket name (e.g. "my-project.appspot.com" for Firebase Storage) */
53
+ bucket: string;
54
+ /** GCP project ID (optional, auto-detected from credentials) */
55
+ projectId?: string;
56
+ /** Path to service account JSON key file */
57
+ keyFilename?: string;
58
+ /** Service account credentials object (alternative to keyFilename) */
59
+ credentials?: { client_email: string; private_key: string; project_id?: string };
60
+ /** Maximum file size in bytes (default: 50MB) */
61
+ maxFileSize?: number;
62
+ /** Allowed MIME types */
63
+ allowedMimeTypes?: string[];
64
+ /** Signed URL expiration in seconds (default: 3600) */
65
+ signedUrlExpiration?: number;
66
+ }
67
+
68
+ /**
69
+ * Storage configuration — local filesystem, S3-compatible, or Google Cloud Storage.
49
70
  *
50
71
  * **Built-in providers:**
51
72
  * - `local` — Zero-config filesystem storage. Great for dev and single-server deployments (Hetzner, bare metal).
52
73
  * - `s3` — Any S3-compatible provider. AWS S3, Cloudflare R2, MinIO, Hetzner Object Storage,
53
74
  * Backblaze B2, DigitalOcean Spaces, and even GCS (via its S3-compatible interoperability API).
75
+ * - `gcs` — Native Google Cloud Storage / Firebase Storage via `@google-cloud/storage`.
54
76
  *
55
77
  * **Custom providers:**
56
- * For cloud-native storage (GCS, Azure Blob, etc.), implement the `StorageController`
78
+ * For other cloud storage (Azure Blob, etc.), implement the `StorageController`
57
79
  * interface and pass the instance directly to the `storage` config.
58
80
  */
59
- export type BackendStorageConfig = LocalStorageConfig | S3StorageConfig;
81
+ export type BackendStorageConfig = LocalStorageConfig | S3StorageConfig | GCSStorageConfig;
60
82
 
61
83
  /**
62
84
  * Storage controller interface for backend implementations
@@ -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
@@ -545,7 +545,7 @@ passwordHash: "custom-hash" },
545
545
  } as any
546
546
  ];
547
547
 
548
- const generator = new RestApiGenerator(authCollections, mockDriver, undefined, mockAuthAdapter as any);
548
+ const generator = new RestApiGenerator(authCollections, mockDriver, mockAuthAdapter as any);
549
549
  app.route("/api", generator.generateRoutes());
550
550
 
551
551
  mockDriver.saveEntity.mockResolvedValue({
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Compile-time tests for `RebaseAuthConfig`.
3
+ *
4
+ * The Jest assertions are trivial (always true) — the real value is that
5
+ * `tsc` validates the `@ts-expect-error` annotations: if a line compiles
6
+ * when it shouldn't, tsc reports an error. This guards against a
7
+ * regression of the `[key: string]: unknown` catch-all that used to make
8
+ * every key (including typos) compile silently.
9
+ */
10
+ import type { RebaseAuthConfig } from "../src/init";
11
+
12
+ describe("RebaseAuthConfig", () => {
13
+
14
+ it("accepts known top-level keys", () => {
15
+ const config: RebaseAuthConfig = {
16
+ jwtSecret: "secret",
17
+ allowRegistration: true,
18
+ defaultRole: "viewer",
19
+ google: { clientId: "id" }
20
+ };
21
+ expect(config.jwtSecret).toBe("secret");
22
+ });
23
+
24
+ it("rejects a misspelled top-level key (compile-time)", () => {
25
+ const config: RebaseAuthConfig = {
26
+ jwtSecret: "secret",
27
+ // @ts-expect-error — "jwtSecrt" is a typo, not a real RebaseAuthConfig key
28
+ jwtSecrt: "secret"
29
+ };
30
+ expect(config).toBeDefined();
31
+ });
32
+
33
+ it("rejects an unknown provider-shaped key (compile-time)", () => {
34
+ const config: RebaseAuthConfig = {
35
+ // @ts-expect-error — "seedDefaultRoles" is not a supported option
36
+ seedDefaultRoles: true
37
+ };
38
+ expect(config).toBeDefined();
39
+ });
40
+ });
@@ -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 ────────────────────────────────────────────────────