@rebasepro/server-core 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/package.json +14 -14
  2. package/src/api/errors.ts +16 -2
  3. package/src/api/graphql/graphql-schema-generator.ts +45 -10
  4. package/src/api/server.ts +10 -2
  5. package/src/api/types.ts +6 -0
  6. package/src/auth/adapter-middleware.ts +10 -2
  7. package/src/auth/admin-roles-route.ts +36 -0
  8. package/src/auth/admin-users-route.ts +302 -0
  9. package/src/auth/api-keys/api-key-middleware.ts +4 -3
  10. package/src/auth/api-keys/api-key-routes.ts +12 -2
  11. package/src/auth/api-keys/api-key-store.ts +83 -66
  12. package/src/auth/api-keys/api-key-types.ts +8 -0
  13. package/src/auth/apple-oauth.ts +2 -1
  14. package/src/auth/auth-hooks.ts +21 -0
  15. package/src/auth/bitbucket-oauth.ts +2 -1
  16. package/src/auth/builtin-auth-adapter.ts +28 -4
  17. package/src/auth/custom-auth-adapter.ts +2 -0
  18. package/src/auth/discord-oauth.ts +2 -1
  19. package/src/auth/facebook-oauth.ts +2 -1
  20. package/src/auth/github-oauth.ts +2 -1
  21. package/src/auth/gitlab-oauth.ts +2 -1
  22. package/src/auth/google-oauth.ts +8 -4
  23. package/src/auth/index.ts +2 -0
  24. package/src/auth/interfaces.ts +27 -0
  25. package/src/auth/linkedin-oauth.ts +2 -1
  26. package/src/auth/magic-link-routes.ts +167 -0
  27. package/src/auth/mfa-crypto.ts +91 -0
  28. package/src/auth/mfa-routes.ts +34 -10
  29. package/src/auth/microsoft-oauth.ts +2 -1
  30. package/src/auth/middleware.ts +10 -1
  31. package/src/auth/reset-password-admin.ts +17 -1
  32. package/src/auth/routes.ts +77 -8
  33. package/src/auth/session-routes.ts +15 -3
  34. package/src/auth/slack-oauth.ts +2 -1
  35. package/src/auth/spotify-oauth.ts +2 -1
  36. package/src/auth/twitter-oauth.ts +8 -1
  37. package/src/cron/cron-store.ts +25 -23
  38. package/src/email/index.ts +3 -2
  39. package/src/email/templates.ts +82 -0
  40. package/src/email/types.ts +16 -0
  41. package/src/init.ts +137 -5
  42. package/src/services/routed-realtime-service.ts +113 -0
  43. package/src/utils/dev-port.ts +13 -7
  44. package/test/auth-routes.test.ts +54 -4
  45. package/test/custom-auth-adapter.test.ts +20 -1
  46. package/test/env.test.ts +9 -19
  47. package/test/multi-datasource-routing.test.ts +113 -0
  48. package/test/routed-realtime-service.test.ts +86 -0
  49. package/test/transform-auth-response.test.ts +305 -0
  50. package/build-errors.txt +0 -52
  51. package/dist/api/ast-schema-editor.d.ts +0 -21
  52. package/dist/api/collections_for_test/callbacks_test_collection.d.ts +0 -2
  53. package/dist/api/errors.d.ts +0 -53
  54. package/dist/api/graphql/graphql-schema-generator.d.ts +0 -35
  55. package/dist/api/graphql/index.d.ts +0 -1
  56. package/dist/api/index.d.ts +0 -9
  57. package/dist/api/logs-routes.d.ts +0 -37
  58. package/dist/api/openapi-generator.d.ts +0 -16
  59. package/dist/api/rest/api-generator.d.ts +0 -88
  60. package/dist/api/rest/index.d.ts +0 -1
  61. package/dist/api/rest/query-parser.d.ts +0 -11
  62. package/dist/api/schema-editor-routes.d.ts +0 -3
  63. package/dist/api/server.d.ts +0 -40
  64. package/dist/api/types.d.ts +0 -98
  65. package/dist/auth/adapter-middleware.d.ts +0 -37
  66. package/dist/auth/admin-user-ops.d.ts +0 -79
  67. package/dist/auth/api-keys/api-key-middleware.d.ts +0 -39
  68. package/dist/auth/api-keys/api-key-permission-guard.d.ts +0 -32
  69. package/dist/auth/api-keys/api-key-routes.d.ts +0 -20
  70. package/dist/auth/api-keys/api-key-store.d.ts +0 -35
  71. package/dist/auth/api-keys/api-key-types.d.ts +0 -88
  72. package/dist/auth/api-keys/index.d.ts +0 -17
  73. package/dist/auth/apple-oauth.d.ts +0 -30
  74. package/dist/auth/auth-hooks.d.ts +0 -225
  75. package/dist/auth/bitbucket-oauth.d.ts +0 -11
  76. package/dist/auth/builtin-auth-adapter.d.ts +0 -49
  77. package/dist/auth/crypto-utils.d.ts +0 -16
  78. package/dist/auth/custom-auth-adapter.d.ts +0 -39
  79. package/dist/auth/discord-oauth.d.ts +0 -14
  80. package/dist/auth/facebook-oauth.d.ts +0 -14
  81. package/dist/auth/github-oauth.d.ts +0 -15
  82. package/dist/auth/gitlab-oauth.d.ts +0 -13
  83. package/dist/auth/google-oauth.d.ts +0 -47
  84. package/dist/auth/index.d.ts +0 -36
  85. package/dist/auth/interfaces.d.ts +0 -401
  86. package/dist/auth/jwt.d.ts +0 -55
  87. package/dist/auth/linkedin-oauth.d.ts +0 -18
  88. package/dist/auth/mfa-routes.d.ts +0 -6
  89. package/dist/auth/mfa.d.ts +0 -49
  90. package/dist/auth/microsoft-oauth.d.ts +0 -16
  91. package/dist/auth/middleware.d.ts +0 -106
  92. package/dist/auth/password.d.ts +0 -22
  93. package/dist/auth/rate-limiter.d.ts +0 -50
  94. package/dist/auth/reset-password-admin.d.ts +0 -29
  95. package/dist/auth/rls-scope.d.ts +0 -31
  96. package/dist/auth/routes.d.ts +0 -33
  97. package/dist/auth/session-routes.d.ts +0 -25
  98. package/dist/auth/slack-oauth.d.ts +0 -12
  99. package/dist/auth/spotify-oauth.d.ts +0 -12
  100. package/dist/auth/twitter-oauth.d.ts +0 -18
  101. package/dist/backend-CIxN4FVm.js +0 -15
  102. package/dist/backend-CIxN4FVm.js.map +0 -1
  103. package/dist/chunk-Dze3rakg.js +0 -42
  104. package/dist/collections/BackendCollectionRegistry.d.ts +0 -13
  105. package/dist/collections/loader.d.ts +0 -5
  106. package/dist/cron/cron-loader.d.ts +0 -17
  107. package/dist/cron/cron-routes.d.ts +0 -14
  108. package/dist/cron/cron-scheduler.d.ts +0 -106
  109. package/dist/cron/cron-store.d.ts +0 -32
  110. package/dist/cron/index.d.ts +0 -6
  111. package/dist/db/interfaces.d.ts +0 -18
  112. package/dist/dist-CZKP-Xz4.js +0 -832
  113. package/dist/dist-CZKP-Xz4.js.map +0 -1
  114. package/dist/email/index.d.ts +0 -6
  115. package/dist/email/smtp-email-service.d.ts +0 -25
  116. package/dist/email/templates.d.ts +0 -42
  117. package/dist/email/types.d.ts +0 -108
  118. package/dist/env.d.ts +0 -102
  119. package/dist/from-VbwD7xRf.js +0 -3849
  120. package/dist/from-VbwD7xRf.js.map +0 -1
  121. package/dist/functions/function-loader.d.ts +0 -17
  122. package/dist/functions/function-routes.d.ts +0 -10
  123. package/dist/functions/index.d.ts +0 -3
  124. package/dist/history/history-routes.d.ts +0 -23
  125. package/dist/history/index.d.ts +0 -1
  126. package/dist/index.d.ts +0 -33
  127. package/dist/index.es.js +0 -61731
  128. package/dist/index.es.js.map +0 -1
  129. package/dist/index.umd.js +0 -73221
  130. package/dist/index.umd.js.map +0 -1
  131. package/dist/init/docs.d.ts +0 -4
  132. package/dist/init/health.d.ts +0 -2
  133. package/dist/init/middlewares.d.ts +0 -10
  134. package/dist/init/shutdown.d.ts +0 -11
  135. package/dist/init/storage.d.ts +0 -5
  136. package/dist/init.d.ts +0 -258
  137. package/dist/jwt-DHcQRGC3.js +0 -4168
  138. package/dist/jwt-DHcQRGC3.js.map +0 -1
  139. package/dist/logger-BYU66ENZ.js +0 -94
  140. package/dist/logger-BYU66ENZ.js.map +0 -1
  141. package/dist/ms-BeBSuOXN.js +0 -125
  142. package/dist/ms-BeBSuOXN.js.map +0 -1
  143. package/dist/multipart-parser-CedBDOeC.js +0 -299
  144. package/dist/multipart-parser-CedBDOeC.js.map +0 -1
  145. package/dist/serve-spa.d.ts +0 -30
  146. package/dist/services/driver-registry.d.ts +0 -78
  147. package/dist/services/webhook-service.d.ts +0 -29
  148. package/dist/singleton.d.ts +0 -35
  149. package/dist/src-COaj0G3P.js +0 -1182
  150. package/dist/src-COaj0G3P.js.map +0 -1
  151. package/dist/storage/LocalStorageController.d.ts +0 -46
  152. package/dist/storage/S3StorageController.d.ts +0 -36
  153. package/dist/storage/image-transform.d.ts +0 -50
  154. package/dist/storage/index.d.ts +0 -28
  155. package/dist/storage/routes.d.ts +0 -38
  156. package/dist/storage/storage-registry.d.ts +0 -78
  157. package/dist/storage/tus-handler.d.ts +0 -51
  158. package/dist/storage/types.d.ts +0 -103
  159. package/dist/types/index.d.ts +0 -11
  160. package/dist/utils/dev-port.d.ts +0 -35
  161. package/dist/utils/logger.d.ts +0 -31
  162. package/dist/utils/logging.d.ts +0 -9
  163. package/dist/utils/request-id.d.ts +0 -4
  164. package/dist/utils/request-logger.d.ts +0 -19
  165. package/dist/utils/sql.d.ts +0 -27
