@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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/server-core",
3
3
  "type": "module",
4
- "version": "0.6.1",
4
+ "version": "0.7.0",
5
5
  "description": "Database-Agnostic Backend Core for Rebase",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -30,6 +30,13 @@
30
30
  "backend",
31
31
  "database"
32
32
  ],
33
+ "scripts": {
34
+ "watch": "vite build --watch",
35
+ "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json",
36
+ "test:lint": "eslint \"src/**\" --quiet",
37
+ "test": "cross-env NODE_OPTIONS=\"--experimental-vm-modules\" jest --passWithNoTests --forceExit",
38
+ "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f"
39
+ },
33
40
  "exports": {
34
41
  ".": {
35
42
  "types": "./dist/index.d.ts",
@@ -44,6 +51,10 @@
44
51
  "@aws-sdk/s3-request-presigner": "^3.1068.0",
45
52
  "@hono/graphql-server": "0.7.0",
46
53
  "@hono/node-server": "2.0.4",
54
+ "@rebasepro/client": "workspace:*",
55
+ "@rebasepro/common": "workspace:*",
56
+ "@rebasepro/types": "workspace:*",
57
+ "@rebasepro/utils": "workspace:*",
47
58
  "drizzle-orm": "^0.45.2",
48
59
  "google-auth-library": "^10.7.0",
49
60
  "graphql": "^16.14.2",
@@ -53,11 +64,7 @@
53
64
  "sharp": "^0.35.1",
54
65
  "ts-morph": "28.0.0",
55
66
  "ws": "^8.21.0",
56
- "zod": "^4.4.3",
57
- "@rebasepro/client": "0.6.1",
58
- "@rebasepro/types": "0.6.1",
59
- "@rebasepro/utils": "0.6.1",
60
- "@rebasepro/common": "0.6.1"
67
+ "zod": "^4.4.3"
61
68
  },
62
69
  "devDependencies": {
63
70
  "@types/jest": "^30.0.0",
@@ -77,12 +84,5 @@
77
84
  "gitHead": "d935eefa5aa8d1009a2398cfac2c1e4ee9aeb6b6",
78
85
  "publishConfig": {
79
86
  "access": "public"
80
- },
81
- "scripts": {
82
- "watch": "vite build --watch",
83
- "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json",
84
- "test:lint": "eslint \"src/**\" --quiet",
85
- "test": "cross-env NODE_OPTIONS=\"--experimental-vm-modules\" jest --passWithNoTests --forceExit",
86
- "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f"
87
87
  }
88
- }
88
+ }
package/src/api/errors.ts CHANGED
@@ -145,12 +145,26 @@ export const errorHandler: ErrorHandler<HonoEnv> = (err, c) => {
145
145
 
146
146
  // Handle DB connection and specific system errors for better logging
147
147
  let logMessage = error.message;
148
+
149
+ // Resolve the actual cause — Node's net module wraps dual-stack failures
150
+ // in an AggregateError whose inner errors carry the real address/port.
151
+ let resolvedCause: PgLikeError | undefined;
148
152
  if (error.cause && typeof error.cause === "object" && error.cause !== null && "code" in error.cause) {
149
- const cause = error.cause as PgLikeError;
153
+ const cause = error.cause as PgLikeError & { errors?: PgLikeError[] };
154
+ if (cause.code === "ECONNREFUSED" && !cause.address && Array.isArray(cause.errors)) {
155
+ // AggregateError — pick the first inner error that has address info
156
+ resolvedCause = cause.errors.find(e => e.address) || cause;
157
+ } else {
158
+ resolvedCause = cause;
159
+ }
160
+ }
161
+
162
+ if (resolvedCause) {
163
+ const cause = resolvedCause;
150
164
  if (cause.code === "ENETUNREACH") {
151
165
  logMessage = `Network unreachable. Cannot connect to database at ${cause.address}:${cause.port}.`;
152
166
  } else if (cause.code === "ECONNREFUSED") {
153
- logMessage = `Connection refused to database at ${cause.address}:${cause.port}.`;
167
+ logMessage = `Connection refused to database at ${cause.address}:${cause.port}. Is PostgreSQL running?`;
154
168
  } else if (cause.code === "42703" || cause.code === "42P01") {
155
169
  code = "SCHEMA_DRIFT";
156
170
  const issue = cause.code === "42703" ? "column" : "table";
@@ -12,6 +12,14 @@ import {
12
12
  GraphQLInputFieldConfig
13
13
  } from "graphql";
14
14
  import { DataDriver, EntityCollection, FetchCollectionProps, Property, Entity } from "@rebasepro/types";
15
+ import { isOperationAllowed, type ApiKeyOperation } from "../../auth/api-keys/api-key-permission-guard";
16
+ import type { ApiKeyMasked } from "../../auth/api-keys/api-key-types";
17
+
18
+ /** Context shape provided by @hono/graphql-server (Hono's Context object). */
19
+ type GraphQLResolverContext = {
20
+ get?: (key: string) => unknown;
21
+ driver?: DataDriver;
22
+ };
15
23
 
16
24
  /**
17
25
  * Lightweight GraphQL schema generator that leverages existing DataDriver
@@ -28,6 +36,28 @@ export class GraphQLSchemaGenerator {
28
36
  this.driver = driver;
29
37
  }
30
38
 
39
+ /**
40
+ * Enforce API key permission scoping for a GraphQL resolver.
41
+ *
42
+ * Extracts the `apiKey` from the Hono context passed by `@hono/graphql-server`.
43
+ * If the request was made with an API key and the key does not have the
44
+ * required permission for the target collection/operation, an error is thrown.
45
+ * Non-API-key requests (e.g. session auth) are allowed through.
46
+ */
47
+ private enforceApiKeyPermission(
48
+ context: GraphQLResolverContext | undefined,
49
+ collectionSlug: string,
50
+ operation: ApiKeyOperation
51
+ ): void {
52
+ const apiKey = context?.get?.("apiKey") as ApiKeyMasked | undefined;
53
+ if (!apiKey) return;
54
+ if (!isOperationAllowed(apiKey.permissions, collectionSlug, operation)) {
55
+ throw new Error(
56
+ `API key does not have "${operation}" permission for collection "${collectionSlug}"`
57
+ );
58
+ }
59
+ }
60
+
31
61
  /**
32
62
  * Generate complete GraphQL schema using existing DataDriver
33
63
  */
@@ -184,8 +214,9 @@ export class GraphQLSchemaGenerator {
184
214
  id: { type: new GraphQLNonNull(GraphQLString) }
185
215
  },
186
216
  resolve: async (_, args, context: unknown) => {
187
- const ctx = context as { driver?: DataDriver } | undefined;
188
- const ds = ctx?.driver;
217
+ const ctx = context as GraphQLResolverContext | undefined;
218
+ this.enforceApiKeyPermission(ctx, collection.slug, "read");
219
+ const ds = ctx?.get?.("driver") as DataDriver | undefined ?? (ctx as Record<string, unknown> | undefined)?.driver as DataDriver | undefined;
189
220
  if (!ds) throw new Error("Scoped driver not available");
190
221
  const entity = await ds.fetchEntity({
191
222
  path: collection.slug,
@@ -208,8 +239,9 @@ defaultValue: 0 },
208
239
  orderBy: { type: GraphQLString }
209
240
  },
210
241
  resolve: async (_, args, context: unknown) => {
211
- const ctx = context as { driver?: DataDriver } | undefined;
212
- const ds = ctx?.driver;
242
+ const ctx = context as GraphQLResolverContext | undefined;
243
+ this.enforceApiKeyPermission(ctx, collection.slug, "read");
244
+ const ds = ctx?.get?.("driver") as DataDriver | undefined ?? (ctx as Record<string, unknown> | undefined)?.driver as DataDriver | undefined;
213
245
  if (!ds) throw new Error("Scoped driver not available");
214
246
  let filter: FetchCollectionProps["filter"] | undefined;
215
247
  if (args.where) {
@@ -261,8 +293,9 @@ defaultValue: 0 },
261
293
  input: { type: new GraphQLNonNull(inputType) }
262
294
  },
263
295
  resolve: async (_, args, context: unknown) => {
264
- const ctx = context as { driver?: DataDriver } | undefined;
265
- const ds = ctx?.driver;
296
+ const ctx = context as GraphQLResolverContext | undefined;
297
+ this.enforceApiKeyPermission(ctx, collection.slug, "write");
298
+ const ds = ctx?.get?.("driver") as DataDriver | undefined ?? (ctx as Record<string, unknown> | undefined)?.driver as DataDriver | undefined;
266
299
  if (!ds) throw new Error("Scoped driver not available");
267
300
  const path = collection.slug;
268
301
  const entity = await ds.saveEntity({
@@ -283,8 +316,9 @@ defaultValue: 0 },
283
316
  input: { type: new GraphQLNonNull(inputType) }
284
317
  },
285
318
  resolve: async (_, args, context: unknown) => {
286
- const ctx = context as { driver?: DataDriver } | undefined;
287
- const ds = ctx?.driver;
319
+ const ctx = context as GraphQLResolverContext | undefined;
320
+ this.enforceApiKeyPermission(ctx, collection.slug, "write");
321
+ const ds = ctx?.get?.("driver") as DataDriver | undefined ?? (ctx as Record<string, unknown> | undefined)?.driver as DataDriver | undefined;
288
322
  if (!ds) throw new Error("Scoped driver not available");
289
323
  const entity = await ds.saveEntity({
290
324
  path: collection.slug,
@@ -304,9 +338,10 @@ defaultValue: 0 },
304
338
  id: { type: new GraphQLNonNull(GraphQLString) }
305
339
  },
306
340
  resolve: async (_, args, context: unknown) => {
341
+ const ctx = context as GraphQLResolverContext | undefined;
342
+ this.enforceApiKeyPermission(ctx, collection.slug, "delete");
307
343
  try {
308
- const ctx = context as { driver?: DataDriver } | undefined;
309
- const ds = ctx?.driver;
344
+ const ds = ctx?.get?.("driver") as DataDriver | undefined ?? (ctx as Record<string, unknown> | undefined)?.driver as DataDriver | undefined;
310
345
  if (!ds) throw new Error("Scoped driver not available");
311
346
  const existingEntity = await ds.fetchEntity({
312
347
  path: collection.slug,
package/src/api/server.ts CHANGED
@@ -6,6 +6,7 @@ import { serve } from "@hono/node-server";
6
6
  import { GraphQLSchemaGenerator } from "./graphql/graphql-schema-generator";
7
7
  import { RestApiGenerator } from "./rest/api-generator";
8
8
  import { DataDriver, EntityCollection, Relation } from "@rebasepro/types";
9
+ import { createDataSourceRegistry, resolveDataSource } from "@rebasepro/common";
9
10
  import { ApiConfig, HonoEnv } from "./types";
10
11
  import { loadCollectionsFromDirectory } from "../collections/loader";
11
12
  import { createSchemaEditorRoutes } from "./schema-editor-routes";
@@ -142,9 +143,16 @@ export class RebaseApiServer {
142
143
  return c.json({ data: collectionsMetadata });
143
144
  });
144
145
 
146
+ // Server data routes are only generated for server-transport
147
+ // collections; direct/custom sources are client-only.
148
+ const dataSourceRegistry = createDataSourceRegistry(this.config.dataSources);
149
+ const serverCollections = (this.config.collections || []).filter(
150
+ (collection) => resolveDataSource(collection, dataSourceRegistry).transport === "server"
151
+ );
152
+
145
153
  // GraphQL endpoint
146
154
  if (this.config.enableGraphQL) {
147
- const schemaGenerator = new GraphQLSchemaGenerator(this.config.collections || [], this.driver);
155
+ const schemaGenerator = new GraphQLSchemaGenerator(serverCollections, this.driver);
148
156
  const schema = schemaGenerator.generateSchema();
149
157
 
150
158
  // Context is automatically passed to resolvers via contextValue containing Hono's 'c'
@@ -182,7 +190,7 @@ export class RebaseApiServer {
182
190
  }
183
191
 
184
192
  if (this.config.enableREST) {
185
- const restGenerator = new RestApiGenerator(this.config.collections || [], this.driver);
193
+ const restGenerator = new RestApiGenerator(serverCollections, this.driver);
186
194
  const restRoutes = restGenerator.generateRoutes();
187
195
  this.router.route(basePath, restRoutes);
188
196
  }
package/src/api/types.ts CHANGED
@@ -28,6 +28,12 @@ export type HonoEnv = {
28
28
  export interface ApiConfig {
29
29
  collections?: EntityCollection[];
30
30
  collectionsDir?: string;
31
+ /**
32
+ * Declared data sources. When provided, collections on a `direct`/`custom`
33
+ * transport are treated as client-only and no server routes are generated
34
+ * for them.
35
+ */
36
+ dataSources?: import("@rebasepro/types").DataSourceDefinition[];
31
37
  basePath?: string;
32
38
  enableGraphQL?: boolean;
33
39
  enableREST?: boolean;
@@ -15,7 +15,7 @@
15
15
  * token verification strategy is pluggable.
16
16
  */
17
17
 
18
- import type { MiddlewareHandler } from "hono";
18
+ import type { MiddlewareHandler, Context } from "hono";
19
19
  import type { DataDriver, AuthAdapter } from "@rebasepro/types";
20
20
  import type { HonoEnv } from "../api/types";
21
21
  import type { ApiKeyStore } from "./api-keys/api-key-store";
@@ -28,6 +28,12 @@ export interface AdapterAuthMiddlewareOptions {
28
28
  adapter: AuthAdapter;
29
29
  /** The DataDriver to scope via withAuth() for RLS. */
30
30
  driver: DataDriver;
31
+ /**
32
+ * Optional per-request driver resolver for multi-data-source backends.
33
+ * Returns the unscoped delegate to use for this request (e.g. Postgres vs
34
+ * Mongo). When omitted, `driver` is used for every request.
35
+ */
36
+ resolveDriver?: (c: Context<HonoEnv>) => DataDriver;
31
37
  /**
32
38
  * If true, return 401 when no valid user is resolved.
33
39
  * Defaults to `true` (secure by default).
@@ -41,9 +47,11 @@ export interface AdapterAuthMiddlewareOptions {
41
47
  * Create a Hono middleware that uses an `AuthAdapter` for request verification.
42
48
  */
43
49
  export function createAdapterAuthMiddleware(options: AdapterAuthMiddlewareOptions): MiddlewareHandler<HonoEnv> {
44
- const { adapter, driver, requireAuth: enforceAuth = true, apiKeyStore } = options;
50
+ const { adapter, driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, apiKeyStore } = options;
45
51
 
46
52
  return async (c, next) => {
53
+ // Pick the per-request delegate (multi-data-source) before scoping.
54
+ const driver = resolveDriver ? resolveDriver(c) : baseDriver;
47
55
  // ── API Key check (Rebase-level, independent of auth adapter) ────
48
56
  if (apiKeyStore) {
49
57
  const authHeader = c.req.header("authorization") || "";
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Admin endpoint for listing all roles.
3
+ *
4
+ * Mounts: GET /roles
5
+ */
6
+
7
+ import { Hono } from "hono";
8
+ import { errorHandler } from "../api/errors";
9
+ import type { AuthRepository } from "./interfaces";
10
+ import { createRequireAuth, requireAdmin } from "./middleware";
11
+ import type { HonoEnv } from "../api/types";
12
+
13
+ export interface AdminRolesRouteConfig {
14
+ authRepo: AuthRepository;
15
+ serviceKey?: string;
16
+ }
17
+
18
+ /**
19
+ * Create a standalone admin route for listing roles.
20
+ *
21
+ * Mounts: GET /roles
22
+ */
23
+ export function createAdminRolesRoute(config: AdminRolesRouteConfig): Hono<HonoEnv> {
24
+ const router = new Hono<HonoEnv>();
25
+ const authRepo = config.authRepo;
26
+
27
+ router.onError(errorHandler);
28
+ router.use("/*", createRequireAuth({ serviceKey: config.serviceKey }));
29
+
30
+ router.get("/roles", requireAdmin, async (c) => {
31
+ const roles = await authRepo.listRoles();
32
+ return c.json({ roles });
33
+ });
34
+
35
+ return router;
36
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Standalone admin endpoint for user management.
3
+ *
4
+ * Mounts:
5
+ * GET /users
6
+ * GET /users/:userId
7
+ * POST /users
8
+ * PUT /users/:userId
9
+ * DELETE /users/:userId
10
+ * POST /bootstrap
11
+ */
12
+
13
+ import { Hono } from "hono";
14
+ import { ApiError, errorHandler } from "../api/errors";
15
+ import type { AuthRepository } from "./interfaces";
16
+ import { createRequireAuth, requireAdmin } from "./middleware";
17
+ import type { AuthHooks } from "./auth-hooks";
18
+ import { resolveAuthHooks } from "./auth-hooks";
19
+ import { prepareAdminUserValues, finalizeAdminUserCreation } from "./admin-user-ops";
20
+ import type { EmailService, EmailConfig } from "../email";
21
+ import type { HonoEnv } from "../api/types";
22
+ import type { AdminUser, AuthCollectionConfig } from "@rebasepro/types";
23
+ import { logger } from "../utils/logger";
24
+
25
+ export interface AdminUsersRouteConfig {
26
+ authRepo: AuthRepository;
27
+ emailService?: EmailService;
28
+ emailConfig?: EmailConfig;
29
+ serviceKey?: string;
30
+ authHooks?: AuthHooks;
31
+ collectionAuthConfig?: AuthCollectionConfig;
32
+ isBootstrapCompleted?: () => Promise<boolean>;
33
+ setBootstrapCompleted?: () => Promise<void>;
34
+ }
35
+
36
+ export function createAdminUsersRoute(config: AdminUsersRouteConfig): Hono<HonoEnv> {
37
+ const router = new Hono<HonoEnv>();
38
+ const authRepo = config.authRepo;
39
+ const { emailService, emailConfig, collectionAuthConfig } = config;
40
+ const ops = resolveAuthHooks(config.authHooks);
41
+
42
+ function toAdminUser(
43
+ u: {
44
+ id: string;
45
+ email: string;
46
+ displayName?: string | null;
47
+ photoUrl?: string | null;
48
+ createdAt?: Date | string;
49
+ updatedAt?: Date | string;
50
+ },
51
+ roles: string[]
52
+ ): AdminUser {
53
+ return {
54
+ uid: u.id,
55
+ email: u.email,
56
+ displayName: u.displayName ?? null,
57
+ photoURL: u.photoUrl ?? null,
58
+ provider: "custom",
59
+ roles,
60
+ createdAt: u.createdAt instanceof Date ? u.createdAt.toISOString() : (u.createdAt ?? new Date().toISOString()),
61
+ updatedAt: u.updatedAt instanceof Date ? u.updatedAt.toISOString() : (u.updatedAt ?? new Date().toISOString())
62
+ };
63
+ }
64
+
65
+ router.onError(errorHandler);
66
+ router.use("/*", createRequireAuth({ serviceKey: config.serviceKey }));
67
+
68
+ router.post("/bootstrap", async (c) => {
69
+ const user = c.get("user");
70
+ if (!user || typeof user !== "object") {
71
+ throw ApiError.unauthorized("Not authenticated");
72
+ }
73
+
74
+ if (config.isBootstrapCompleted) {
75
+ const alreadyDone = await config.isBootstrapCompleted();
76
+ if (alreadyDone) {
77
+ throw ApiError.forbidden("Bootstrap has already been completed.", "BOOTSTRAP_COMPLETED");
78
+ }
79
+ }
80
+
81
+ const users = await authRepo.listUsers();
82
+ let hasAdmin = false;
83
+
84
+ for (const u of users) {
85
+ const roles = await authRepo.getUserRoleIds(u.id);
86
+ if (roles.includes("admin")) {
87
+ hasAdmin = true;
88
+ break;
89
+ }
90
+ }
91
+
92
+ if (hasAdmin) {
93
+ throw ApiError.forbidden("Admin users already exist. Bootstrap not allowed.", "BOOTSTRAP_COMPLETED");
94
+ }
95
+
96
+ const userId = "userId" in user ? (user as { userId: string }).userId : ("uid" in user ? (user as { uid: string }).uid : undefined);
97
+ if (!userId) {
98
+ throw ApiError.unauthorized("User ID not found in auth context");
99
+ }
100
+ const caller = await authRepo.getUserById(userId);
101
+ if (!caller) {
102
+ throw ApiError.notFound("Authenticated user does not exist in the database.", "USER_NOT_FOUND");
103
+ }
104
+ await authRepo.setUserRoles(userId, ["admin"]);
105
+
106
+ if (config.setBootstrapCompleted) {
107
+ await config.setBootstrapCompleted();
108
+ }
109
+
110
+ return c.json({
111
+ success: true,
112
+ message: "You are now an admin",
113
+ user: {
114
+ uid: userId,
115
+ roles: ["admin"]
116
+ }
117
+ });
118
+ });
119
+
120
+ router.get("/users", requireAdmin, async (c) => {
121
+ const limitParam = c.req.query("limit");
122
+ const offsetParam = c.req.query("offset");
123
+ const search = c.req.query("search");
124
+ const orderBy = c.req.query("orderBy");
125
+ const orderDir = c.req.query("orderDir") as "asc" | "desc" | undefined;
126
+
127
+ const limit = limitParam ? parseInt(limitParam, 10) : 25;
128
+ const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
129
+
130
+ const result = await authRepo.listUsersPaginated({
131
+ limit,
132
+ offset,
133
+ search: search || undefined,
134
+ orderBy: orderBy || undefined,
135
+ orderDir: orderDir || undefined,
136
+ roleId: c.req.query("role") || undefined
137
+ });
138
+
139
+ const usersWithRoles = await Promise.all(
140
+ result.users.map(async (u) => {
141
+ const roles = await authRepo.getUserRoleIds(u.id);
142
+ return toAdminUser(u, roles);
143
+ })
144
+ );
145
+
146
+ return c.json({
147
+ users: usersWithRoles,
148
+ total: result.total,
149
+ limit: result.limit,
150
+ offset: result.offset
151
+ });
152
+ });
153
+
154
+ router.get("/users/:userId", requireAdmin, async (c) => {
155
+ const userId = c.req.param("userId");
156
+ const result = await authRepo.getUserWithRoles(userId);
157
+
158
+ if (!result) {
159
+ throw ApiError.notFound("User not found");
160
+ }
161
+
162
+ const adminUser = toAdminUser(result.user, result.roles.map((r) => r.id));
163
+ return c.json({ user: adminUser });
164
+ });
165
+
166
+ router.post("/users", requireAdmin, async (c) => {
167
+ const body = await c.req.json();
168
+ const { email, roles } = body;
169
+ if (!email) {
170
+ throw ApiError.badRequest("Email is required");
171
+ }
172
+
173
+ const existing = await authRepo.getUserByEmail(email.toLowerCase());
174
+ if (existing) {
175
+ throw ApiError.conflict("A user with this email already exists");
176
+ }
177
+
178
+ const prepResult = await prepareAdminUserValues(body, {
179
+ authRepo,
180
+ emailService,
181
+ emailConfig,
182
+ resolvedHooks: ops,
183
+ collectionAuthConfig
184
+ });
185
+
186
+ const user = await authRepo.createUser({
187
+ email: prepResult.values.email as string,
188
+ passwordHash: prepResult.values.passwordHash as string | undefined,
189
+ displayName: prepResult.values.displayName as string | undefined,
190
+ photoUrl: prepResult.values.photoUrl as string | undefined,
191
+ metadata: prepResult.values.metadata as Record<string, unknown> | undefined
192
+ });
193
+
194
+ if (roles && Array.isArray(roles)) {
195
+ await authRepo.setUserRoles(user.id, roles);
196
+ }
197
+
198
+ const finalizeResult = await finalizeAdminUserCreation(
199
+ { id: user.id, values: prepResult.values },
200
+ prepResult.clearPassword,
201
+ {
202
+ authRepo,
203
+ emailService,
204
+ emailConfig,
205
+ resolvedHooks: ops,
206
+ collectionAuthConfig
207
+ }
208
+ );
209
+
210
+ const userRoles = await authRepo.getUserRoleIds(user.id);
211
+ const adminUser = toAdminUser(user, userRoles);
212
+
213
+ return c.json(
214
+ {
215
+ user: adminUser,
216
+ invitationSent: finalizeResult.invitationSent,
217
+ ...(finalizeResult.temporaryPassword ? { temporaryPassword: finalizeResult.temporaryPassword } : {})
218
+ },
219
+ 201
220
+ );
221
+ });
222
+
223
+ router.put("/users/:userId", requireAdmin, async (c) => {
224
+ const userId = c.req.param("userId");
225
+ const body = await c.req.json();
226
+ const { password, email, displayName, roles } = body;
227
+
228
+ const existing = await authRepo.getUserById(userId);
229
+ if (!existing) {
230
+ throw ApiError.notFound("User not found");
231
+ }
232
+
233
+ const updates: Record<string, unknown> = {};
234
+ if (email !== undefined) updates.email = email.toLowerCase();
235
+ if (displayName !== undefined) updates.displayName = displayName;
236
+
237
+ if (password) {
238
+ const validation = ops.validatePasswordStrength(password);
239
+ if (!validation.valid) {
240
+ throw ApiError.badRequest(`Password too weak: ${validation.errors.join(". ")}`);
241
+ }
242
+ updates.passwordHash = await ops.hashPassword(password);
243
+ }
244
+
245
+ if (Object.keys(updates).length > 0) {
246
+ await authRepo.updateUser(userId, updates);
247
+ }
248
+
249
+ if (roles !== undefined && Array.isArray(roles)) {
250
+ const currentRoles = await authRepo.getUserRoleIds(userId);
251
+ const wasAdmin = currentRoles.includes("admin");
252
+ const willBeAdmin = roles.includes("admin");
253
+
254
+ if (wasAdmin && !willBeAdmin) {
255
+ const adminUsers = await authRepo.listUsersPaginated({
256
+ roleId: "admin",
257
+ limit: 1
258
+ });
259
+ if (adminUsers.total <= 1) {
260
+ throw ApiError.forbidden("Cannot demote the last administrator", "LAST_ADMIN");
261
+ }
262
+ }
263
+ await authRepo.setUserRoles(userId, roles);
264
+ }
265
+
266
+ const result = await authRepo.getUserWithRoles(userId);
267
+ const adminUser = toAdminUser(result!.user, result!.roles.map((r) => r.id));
268
+
269
+ return c.json({ user: adminUser });
270
+ });
271
+
272
+ router.delete("/users/:userId", requireAdmin, async (c) => {
273
+ const userId = c.req.param("userId");
274
+ const authUser = c.get("user") as { uid?: string; userId?: string } | undefined;
275
+ const currentUserId = authUser?.uid || authUser?.userId;
276
+
277
+ if (currentUserId === userId) {
278
+ throw ApiError.badRequest("Cannot delete your own account", "SELF_DELETE");
279
+ }
280
+
281
+ const existing = await authRepo.getUserById(userId);
282
+ if (!existing) {
283
+ throw ApiError.notFound("User not found");
284
+ }
285
+
286
+ const roles = await authRepo.getUserRoleIds(userId);
287
+ if (roles.includes("admin")) {
288
+ const adminUsers = await authRepo.listUsersPaginated({
289
+ roleId: "admin",
290
+ limit: 1
291
+ });
292
+ if (adminUsers.total <= 1) {
293
+ throw ApiError.forbidden("Cannot delete the last administrator", "LAST_ADMIN");
294
+ }
295
+ }
296
+
297
+ await authRepo.deleteUser(userId);
298
+ return c.json({ success: true });
299
+ });
300
+
301
+ return router;
302
+ }
@@ -88,8 +88,8 @@ code: "UNAUTHORIZED" }
88
88
 
89
89
  // Set user identity — API keys represent service accounts
90
90
  const userId = `api-key:${apiKey.id}`;
91
- c.set("user", { userId,
92
- roles: [] });
91
+ const roles: string[] = apiKey.admin ? ["admin", "service"] : ["service"];
92
+ c.set("user", { userId, roles });
93
93
 
94
94
  // Expose masked key metadata for downstream permission checks
95
95
  const masked: ApiKeyMasked = {
@@ -97,6 +97,7 @@ roles: [] });
97
97
  name: apiKey.name,
98
98
  key_prefix: apiKey.key_prefix,
99
99
  permissions: apiKey.permissions,
100
+ admin: apiKey.admin,
100
101
  rate_limit: apiKey.rate_limit,
101
102
  created_by: apiKey.created_by,
102
103
  created_at: apiKey.created_at,
@@ -112,7 +113,7 @@ roles: [] });
112
113
  try {
113
114
  const scopedDriver = await scopeDataDriver(driver, {
114
115
  uid: userId,
115
- roles: ["service"]
116
+ roles
116
117
  });
117
118
  c.set("driver", scopedDriver);
118
119
  } catch (error) {