@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
package/src/init.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import {
2
2
  AuthAdapter,
3
3
  BackendBootstrapper,
4
- BackendHooks,
5
4
  BootstrappedAuth,
6
5
  DatabaseAdapter,
7
6
  DataDriver,
7
+ DataSourceDefinition,
8
+ EntityCallbacks,
8
9
  EntityCollection,
9
10
  HealthCheckResult,
10
11
  InitializedDriver,
@@ -13,9 +14,11 @@ import {
13
14
  RealtimeProvider,
14
15
  SecurityRule
15
16
  } from "@rebasepro/types";
17
+ import { createDataSourceRegistry, resolveDataSource } from "@rebasepro/common";
16
18
  import { BackendCollectionRegistry } from "./collections/BackendCollectionRegistry";
17
19
  import { loadCollectionsFromDirectory } from "./collections/loader";
18
20
  import { DEFAULT_DRIVER_ID, DefaultDriverRegistry, DriverRegistry } from "./services/driver-registry";
21
+ import { createRoutedRealtimeService } from "./services/routed-realtime-service";
19
22
  import { Server } from "http";
20
23
 
21
24
  import { RestApiGenerator } from "./api/rest/api-generator";
@@ -80,7 +83,7 @@ export interface RebaseAuthConfig {
80
83
  *
81
84
  * When a request includes `Authorization: Bearer <serviceKey>`, it is
82
85
  * granted admin-level access without JWT verification. This is the
83
- * Rebase equivalent of a Firebase Service Account key.
86
+ * Rebase equivalent of a Service Account key.
84
87
  *
85
88
  * Generate with: `node -e "logger.info(require('crypto').randomBytes(48).toString('base64'))"`
86
89
  *
@@ -89,6 +92,14 @@ export interface RebaseAuthConfig {
89
92
  */
90
93
  serviceKey?: string;
91
94
  email?: EmailConfig;
95
+ // ── Convenience shortcuts ─────────────────────────────────────────
96
+ // Each named field below is syntactic sugar that internally resolves
97
+ // to an `OAuthProvider` via the corresponding `create*Provider`
98
+ // factory at startup. They are equivalent to constructing the
99
+ // provider manually and passing it in the `providers` array.
100
+ //
101
+ // For providers not listed here, or for full control over the
102
+ // provider configuration, use the `providers` array directly.
92
103
  google?: { clientId: string; clientSecret?: string };
93
104
  linkedin?: { clientId: string; clientSecret: string };
94
105
  github?: { clientId: string; clientSecret: string };
@@ -102,8 +113,31 @@ export interface RebaseAuthConfig {
102
113
  slack?: { clientId: string; clientSecret: string };
103
114
  spotify?: { clientId: string; clientSecret: string };
104
115
  defaultRole?: string;
105
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
106
- providers?: OAuthProvider<any>[];
116
+ /**
117
+ * Canonical array of OAuth providers.
118
+ *
119
+ * This is the primary extension point for **all** OAuth integrations.
120
+ * Each entry is an `OAuthProvider<unknown>` constructed via one of
121
+ * the `create*Provider` factories exported from `@rebasepro/server-core`
122
+ * (e.g. `createGoogleProvider`, `createGitHubProvider`).
123
+ *
124
+ * The named convenience fields above (`google`, `github`, etc.) are
125
+ * automatically resolved into this array at startup. You can mix both
126
+ * approaches; named fields and explicit entries are merged (named
127
+ * fields are appended after explicit entries).
128
+ *
129
+ * @example
130
+ * ```ts
131
+ * import { createGoogleProvider } from "@rebasepro/server-core";
132
+ *
133
+ * auth: {
134
+ * providers: [
135
+ * createGoogleProvider({ clientId: "…", clientSecret: "…" }),
136
+ * ],
137
+ * }
138
+ * ```
139
+ */
140
+ providers?: OAuthProvider<unknown>[];
107
141
  /**
108
142
  * Override specific parts of the built-in auth implementation.
109
143
  *
@@ -123,7 +157,11 @@ export interface RebaseAuthConfig {
123
157
  */
124
158
  hooks?: AuthHooks;
125
159
 
126
- [key: string]: unknown;
160
+ /**
161
+ * Enable magic link (passwordless email) authentication.
162
+ * Requires email to be configured.
163
+ */
164
+ magicLink?: boolean;
127
165
  }
128
166
 
129
167
  export interface RebaseBackendConfig {
@@ -133,6 +171,16 @@ export interface RebaseBackendConfig {
133
171
  app: Hono<HonoEnv>;
134
172
  basePath?: string;
135
173
 
174
+ /**
175
+ * Declared data sources, shared with the frontend `<Rebase dataSources>`.
176
+ *
177
+ * Used to resolve each collection's engine (capabilities) and transport.
178
+ * Collections on a `direct`/`custom` transport are client-only: the backend
179
+ * still owns their schema/registry but does **not** generate server data
180
+ * routes for them. Server-mediated sources (the default) need no entry.
181
+ */
182
+ dataSources?: DataSourceDefinition[];
183
+
136
184
  /**
137
185
  * Database bootstrappers.
138
186
  */
@@ -169,11 +217,22 @@ export interface RebaseBackendConfig {
169
217
  /**
170
218
  * Storage configuration. Accepts:
171
219
  *
172
- * - A `BackendStorageConfig` object (`{ type: 'local' | 's3', ... }`)
173
- * - A `StorageController` instance (for custom providers like GCS, Azure, etc.)
220
+ * - A `BackendStorageConfig` object (`{ type: 'local' | 's3' | 'gcs', ... }`)
221
+ * - A `StorageController` instance (for custom providers like Azure, etc.)
174
222
  * - A `Record<string, ...>` of either, for multi-backend setups
175
223
  */
176
224
  storage?: BackendStorageConfig | StorageController | Record<string, BackendStorageConfig | StorageController>;
225
+
226
+ /**
227
+ * Declared storage sources. Drives the client-side StorageSourceRegistry
228
+ * and the transport distinction (server vs direct).
229
+ *
230
+ * Server-backed sources are auto-derived from the `storage` map — you
231
+ * only need explicit entries for "direct" transport sources (e.g.
232
+ * external storage) that the backend does not proxy.
233
+ */
234
+ storageSources?: import("@rebasepro/types").StorageSourceDefinition[];
235
+
177
236
  history?: unknown;
178
237
  /**
179
238
  * Default security rules applied to any collection that does not define
@@ -223,14 +282,24 @@ export interface RebaseBackendConfig {
223
282
  origin: string | string[] | ((origin: string) => boolean);
224
283
  };
225
284
  /**
226
- * Backend-level hooks for intercepting collection entity data
227
- * at the REST API boundary. These run server-side after database
228
- * operations and before API responses are sent.
285
+ * Global lifecycle callbacks applied to every collection.
286
+ *
287
+ * Same type as per-collection `callbacks` fires on **every** data path
288
+ * (REST API, WebSocket / realtime, server-side `rebase.data`).
229
289
  *
230
- * Complement the per-collection `EntityCallbacks` system which
231
- * handles collection CRUD operations.
290
+ * Execution order: global callbacks → collection callbacks property callbacks.
291
+ *
292
+ * @example
293
+ * ```ts
294
+ * callbacks: {
295
+ * afterRead({ entity, collection }) {
296
+ * console.log(`Read ${collection.slug}/${entity.id}`);
297
+ * return entity;
298
+ * }
299
+ * }
300
+ * ```
232
301
  */
233
- hooks?: BackendHooks;
302
+ callbacks?: EntityCallbacks;
234
303
  }
235
304
 
236
305
  /**
@@ -313,6 +382,16 @@ async function _initializeRebaseBackend(config: RebaseBackendConfig): Promise<Re
313
382
  configureMiddlewares(config.app, basePath, isProduction, config);
314
383
 
315
384
  const collectionRegistry = new BackendCollectionRegistry();
385
+ // Declared data sources — drives engine resolution (capabilities) and the
386
+ // server-vs-direct transport distinction. Set before collections register
387
+ // so normalization can resolve each collection's engine.
388
+ const dataSourceRegistry = createDataSourceRegistry(config.dataSources);
389
+ collectionRegistry.setDataSources(dataSourceRegistry);
390
+
391
+ // Global lifecycle callbacks — applied to every collection, on all data paths.
392
+ if (config.callbacks) {
393
+ collectionRegistry.setGlobalCallbacks(config.callbacks);
394
+ }
316
395
  let activeCollections = config.collections || [];
317
396
  if (config.collectionsDir && activeCollections.length === 0) {
318
397
  activeCollections = await loadCollectionsFromDirectory(config.collectionsDir);
@@ -401,6 +480,56 @@ async function _initializeRebaseBackend(config: RebaseBackendConfig): Promise<Re
401
480
  const defaultBootstrapper = bootstrappers.find(b => b.id === defaultDriverId || b.type === defaultDriverId) || bootstrappers[0];
402
481
  const defaultRealtimeService = defaultDriverResult.realtimeProvider;
403
482
 
483
+ // Resolve a collection path (e.g. "products", "authors/1/posts") to its
484
+ // data-source key — shared by the data-driver router and the realtime
485
+ // router. Falls back to the default key for unknown paths.
486
+ const keyForCollectionPath = (collectionPath: string): string => {
487
+ const slug = collectionPath.replace(/^\/+/, "").split("/")[0]?.split("?")[0];
488
+ if (!slug) return DEFAULT_DRIVER_ID;
489
+ const collection = collectionRegistry.get(slug) ?? collectionRegistry.getCollectionByPath(slug);
490
+ if (!collection) return DEFAULT_DRIVER_ID;
491
+ return resolveDataSource(collection, dataSourceRegistry).key;
492
+ };
493
+
494
+ // ── Data-source misconfiguration check ────────────────────────────────
495
+ // A server-transport collection whose resolved data-source key has no
496
+ // registered driver delegate would silently fall back to the default
497
+ // driver — i.e. land in the wrong database. Warn loudly so this surfaces
498
+ // at boot rather than as mysterious data going to the wrong engine.
499
+ {
500
+ const unresolved = new Map<string, string[]>();
501
+ const nonRlsEngines = new Set<string>();
502
+ for (const collection of activeCollections) {
503
+ const ds = resolveDataSource(collection, dataSourceRegistry);
504
+ if (ds.transport !== "server") continue; // direct/custom are client-only
505
+ // Server engines without row-level security enforce authorization
506
+ // only at the application layer — surface this so it isn't a
507
+ // silent assumption. (The default Postgres engine supports RLS.)
508
+ if (!ds.capabilities.supportsRLS) nonRlsEngines.add(ds.engine);
509
+ if (ds.key === DEFAULT_DRIVER_ID) continue; // always maps to the default
510
+ if (!driverRegistry.has(ds.key)) {
511
+ const slugs = unresolved.get(ds.key) ?? [];
512
+ slugs.push(collection.slug ?? collection.name ?? "?");
513
+ unresolved.set(ds.key, slugs);
514
+ }
515
+ }
516
+ for (const [key, slugs] of unresolved) {
517
+ logger.warn(
518
+ `[DataSource] No driver registered for data source "${key}" ` +
519
+ `(used by: ${slugs.join(", ")}). These collections will fall back to the ` +
520
+ `default driver "${defaultDriverId}" — register a bootstrapper with this id, ` +
521
+ `or mark the data source as a direct/custom transport in \`dataSources\`.`
522
+ );
523
+ }
524
+ for (const engine of nonRlsEngines) {
525
+ logger.warn(
526
+ `[DataSource] Engine "${engine}" does not support row-level security; ` +
527
+ `authorization for its collections is enforced only at the application layer ` +
528
+ `(authentication still applies). Ensure app-level checks or engine-native rules are in place.`
529
+ );
530
+ }
531
+ }
532
+
404
533
  // 2. Initialize Auth & History via the default driver's bootstrapper
405
534
  let authConfigResult: BootstrappedAuth | undefined = undefined;
406
535
  let serviceKey: string | undefined;
@@ -439,6 +568,23 @@ async function _initializeRebaseBackend(config: RebaseBackendConfig): Promise<Re
439
568
  }
440
569
  }
441
570
 
571
+ // The built-in auth subsystem (users, sessions, repository) is
572
+ // bootstrapped on the DEFAULT driver. If the auth collection is
573
+ // routed to a non-default data source, login would read/write the
574
+ // default engine while the collection's data views hit another —
575
+ // a split-brain user store. Warn loudly.
576
+ if (safeAuthConfig.collection) {
577
+ const authDs = resolveDataSource(safeAuthConfig.collection, dataSourceRegistry);
578
+ if (authDs.key !== DEFAULT_DRIVER_ID) {
579
+ logger.warn(
580
+ `[Auth] The auth collection "${safeAuthConfig.collection.slug}" is on data source ` +
581
+ `"${authDs.key}", but the built-in auth system always uses the default data source. ` +
582
+ `Move the auth collection to the default data source, or replace auth with an AuthAdapter ` +
583
+ `that manages users in "${authDs.key}".`
584
+ );
585
+ }
586
+ }
587
+
442
588
  // Extract the collection-level auth config (if `auth` is an object, not just `true`)
443
589
  const collectionAuth = safeAuthConfig.collection ? safeAuthConfig.collection.auth : undefined;
444
590
  const collectionAuthConfig = (typeof collectionAuth === "object" && collectionAuth !== null) ? collectionAuth : undefined;
@@ -516,8 +662,7 @@ async function _initializeRebaseBackend(config: RebaseBackendConfig): Promise<Re
516
662
 
517
663
  if (!isAuthAdapter(config.auth)) {
518
664
  const safeAuthConfig = config.auth as RebaseAuthConfig;
519
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
520
- const oauthProviders: OAuthProvider<any>[] = [...(safeAuthConfig.providers || [])];
665
+ const oauthProviders: OAuthProvider<unknown>[] = [...(safeAuthConfig.providers || [])];
521
666
 
522
667
  // Resolve configured OAuth providers via data-driven registration.
523
668
  // Each entry maps a config key to its factory function name and required fields.
@@ -561,7 +706,8 @@ async function _initializeRebaseBackend(config: RebaseBackendConfig): Promise<Re
561
706
  oauthProviders,
562
707
  serviceKey,
563
708
  authHooks: safeAuthConfig.hooks,
564
- collectionAuthConfig
709
+ collectionAuthConfig,
710
+ enableMagicLink: safeAuthConfig.magicLink ?? false
565
711
  });
566
712
  }
567
713
 
@@ -631,7 +777,10 @@ async function _initializeRebaseBackend(config: RebaseBackendConfig): Promise<Re
631
777
 
632
778
  const storageRoutes = createStorageRoutes({
633
779
  controller: storageController,
634
- requireAuth: resolveRequireAuth(config.auth)
780
+ registry: storageRegistry,
781
+ sources: config.storageSources,
782
+ requireAuth: resolveRequireAuth(config.auth),
783
+ authAdapter
635
784
  });
636
785
 
637
786
  // Apply a permissive body limit specifically for the upload endpoint
@@ -668,18 +817,39 @@ async function _initializeRebaseBackend(config: RebaseBackendConfig): Promise<Re
668
817
  );
669
818
  }
670
819
 
820
+ // Multi-data-source routing: when more than one database engine is
821
+ // registered (e.g. Postgres + MongoDB in one instance), resolve the
822
+ // delegate per request from the request's collection data source. The
823
+ // auth middleware then scopes that delegate (RLS for Postgres, no-op
824
+ // for engines without `withAuth()`) into the request context. For a
825
+ // single-engine backend this is omitted — behaviour is unchanged.
826
+ const dataPathMarker = `${basePath}/data/`;
827
+ const resolveRequestDriver = (reqPath: string): DataDriver => {
828
+ const i = reqPath.indexOf(dataPathMarker);
829
+ const collectionPath = i >= 0 ? reqPath.slice(i + dataPathMarker.length) : reqPath;
830
+ const key = keyForCollectionPath(collectionPath);
831
+ // Use the authoritative default for the default key; otherwise the
832
+ // named delegate, falling back to default if it isn't registered.
833
+ if (!key || key === DEFAULT_DRIVER_ID) return defaultDriver;
834
+ return driverRegistry.get(key) ?? defaultDriver;
835
+ };
836
+ const multiEngine = bootstrappers.length > 1;
837
+ const resolveDriver = multiEngine ? ((c: { req: { path: string } }) => resolveRequestDriver(c.req.path)) : undefined;
838
+
671
839
  // Use adapter middleware when an AuthAdapter is available,
672
840
  // falling back to the built-in JWT middleware otherwise.
673
841
  if (authAdapter) {
674
842
  dataRouter.use("/*", createAdapterAuthMiddleware({
675
843
  adapter: authAdapter,
676
844
  driver: defaultDriver,
845
+ resolveDriver,
677
846
  requireAuth: dataRequireAuth,
678
847
  apiKeyStore
679
848
  }));
680
849
  } else {
681
850
  dataRouter.use("/*", createAuthMiddleware({
682
851
  driver: defaultDriver,
852
+ resolveDriver,
683
853
  requireAuth: dataRequireAuth,
684
854
  serviceKey,
685
855
  apiKeyStore
@@ -702,10 +872,16 @@ async function _initializeRebaseBackend(config: RebaseBackendConfig): Promise<Re
702
872
  dataRouter.route("/", historyRoutes);
703
873
  }
704
874
 
875
+ // Only generate server data routes for server-mediated collections.
876
+ // Collections on a direct/custom transport are client-only — the
877
+ // backend must not expose a (mis-engined) endpoint for them.
878
+ const serverCollections = activeCollections.filter(
879
+ (collection) => resolveDataSource(collection, dataSourceRegistry).transport === "server"
880
+ );
881
+
705
882
  const restGenerator = new RestApiGenerator(
706
- activeCollections,
883
+ serverCollections,
707
884
  defaultDriver,
708
- config.hooks?.data,
709
885
  authAdapter
710
886
  );
711
887
  dataRouter.route("/", restGenerator.generateRoutes());
@@ -793,9 +969,10 @@ async function _initializeRebaseBackend(config: RebaseBackendConfig): Promise<Re
793
969
  const functionsRouter = new Hono<HonoEnv>();
794
970
  functionsRouter.onError(errorHandler);
795
971
 
796
- // Custom functions follow the same auth policy as data routes.
797
- // Per-route auth can be further refined inside individual functions.
798
- const functionsRequireAuth = resolveRequireAuth(config.auth);
972
+ // Custom functions do NOT require authentication at the global level by default.
973
+ // This allows custom functions to define public endpoints (like webhooks).
974
+ // Per-route auth can be further refined inside individual functions using `requireAuth`.
975
+ const functionsRequireAuth = false;
799
976
 
800
977
  // Use adapter middleware when available, fallback to built-in
801
978
  if (authAdapter) {
@@ -876,8 +1053,20 @@ async function _initializeRebaseBackend(config: RebaseBackendConfig): Promise<Re
876
1053
  }
877
1054
  }
878
1055
 
879
- if (defaultBootstrapper.initializeWebsockets && defaultRealtimeService) {
880
- await defaultBootstrapper.initializeWebsockets(config.server, defaultRealtimeService, defaultDriver, config.auth, authAdapter);
1056
+ // With multiple realtime-capable engines, route subscriptions to the
1057
+ // provider owning each collection (the realtime counterpart of the data
1058
+ // router). The single WebSocket server is driven by this composite.
1059
+ // Single-engine setups use the default provider unchanged.
1060
+ const effectiveRealtimeService: RealtimeProvider = Object.keys(realtimeServices).length > 1
1061
+ ? createRoutedRealtimeService({
1062
+ providers: realtimeServices,
1063
+ defaultKey: defaultDriverId,
1064
+ resolveKey: keyForCollectionPath
1065
+ })
1066
+ : defaultRealtimeService as RealtimeProvider;
1067
+
1068
+ if (defaultBootstrapper.initializeWebsockets && effectiveRealtimeService) {
1069
+ await defaultBootstrapper.initializeWebsockets(config.server, effectiveRealtimeService, defaultDriver, config.auth, authAdapter);
881
1070
  }
882
1071
 
883
1072
  logger.info("Rebase Backend Initialized");
@@ -896,7 +1085,7 @@ async function _initializeRebaseBackend(config: RebaseBackendConfig): Promise<Re
896
1085
  driverRegistry,
897
1086
  driver: defaultDriver,
898
1087
  realtimeServices,
899
- realtimeService: defaultRealtimeService as RealtimeProvider,
1088
+ realtimeService: effectiveRealtimeService,
900
1089
  auth: authConfigResult,
901
1090
  history: historyConfigResult,
902
1091
  storageRegistry,
@@ -0,0 +1,113 @@
1
+ import { RealtimeProvider, Entity } from "@rebasepro/types";
2
+
3
+ /**
4
+ * A realtime client message as forwarded by the WebSocket server.
5
+ */
6
+ interface ClientMessage {
7
+ type: string;
8
+ payload?: Record<string, unknown>;
9
+ subscriptionId?: string;
10
+ }
11
+
12
+ /**
13
+ * The concrete realtime service surface the WebSocket server drives — the
14
+ * typed {@link RealtimeProvider} plus the client-connection methods that
15
+ * every engine's realtime service implements.
16
+ */
17
+ export interface WsRealtimeService extends RealtimeProvider {
18
+ addClient(clientId: string, ws: unknown): void;
19
+ handleClientMessage(clientId: string, message: ClientMessage, authContext?: unknown): Promise<void> | void;
20
+ }
21
+
22
+ /** Channel/presence/broadcast messages are engine-agnostic pub/sub. */
23
+ const CHANNEL_MESSAGE_TYPES = new Set([
24
+ "join_channel", "leave_channel", "broadcast",
25
+ "presence_track", "presence_untrack", "presence_state"
26
+ ]);
27
+
28
+ export interface RoutedRealtimeOptions {
29
+ /** Per-engine realtime providers, keyed by data-source key. */
30
+ providers: Record<string, RealtimeProvider>;
31
+ /** Key of the default provider (handles channels/presence/broadcast). */
32
+ defaultKey: string;
33
+ /** Resolve a collection path to its data-source key. */
34
+ resolveKey: (collectionPath: string) => string;
35
+ }
36
+
37
+ /**
38
+ * Compose multiple per-engine {@link RealtimeProvider}s into one that routes
39
+ * each subscription to the provider owning the subscribed collection — the
40
+ * realtime counterpart of `buildRoutedRebaseData`.
41
+ *
42
+ * The WebSocket server stays single and engine-agnostic; this composite is
43
+ * passed in its place. Routing rules:
44
+ * - `subscribe_collection` / `subscribe_entity` → the provider for the
45
+ * collection's data source (by `payload.path`).
46
+ * - `unsubscribe` → forwarded to all providers (a no-op on non-owners).
47
+ * - channel / presence / broadcast → the default provider (these are global
48
+ * pub/sub, not bound to an engine).
49
+ * - `addClient` and lifecycle (`onServerReady`/`destroy`/`stopListening`) →
50
+ * all providers (each registers its own ws close handler for cleanup).
51
+ */
52
+ export function createRoutedRealtimeService(opts: RoutedRealtimeOptions): WsRealtimeService {
53
+ const { providers, defaultKey, resolveKey } = opts;
54
+
55
+ const asWs = (p: RealtimeProvider): WsRealtimeService => p as unknown as WsRealtimeService;
56
+ const all = (): WsRealtimeService[] => Object.values(providers).map(asWs);
57
+ const fallback = (): WsRealtimeService => asWs(providers[defaultKey] ?? Object.values(providers)[0]);
58
+ const forPath = (path?: string): WsRealtimeService => {
59
+ if (!path) return fallback();
60
+ const key = resolveKey(path);
61
+ return asWs(providers[key] ?? providers[defaultKey] ?? Object.values(providers)[0]);
62
+ };
63
+
64
+ return {
65
+ addClient(clientId, ws) {
66
+ for (const p of all()) p.addClient?.(clientId, ws);
67
+ },
68
+
69
+ async handleClientMessage(clientId, message, authContext) {
70
+ const { type } = message;
71
+ if (type === "subscribe_collection" || type === "subscribe_entity") {
72
+ await forPath(message.payload?.path as string | undefined)
73
+ .handleClientMessage(clientId, message, authContext);
74
+ return;
75
+ }
76
+ if (type === "unsubscribe") {
77
+ // The owning provider acts; others no-op on an unknown id.
78
+ await Promise.all(all().map((p) => p.handleClientMessage(clientId, message, authContext)));
79
+ return;
80
+ }
81
+ // Channels/presence/broadcast (and anything else) → default provider.
82
+ await fallback().handleClientMessage(clientId, message, authContext);
83
+ },
84
+
85
+ subscribeToCollection(subscriptionId, config, callback) {
86
+ forPath((config as { path?: string }).path).subscribeToCollection(subscriptionId, config, callback);
87
+ },
88
+
89
+ subscribeToEntity(subscriptionId, config, callback) {
90
+ forPath((config as { path?: string }).path).subscribeToEntity(subscriptionId, config, callback);
91
+ },
92
+
93
+ unsubscribe(subscriptionId) {
94
+ for (const p of all()) p.unsubscribe(subscriptionId);
95
+ },
96
+
97
+ async notifyEntityUpdate(path: string, entityId: string, entity: Entity | null, databaseId?: string) {
98
+ await forPath(path).notifyEntityUpdate(path, entityId, entity, databaseId);
99
+ },
100
+
101
+ onServerReady(serverInfo) {
102
+ for (const p of all()) p.onServerReady?.(serverInfo);
103
+ },
104
+
105
+ async destroy() {
106
+ await Promise.all(all().map((p) => p.destroy?.()));
107
+ },
108
+
109
+ async stopListening() {
110
+ await Promise.all(all().map((p) => p.stopListening?.()));
111
+ }
112
+ };
113
+ }