@@ -84,6 +84,7 @@ export function createTwitterProvider(config: { clientId: string; clientSecret:
84
84
  // generate a placeholder email — the user can update it later.
85
85
  // For apps with elevated access, fetch from v1.1 endpoint.
86
86
  let email: string | null = null;
87
+ let emailVerified = false;
87
88
  try {
88
89
  const emailResponse = await fetch(
89
90
  "https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true",
@@ -94,6 +95,9 @@ export function createTwitterProvider(config: { clientId: string; clientSecret:
94
95
  if (emailResponse.ok) {
95
96
  const emailData = await emailResponse.json() as { email?: string };
96
97
  email = emailData.email || null;
98
+ if (email) {
99
+ emailVerified = true;
100
+ }
97
101
  }
98
102
  } catch {
99
103
  // Elevated access not available — fall through
@@ -104,13 +108,16 @@ export function createTwitterProvider(config: { clientId: string; clientSecret:
104
108
  // This allows the account to be created and linked; the user should
105
109
  // update their email through the profile settings.
106
110
  email = `${profileData.id}@twitter.placeholder.rebase`;
111
+ // Placeholder emails are NOT verified — prevents auto-linking to existing accounts
112
+ emailVerified = false;
107
113
  }
108
114
 
109
115
  return {
110
116
  providerId: profileData.id,
111
117
  email,
112
118
  displayName: profileData.name || profileData.username || null,
113
- photoUrl: profileData.profile_image_url?.replace("_normal", "_400x400") || null
119
+ photoUrl: profileData.profile_image_url?.replace("_normal", "_400x400") || null,
120
+ emailVerified
114
121
  };
115
122
  } catch (error) {
116
123
  logger.error("Twitter OAuth error", { error: error });
@@ -43,7 +43,8 @@ export function createCronStore(driver: DataDriver): CronStore | undefined {
43
43
  return undefined;
44
44
  }
45
45
 
46
- const exec = admin.executeSql.bind(admin);
46
+ const exec = (sqlText: string, options?: { params?: unknown[] }) =>
47
+ admin.executeSql(sqlText, options?.params ? { params: options.params } : undefined);
47
48
 
48
49
  return {
49
50
  async ensureTable(): Promise<void> {
@@ -80,22 +81,22 @@ export function createCronStore(driver: DataDriver): CronStore | undefined {
80
81
  try {
81
82
  const resultJson = entry.result !== undefined ? JSON.stringify(entry.result) : null;
82
83
  const logsJson = entry.logs.length > 0 ? JSON.stringify(entry.logs) : null;
83
- const errorEscaped = entry.error ? entry.error.replace(/'/g, "''") : null;
84
84
 
85
- await exec(`
86
- INSERT INTO ${TABLE} (job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual)
87
- VALUES (
88
- '${entry.jobId}',
89
- '${entry.startedAt}',
90
- '${entry.finishedAt}',
91
- ${entry.durationMs},
92
- ${entry.success},
93
- ${errorEscaped ? `'${errorEscaped}'` : "NULL"},
94
- ${resultJson ? `'${resultJson.replace(/'/g, "''")}'::jsonb` : "NULL"},
95
- ${logsJson ? `'${logsJson.replace(/'/g, "''")}'::jsonb` : "NULL"},
96
- ${entry.manual}
97
- )
98
- `);
85
+ await exec(
86
+ `INSERT INTO ${TABLE} (job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual)
87
+ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9)`,
88
+ { params: [
89
+ entry.jobId,
90
+ entry.startedAt,
91
+ entry.finishedAt,
92
+ entry.durationMs,
93
+ entry.success,
94
+ entry.error || null,
95
+ resultJson,
96
+ logsJson,
97
+ entry.manual
98
+ ]}
99
+ );
99
100
  } catch (err) {
100
101
  // Non-blocking — log persistence should never crash the scheduler
101
102
  logger.error(`[cron-store] Failed to persist log for "${entry.jobId}"`, { error: err });
@@ -104,13 +105,14 @@ export function createCronStore(driver: DataDriver): CronStore | undefined {
104
105
 
105
106
  async fetchLogs(jobId: string, limit = 50): Promise<CronJobLogEntry[]> {
106
107
  try {
107
- const rows = await exec(`
108
- SELECT job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual
109
- FROM ${TABLE}
110
- WHERE job_id = '${jobId}'
111
- ORDER BY started_at DESC
112
- LIMIT ${limit}
113
- `);
108
+ const rows = await exec(
109
+ `SELECT job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual
110
+ FROM ${TABLE}
111
+ WHERE job_id = $1
112
+ ORDER BY started_at DESC
113
+ LIMIT $2`,
114
+ { params: [jobId, limit] }
115
+ );
114
116
 
115
117
  return rows.map(rowToLogEntry);
116
118
  } catch (err) {
@@ -10,9 +10,10 @@ export type {
10
10
  PasswordResetTemplateFunction,
11
11
  EmailVerificationTemplateFunction,
12
12
  UserInvitationTemplateFunction,
13
- WelcomeEmailTemplateFunction
13
+ WelcomeEmailTemplateFunction,
14
+ MagicLinkTemplateFunction
14
15
  } from "./types";
15
16
 
16
17
  export { SMTPEmailService, createEmailService } from "./smtp-email-service";
17
18
 
18
- export { getPasswordResetTemplate, getEmailVerificationTemplate, getUserInvitationTemplate, getWelcomeEmailTemplate } from "./templates";
19
+ export { getPasswordResetTemplate, getEmailVerificationTemplate, getUserInvitationTemplate, getWelcomeEmailTemplate, getMagicLinkTemplate } from "./templates";
@@ -386,3 +386,85 @@ Si tienes alguna pregunta, no dudes en contactarnos respondiendo a este correo.
386
386
  html,
387
387
  text };
388
388
  }
389
+
390
+ /**
391
+ * Default magic link email template
392
+ */
393
+ export function getMagicLinkTemplate(
394
+ magicLinkUrl: string,
395
+ user: TemplateUser,
396
+ appName = "Rebase"
397
+ ): { subject: string; html: string; text: string } {
398
+ const greeting = getGreeting(user);
399
+
400
+ const subject = `Sign in to ${appName}`;
401
+
402
+ const html = `
403
+ <!DOCTYPE html>
404
+ <html>
405
+ <head>
406
+ <meta charset="utf-8">
407
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
408
+ <title>${subject}</title>
409
+ </head>
410
+ <body style="margin: 0; padding: 0; background-color: #f8fafc;">
411
+ <div style="${styles.container}">
412
+ <div style="${styles.card}">
413
+ <h1 style="${styles.heading}">Sign In to ${appName}</h1>
414
+
415
+ <p style="${styles.paragraph}">
416
+ Hi ${greeting},
417
+ </p>
418
+
419
+ <p style="${styles.paragraph}">
420
+ We received a request to sign in to your ${appName} account.
421
+ Click the button below to log in:
422
+ </p>
423
+
424
+ <div style="text-align: center;">
425
+ <a href="${magicLinkUrl}" style="${styles.button}">Sign In</a>
426
+ </div>
427
+
428
+ <p style="${styles.paragraph}">
429
+ Or copy and paste this link into your browser:
430
+ </p>
431
+ <p style="color: #3b82f6; word-break: break-all; font-size: 14px;">
432
+ ${magicLinkUrl}
433
+ </p>
434
+
435
+ <div style="${styles.warning}">
436
+ ⏰ This link will expire in 15 minutes for security reasons and can only be used once.
437
+ </div>
438
+
439
+ <div style="${styles.footer}">
440
+ <p style="margin: 0;">
441
+ If you didn't request this sign-in link, you can safely ignore this email.
442
+ No action is needed.
443
+ </p>
444
+ </div>
445
+ </div>
446
+ </div>
447
+ </body>
448
+ </html>
449
+ `.trim();
450
+
451
+ const text = `
452
+ Sign In to ${appName}
453
+
454
+ Hi ${greeting},
455
+
456
+ We received a request to sign in to your ${appName} account.
457
+
458
+ Click this link to log in:
459
+ ${magicLinkUrl}
460
+
461
+ This link will expire in 15 minutes for security reasons and can only be used once.
462
+
463
+ If you didn't request this sign-in link, you can safely ignore this email.
464
+ No action is needed.
465
+ `.trim();
466
+
467
+ return { subject,
468
+ html,
469
+ text };
470
+ }
@@ -57,6 +57,14 @@ export type WelcomeEmailTemplateFunction = (
57
57
  appName: string
58
58
  ) => { subject: string; html: string; text?: string };
59
59
 
60
+ /**
61
+ * Template function for magic link emails
62
+ */
63
+ export type MagicLinkTemplateFunction = (
64
+ magicLinkUrl: string,
65
+ user: { email: string; displayName?: string | null }
66
+ ) => { subject: string; html: string; text?: string };
67
+
60
68
  /**
61
69
  * Complete email configuration
62
70
  */
@@ -89,6 +97,13 @@ export interface EmailConfig {
89
97
  */
90
98
  verifyEmailUrl?: string;
91
99
 
100
+ /**
101
+ * Base URL for magic link login (e.g., "https://myapp.com")
102
+ * The magic link will be: {baseUrl}/auth/magic-link?token={token}
103
+ * Falls back to `resetPasswordUrl` if not set.
104
+ */
105
+ magicLinkUrl?: string;
106
+
92
107
  /**
93
108
  * Application name to use in email templates
94
109
  */
@@ -102,5 +117,6 @@ export interface EmailConfig {
102
117
  emailVerification?: EmailVerificationTemplateFunction;
103
118
  userInvitation?: UserInvitationTemplateFunction;
104
119
  welcomeEmail?: WelcomeEmailTemplateFunction;
120
+ magicLink?: MagicLinkTemplateFunction;
105
121
  };
106
122
  }
package/src/init.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  BootstrappedAuth,
6
6
  DatabaseAdapter,
7
7
  DataDriver,
8
+ DataSourceDefinition,
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";
@@ -123,6 +126,12 @@ export interface RebaseAuthConfig {
123
126
  */
124
127
  hooks?: AuthHooks;
125
128
 
129
+ /**
130
+ * Enable magic link (passwordless email) authentication.
131
+ * Requires email to be configured.
132
+ */
133
+ magicLink?: boolean;
134
+
126
135
  [key: string]: unknown;
127
136
  }
128
137
 
@@ -133,6 +142,16 @@ export interface RebaseBackendConfig {
133
142
  app: Hono<HonoEnv>;
134
143
  basePath?: string;
135
144
 
145
+ /**
146
+ * Declared data sources, shared with the frontend `<Rebase dataSources>`.
147
+ *
148
+ * Used to resolve each collection's engine (capabilities) and transport.
149
+ * Collections on a `direct`/`custom` transport are client-only: the backend
150
+ * still owns their schema/registry but does **not** generate server data
151
+ * routes for them. Server-mediated sources (the default) need no entry.
152
+ */
153
+ dataSources?: DataSourceDefinition[];
154
+
136
155
  /**
137
156
  * Database bootstrappers.
138
157
  */
@@ -313,6 +332,11 @@ async function _initializeRebaseBackend(config: RebaseBackendConfig): Promise<Re
313
332
  configureMiddlewares(config.app, basePath, isProduction, config);
314
333
 
315
334
  const collectionRegistry = new BackendCollectionRegistry();
335
+ // Declared data sources — drives engine resolution (capabilities) and the
336
+ // server-vs-direct transport distinction. Set before collections register
337
+ // so normalization can resolve each collection's engine.
338
+ const dataSourceRegistry = createDataSourceRegistry(config.dataSources);
339
+ collectionRegistry.setDataSources(dataSourceRegistry);
316
340
  let activeCollections = config.collections || [];
317
341
  if (config.collectionsDir && activeCollections.length === 0) {
318
342
  activeCollections = await loadCollectionsFromDirectory(config.collectionsDir);
@@ -401,6 +425,56 @@ async function _initializeRebaseBackend(config: RebaseBackendConfig): Promise<Re
401
425
  const defaultBootstrapper = bootstrappers.find(b => b.id === defaultDriverId || b.type === defaultDriverId) || bootstrappers[0];
402
426
  const defaultRealtimeService = defaultDriverResult.realtimeProvider;
403
427
 
428
+ // Resolve a collection path (e.g. "products", "authors/1/posts") to its
429
+ // data-source key — shared by the data-driver router and the realtime
430
+ // router. Falls back to the default key for unknown paths.
431
+ const keyForCollectionPath = (collectionPath: string): string => {
432
+ const slug = collectionPath.replace(/^\/+/, "").split("/")[0]?.split("?")[0];
433
+ if (!slug) return DEFAULT_DRIVER_ID;
434
+ const collection = collectionRegistry.get(slug) ?? collectionRegistry.getCollectionByPath(slug);
435
+ if (!collection) return DEFAULT_DRIVER_ID;
436
+ return resolveDataSource(collection, dataSourceRegistry).key;
437
+ };
438
+
439
+ // ── Data-source misconfiguration check ────────────────────────────────
440
+ // A server-transport collection whose resolved data-source key has no
441
+ // registered driver delegate would silently fall back to the default
442
+ // driver — i.e. land in the wrong database. Warn loudly so this surfaces
443
+ // at boot rather than as mysterious data going to the wrong engine.
444
+ {
445
+ const unresolved = new Map<string, string[]>();
446
+ const nonRlsEngines = new Set<string>();
447
+ for (const collection of activeCollections) {
448
+ const ds = resolveDataSource(collection, dataSourceRegistry);
449
+ if (ds.transport !== "server") continue; // direct/custom are client-only
450
+ // Server engines without row-level security enforce authorization
451
+ // only at the application layer — surface this so it isn't a
452
+ // silent assumption. (The default Postgres engine supports RLS.)
453
+ if (!ds.capabilities.supportsRLS) nonRlsEngines.add(ds.engine);
454
+ if (ds.key === DEFAULT_DRIVER_ID) continue; // always maps to the default
455
+ if (!driverRegistry.has(ds.key)) {
456
+ const slugs = unresolved.get(ds.key) ?? [];
457
+ slugs.push(collection.slug ?? collection.name ?? "?");
458
+ unresolved.set(ds.key, slugs);
459
+ }
460
+ }
461
+ for (const [key, slugs] of unresolved) {
462
+ logger.warn(
463
+ `[DataSource] No driver registered for data source "${key}" ` +
464
+ `(used by: ${slugs.join(", ")}). These collections will fall back to the ` +
465
+ `default driver "${defaultDriverId}" — register a bootstrapper with this id, ` +
466
+ `or mark the data source as a direct/custom transport in \`dataSources\`.`
467
+ );
468
+ }
469
+ for (const engine of nonRlsEngines) {
470
+ logger.warn(
471
+ `[DataSource] Engine "${engine}" does not support row-level security; ` +
472
+ `authorization for its collections is enforced only at the application layer ` +
473
+ `(authentication still applies). Ensure app-level checks or engine-native rules are in place.`
474
+ );
475
+ }
476
+ }
477
+
404
478
  // 2. Initialize Auth & History via the default driver's bootstrapper
405
479
  let authConfigResult: BootstrappedAuth | undefined = undefined;
406
480
  let serviceKey: string | undefined;
@@ -439,6 +513,23 @@ async function _initializeRebaseBackend(config: RebaseBackendConfig): Promise<Re
439
513
  }
440
514
  }
441
515
 
516
+ // The built-in auth subsystem (users, sessions, repository) is
517
+ // bootstrapped on the DEFAULT driver. If the auth collection is
518
+ // routed to a non-default data source, login would read/write the
519
+ // default engine while the collection's data views hit another —
520
+ // a split-brain user store. Warn loudly.
521
+ if (safeAuthConfig.collection) {
522
+ const authDs = resolveDataSource(safeAuthConfig.collection, dataSourceRegistry);
523
+ if (authDs.key !== DEFAULT_DRIVER_ID) {
524
+ logger.warn(
525
+ `[Auth] The auth collection "${safeAuthConfig.collection.slug}" is on data source ` +
526
+ `"${authDs.key}", but the built-in auth system always uses the default data source. ` +
527
+ `Move the auth collection to the default data source, or replace auth with an AuthAdapter ` +
528
+ `that manages users in "${authDs.key}".`
529
+ );
530
+ }
531
+ }
532
+
442
533
  // Extract the collection-level auth config (if `auth` is an object, not just `true`)
443
534
  const collectionAuth = safeAuthConfig.collection ? safeAuthConfig.collection.auth : undefined;
444
535
  const collectionAuthConfig = (typeof collectionAuth === "object" && collectionAuth !== null) ? collectionAuth : undefined;
@@ -561,7 +652,8 @@ async function _initializeRebaseBackend(config: RebaseBackendConfig): Promise<Re
561
652
  oauthProviders,
562
653
  serviceKey,
563
654
  authHooks: safeAuthConfig.hooks,
564
- collectionAuthConfig
655
+ collectionAuthConfig,
656
+ enableMagicLink: safeAuthConfig.magicLink ?? false
565
657
  });
566
658
  }
567
659
 
@@ -668,18 +760,39 @@ async function _initializeRebaseBackend(config: RebaseBackendConfig): Promise<Re
668
760
  );
