@rebasepro/server-core 0.6.0 → 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.
- package/package.json +14 -14
- package/src/api/errors.ts +16 -2
- package/src/api/graphql/graphql-schema-generator.ts +45 -10
- package/src/api/rest/api-generator.ts +19 -2
- package/src/api/server.ts +10 -2
- package/src/api/types.ts +6 -0
- package/src/auth/adapter-middleware.ts +10 -2
- package/src/auth/admin-roles-route.ts +36 -0
- package/src/auth/admin-users-route.ts +302 -0
- package/src/auth/api-keys/api-key-middleware.ts +4 -3
- package/src/auth/api-keys/api-key-routes.ts +12 -2
- package/src/auth/api-keys/api-key-store.ts +83 -66
- package/src/auth/api-keys/api-key-types.ts +8 -0
- package/src/auth/apple-oauth.ts +2 -1
- package/src/auth/auth-hooks.ts +21 -0
- package/src/auth/bitbucket-oauth.ts +2 -1
- package/src/auth/builtin-auth-adapter.ts +28 -4
- package/src/auth/custom-auth-adapter.ts +2 -0
- package/src/auth/discord-oauth.ts +2 -1
- package/src/auth/facebook-oauth.ts +2 -1
- package/src/auth/github-oauth.ts +2 -1
- package/src/auth/gitlab-oauth.ts +2 -1
- package/src/auth/google-oauth.ts +8 -4
- package/src/auth/index.ts +2 -0
- package/src/auth/interfaces.ts +27 -0
- package/src/auth/linkedin-oauth.ts +2 -1
- package/src/auth/magic-link-routes.ts +167 -0
- package/src/auth/mfa-crypto.ts +91 -0
- package/src/auth/mfa-routes.ts +34 -10
- package/src/auth/microsoft-oauth.ts +2 -1
- package/src/auth/middleware.ts +10 -1
- package/src/auth/reset-password-admin.ts +17 -1
- package/src/auth/routes.ts +77 -8
- package/src/auth/session-routes.ts +15 -3
- package/src/auth/slack-oauth.ts +2 -1
- package/src/auth/spotify-oauth.ts +2 -1
- package/src/auth/twitter-oauth.ts +8 -1
- package/src/cron/cron-store.ts +25 -23
- package/src/email/index.ts +3 -2
- package/src/email/templates.ts +82 -0
- package/src/email/types.ts +16 -0
- package/src/init.ts +137 -5
- package/src/services/routed-realtime-service.ts +113 -0
- package/src/storage/routes.ts +4 -0
- package/src/utils/dev-port.ts +13 -7
- package/test/auth-routes.test.ts +54 -4
- package/test/custom-auth-adapter.test.ts +20 -1
- package/test/env.test.ts +9 -19
- package/test/multi-datasource-routing.test.ts +113 -0
- package/test/routed-realtime-service.test.ts +86 -0
- package/test/transform-auth-response.test.ts +305 -0
- package/vite.config.ts +5 -0
- package/build-errors.txt +0 -52
- package/dist/api/ast-schema-editor.d.ts +0 -21
- package/dist/api/collections_for_test/callbacks_test_collection.d.ts +0 -2
- package/dist/api/errors.d.ts +0 -53
- package/dist/api/graphql/graphql-schema-generator.d.ts +0 -35
- package/dist/api/graphql/index.d.ts +0 -1
- package/dist/api/index.d.ts +0 -9
- package/dist/api/logs-routes.d.ts +0 -37
- package/dist/api/openapi-generator.d.ts +0 -16
- package/dist/api/rest/api-generator.d.ts +0 -88
- package/dist/api/rest/index.d.ts +0 -1
- package/dist/api/rest/query-parser.d.ts +0 -11
- package/dist/api/schema-editor-routes.d.ts +0 -3
- package/dist/api/server.d.ts +0 -40
- package/dist/api/types.d.ts +0 -98
- package/dist/auth/adapter-middleware.d.ts +0 -37
- package/dist/auth/admin-user-ops.d.ts +0 -79
- package/dist/auth/api-keys/api-key-middleware.d.ts +0 -39
- package/dist/auth/api-keys/api-key-permission-guard.d.ts +0 -32
- package/dist/auth/api-keys/api-key-routes.d.ts +0 -20
- package/dist/auth/api-keys/api-key-store.d.ts +0 -35
- package/dist/auth/api-keys/api-key-types.d.ts +0 -88
- package/dist/auth/api-keys/index.d.ts +0 -17
- package/dist/auth/apple-oauth.d.ts +0 -30
- package/dist/auth/auth-hooks.d.ts +0 -225
- package/dist/auth/bitbucket-oauth.d.ts +0 -11
- package/dist/auth/builtin-auth-adapter.d.ts +0 -49
- package/dist/auth/crypto-utils.d.ts +0 -16
- package/dist/auth/custom-auth-adapter.d.ts +0 -39
- package/dist/auth/discord-oauth.d.ts +0 -14
- package/dist/auth/facebook-oauth.d.ts +0 -14
- package/dist/auth/github-oauth.d.ts +0 -15
- package/dist/auth/gitlab-oauth.d.ts +0 -13
- package/dist/auth/google-oauth.d.ts +0 -47
- package/dist/auth/index.d.ts +0 -36
- package/dist/auth/interfaces.d.ts +0 -401
- package/dist/auth/jwt.d.ts +0 -55
- package/dist/auth/linkedin-oauth.d.ts +0 -18
- package/dist/auth/mfa-routes.d.ts +0 -6
- package/dist/auth/mfa.d.ts +0 -49
- package/dist/auth/microsoft-oauth.d.ts +0 -16
- package/dist/auth/middleware.d.ts +0 -106
- package/dist/auth/password.d.ts +0 -22
- package/dist/auth/rate-limiter.d.ts +0 -50
- package/dist/auth/reset-password-admin.d.ts +0 -29
- package/dist/auth/rls-scope.d.ts +0 -31
- package/dist/auth/routes.d.ts +0 -33
- package/dist/auth/session-routes.d.ts +0 -25
- package/dist/auth/slack-oauth.d.ts +0 -12
- package/dist/auth/spotify-oauth.d.ts +0 -12
- package/dist/auth/twitter-oauth.d.ts +0 -18
- package/dist/backend-CIxN4FVm.js +0 -15
- package/dist/backend-CIxN4FVm.js.map +0 -1
- package/dist/chunk-Dze3rakg.js +0 -42
- package/dist/collections/BackendCollectionRegistry.d.ts +0 -13
- package/dist/collections/loader.d.ts +0 -5
- package/dist/cron/cron-loader.d.ts +0 -17
- package/dist/cron/cron-routes.d.ts +0 -14
- package/dist/cron/cron-scheduler.d.ts +0 -106
- package/dist/cron/cron-store.d.ts +0 -32
- package/dist/cron/index.d.ts +0 -6
- package/dist/db/interfaces.d.ts +0 -18
- package/dist/dist-CZKP-Xz4.js +0 -832
- package/dist/dist-CZKP-Xz4.js.map +0 -1
- package/dist/email/index.d.ts +0 -6
- package/dist/email/smtp-email-service.d.ts +0 -25
- package/dist/email/templates.d.ts +0 -42
- package/dist/email/types.d.ts +0 -108
- package/dist/env.d.ts +0 -102
- package/dist/from-VbwD7xRf.js +0 -3849
- package/dist/from-VbwD7xRf.js.map +0 -1
- package/dist/functions/function-loader.d.ts +0 -17
- package/dist/functions/function-routes.d.ts +0 -10
- package/dist/functions/index.d.ts +0 -3
- package/dist/history/history-routes.d.ts +0 -23
- package/dist/history/index.d.ts +0 -1
- package/dist/index.d.ts +0 -33
- package/dist/index.es.js +0 -61726
- package/dist/index.es.js.map +0 -1
- package/dist/index.umd.js +0 -73216
- package/dist/index.umd.js.map +0 -1
- package/dist/init/docs.d.ts +0 -4
- package/dist/init/health.d.ts +0 -2
- package/dist/init/middlewares.d.ts +0 -10
- package/dist/init/shutdown.d.ts +0 -11
- package/dist/init/storage.d.ts +0 -5
- package/dist/init.d.ts +0 -258
- package/dist/jwt-DHcQRGC3.js +0 -4168
- package/dist/jwt-DHcQRGC3.js.map +0 -1
- package/dist/logger-BYU66ENZ.js +0 -94
- package/dist/logger-BYU66ENZ.js.map +0 -1
- package/dist/ms-BeBSuOXN.js +0 -125
- package/dist/ms-BeBSuOXN.js.map +0 -1
- package/dist/multipart-parser-CedBDOeC.js +0 -299
- package/dist/multipart-parser-CedBDOeC.js.map +0 -1
- package/dist/serve-spa.d.ts +0 -30
- package/dist/services/driver-registry.d.ts +0 -78
- package/dist/services/webhook-service.d.ts +0 -29
- package/dist/singleton.d.ts +0 -35
- package/dist/src-COaj0G3P.js +0 -1182
- package/dist/src-COaj0G3P.js.map +0 -1
- package/dist/storage/LocalStorageController.d.ts +0 -46
- package/dist/storage/S3StorageController.d.ts +0 -36
- package/dist/storage/image-transform.d.ts +0 -50
- package/dist/storage/index.d.ts +0 -28
- package/dist/storage/routes.d.ts +0 -38
- package/dist/storage/storage-registry.d.ts +0 -78
- package/dist/storage/tus-handler.d.ts +0 -51
- package/dist/storage/types.d.ts +0 -103
- package/dist/types/index.d.ts +0 -11
- package/dist/utils/dev-port.d.ts +0 -35
- package/dist/utils/logger.d.ts +0 -31
- package/dist/utils/logging.d.ts +0 -9
- package/dist/utils/request-id.d.ts +0 -4
- package/dist/utils/request-logger.d.ts +0 -19
- 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.
|
|
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.0",
|
|
58
|
-
"@rebasepro/common": "0.6.0",
|
|
59
|
-
"@rebasepro/utils": "0.6.0",
|
|
60
|
-
"@rebasepro/types": "0.6.0"
|
|
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
|
|
188
|
-
|
|
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
|
|
212
|
-
|
|
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
|
|
265
|
-
|
|
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
|
|
287
|
-
|
|
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
|
|
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,
|
|
@@ -294,7 +294,16 @@ values: entity.values as Record<string, unknown> },
|
|
|
294
294
|
return c.json(response, 201);
|
|
295
295
|
} catch (error) {
|
|
296
296
|
if (isRebaseApiError(error) && !error.code) {
|
|
297
|
-
|
|
297
|
+
// Only classify as BAD_REQUEST if it's an operational error
|
|
298
|
+
// (e.g. validation, DB constraints). Runtime bugs like TypeError,
|
|
299
|
+
// RangeError etc. should remain as 500 INTERNAL_ERROR.
|
|
300
|
+
const isRuntimeBug = error instanceof TypeError
|
|
301
|
+
|| error instanceof RangeError
|
|
302
|
+
|| error instanceof SyntaxError
|
|
303
|
+
|| error instanceof ReferenceError;
|
|
304
|
+
if (!isRuntimeBug) {
|
|
305
|
+
error.code = "BAD_REQUEST";
|
|
306
|
+
}
|
|
298
307
|
}
|
|
299
308
|
throw error;
|
|
300
309
|
}
|
|
@@ -343,7 +352,15 @@ values: entity.values as Record<string, unknown> },
|
|
|
343
352
|
return c.json(response);
|
|
344
353
|
} catch (error) {
|
|
345
354
|
if (isRebaseApiError(error) && !error.code) {
|
|
346
|
-
|
|
355
|
+
// Only classify as BAD_REQUEST if it's an operational error.
|
|
356
|
+
// Runtime bugs (TypeError, RangeError, etc.) stay as 500.
|
|
357
|
+
const isRuntimeBug = error instanceof TypeError
|
|
358
|
+
|| error instanceof RangeError
|
|
359
|
+
|| error instanceof SyntaxError
|
|
360
|
+
|| error instanceof ReferenceError;
|
|
361
|
+
if (!isRuntimeBug) {
|
|
362
|
+
error.code = "BAD_REQUEST";
|
|
363
|
+
}
|
|
347
364
|
}
|
|
348
365
|
throw error;
|
|
349
366
|
}
|
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(
|
|
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(
|
|
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
|
+
}
|