669
761
  }
670
762
 
763
+ // Multi-data-source routing: when more than one database engine is
764
+ // registered (e.g. Postgres + MongoDB in one instance), resolve the
765
+ // delegate per request from the request's collection data source. The
766
+ // auth middleware then scopes that delegate (RLS for Postgres, no-op
767
+ // for engines without `withAuth()`) into the request context. For a
768
+ // single-engine backend this is omitted — behaviour is unchanged.
769
+ const dataPathMarker = `${basePath}/data/`;
770
+ const resolveRequestDriver = (reqPath: string): DataDriver => {
771
+ const i = reqPath.indexOf(dataPathMarker);
772
+ const collectionPath = i >= 0 ? reqPath.slice(i + dataPathMarker.length) : reqPath;
773
+ const key = keyForCollectionPath(collectionPath);
774
+ // Use the authoritative default for the default key; otherwise the
775
+ // named delegate, falling back to default if it isn't registered.
776
+ if (!key || key === DEFAULT_DRIVER_ID) return defaultDriver;
777
+ return driverRegistry.get(key) ?? defaultDriver;
778
+ };
779
+ const multiEngine = bootstrappers.length > 1;
780
+ const resolveDriver = multiEngine ? ((c: { req: { path: string } }) => resolveRequestDriver(c.req.path)) : undefined;
781
+
671
782
  // Use adapter middleware when an AuthAdapter is available,
672
783
  // falling back to the built-in JWT middleware otherwise.
673
784
  if (authAdapter) {
674
785
  dataRouter.use("/*", createAdapterAuthMiddleware({
675
786
  adapter: authAdapter,
676
787
  driver: defaultDriver,
788
+ resolveDriver,
677
789
  requireAuth: dataRequireAuth,
678
790
  apiKeyStore
679
791
  }));
680
792
  } else {
681
793
  dataRouter.use("/*", createAuthMiddleware({
682
794
  driver: defaultDriver,
795
+ resolveDriver,
683
796
  requireAuth: dataRequireAuth,
684
797
  serviceKey,
685
798
  apiKeyStore
@@ -702,8 +815,15 @@ async function _initializeRebaseBackend(config: RebaseBackendConfig): Promise<Re
702
815
  dataRouter.route("/", historyRoutes);
703
816
  }
704
817
 
818
+ // Only generate server data routes for server-mediated collections.
819
+ // Collections on a direct/custom transport are client-only — the
820
+ // backend must not expose a (mis-engined) endpoint for them.
821
+ const serverCollections = activeCollections.filter(
822
+ (collection) => resolveDataSource(collection, dataSourceRegistry).transport === "server"
823
+ );
824
+
705
825
  const restGenerator = new RestApiGenerator(
706
- activeCollections,
826
+ serverCollections,
707
827
  defaultDriver,
708
828
  config.hooks?.data,
709
829
  authAdapter
@@ -876,8 +996,20 @@ async function _initializeRebaseBackend(config: RebaseBackendConfig): Promise<Re
876
996
  }
877
997
  }
878
998
 
879
- if (defaultBootstrapper.initializeWebsockets && defaultRealtimeService) {
880
- await defaultBootstrapper.initializeWebsockets(config.server, defaultRealtimeService, defaultDriver, config.auth, authAdapter);
999
+ // With multiple realtime-capable engines, route subscriptions to the
1000
+ // provider owning each collection (the realtime counterpart of the data
1001
+ // router). The single WebSocket server is driven by this composite.
1002
+ // Single-engine setups use the default provider unchanged.
1003
+ const effectiveRealtimeService: RealtimeProvider = Object.keys(realtimeServices).length > 1
1004
+ ? createRoutedRealtimeService({
1005
+ providers: realtimeServices,
1006
+ defaultKey: defaultDriverId,
1007
+ resolveKey: keyForCollectionPath
1008
+ })
1009
+ : defaultRealtimeService as RealtimeProvider;
1010
+
1011
+ if (defaultBootstrapper.initializeWebsockets && effectiveRealtimeService) {
1012
+ await defaultBootstrapper.initializeWebsockets(config.server, effectiveRealtimeService, defaultDriver, config.auth, authAdapter);
881
1013
  }
882
1014
 
883
1015
  logger.info("Rebase Backend Initialized");
@@ -896,7 +1028,7 @@ async function _initializeRebaseBackend(config: RebaseBackendConfig): Promise<Re
896
1028
  driverRegistry,
897
1029
  driver: defaultDriver,
898
1030
  realtimeServices,
899
- realtimeService: defaultRealtimeService as RealtimeProvider,
1031
+ realtimeService: effectiveRealtimeService,
900
1032
  auth: authConfigResult,
901
1033
  history: historyConfigResult,
902
1034
  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
+ }
@@ -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