@rebasepro/server-core 0.4.0 → 0.5.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/README.md CHANGED
@@ -1,40 +1,77 @@
1
- # @rebasepro/backend
1
+ # @rebasepro/server-core
2
2
 
3
- PostgreSQL and Drizzle ORM backend implementation for Rebase.
4
-
5
- This package provides a complete backend solution for Rebase applications using PostgreSQL as the database and Drizzle ORM for type-safe database operations.
3
+ Database-agnostic backend core for Rebase.
6
4
 
7
5
  ## Installation
8
6
 
9
7
  ```bash
10
- npm install @rebasepro/backend @rebasepro/core
8
+ pnpm add @rebasepro/server-core
11
9
  ```
12
10
 
13
- ## Usage
11
+ ## What This Package Does
14
12
 
15
- ```typescript
16
- import { createBackend } from "@rebasepro/backend";
13
+ This is the central orchestrator for any Rebase backend. It provides the framework-level plumbing — HTTP routing (Hono), authentication middleware, storage, email, cron jobs, custom functions, and the REST/GraphQL API generator — without being coupled to any specific database. Database implementations are plugged in via driver packages like `@rebasepro/server-postgresql` or `@rebasepro/server-mongodb`.
17
14
 
18
- const backend = createBackend({
19
- connectionString: "postgresql://user:password@localhost:5432/database",
20
- schema: "public",
21
- debug: true
22
- });
23
- ```
15
+ ## Key Exports
16
+
17
+ | Export | Description |
18
+ |--------|-------------|
19
+ | `initializeRebaseBackend(config)` | Main entry point. Wires up drivers, auth, storage, API routes, cron, and custom functions. Returns a `RebaseBackendInstance`. |
20
+ | `rebase` | Server-side singleton (`RebaseClient`). Available after init. Admin-level access to data, auth, email, and storage. |
21
+ | `loadEnv()` | Validates `process.env` against the Rebase env schema (Zod). Auto-generates dev secrets. Supports `extend` for custom vars. |
22
+ | `serveSPA(app, config)` | Mounts SPA static-file serving + index.html fallback on a Hono app. |
23
+ | `RebaseBackendConfig` | Config type for `initializeRebaseBackend`. |
24
+ | `RebaseAuthConfig` | Auth config type (JWT, OAuth providers, hooks, service key). |
25
+ | `RebaseBackendInstance` | Return type — includes `driver`, `healthCheck()`, `shutdown()`, `cronScheduler`, `storageController`, etc. |
26
+ | `RebaseEnv` | Zod-inferred type of validated environment variables. |
27
+ | `z` | Re-exported Zod instance for extending `loadEnv`. |
28
+ | `_setRebaseMock` / `_resetRebaseMock` | Test helpers to mock the `rebase` singleton (NODE_ENV=test only). |
24
29
 
25
- ## Features
30
+ Also re-exports all abstract interfaces (`DatabaseAdapter`, `AuthAdapter`, `DataDriver`, etc.), API types (`HonoEnv`, `ApiConfig`), auth module, email module, storage module, history module, cron module, custom functions, logging utilities, and driver registry.
26
31
 
27
- - PostgreSQL database support
28
- - Drizzle ORM integration
29
- - Type-safe database operations
30
- - Full Rebase compatibility
31
- - Migration support
32
- - Connection pooling
32
+ ## Quick Start
33
33
 
34
- ## Development
34
+ ```typescript
35
+ import { serve } from "@hono/node-server";
36
+ import { Hono } from "hono";
37
+ import { initializeRebaseBackend, loadEnv, serveSPA } from "@rebasepro/server-core";
38
+ import { createPostgresAdapter } from "@rebasepro/server-postgresql";
35
39
 
36
- This package is part of the Rebase monorepo. For development instructions, see the main repository README.
40
+ // 1. Load and validate environment
41
+ const env = loadEnv();
42
+
43
+ // 2. Create Hono app + HTTP server
44
+ const app = new Hono();
45
+ const server = serve({ fetch: app.fetch, port: env.PORT });
46
+
47
+ // 3. Initialize Rebase
48
+ const backend = await initializeRebaseBackend({
49
+ app,
50
+ server,
51
+ database: createPostgresAdapter({ connection: db, schema }),
52
+ collections: myCollections,
53
+ auth: {
54
+ collection: defaultUsersCollection,
55
+ jwtSecret: env.JWT_SECRET,
56
+ allowRegistration: env.ALLOW_REGISTRATION,
57
+ serviceKey: env.REBASE_SERVICE_KEY,
58
+ google: { clientId: env.GOOGLE_CLIENT_ID },
59
+ },
60
+ storage: { type: "s3", bucket: env.S3_BUCKET },
61
+ functionsDir: "./functions",
62
+ cronsDir: "./crons",
63
+ });
64
+
65
+ // 4. Optionally serve a frontend SPA
66
+ serveSPA(app, { frontendPath: "./frontend/dist" });
67
+ ```
37
68
 
38
- ## License
69
+ ## Related Packages
39
70
 
40
- MIT
71
+ | Package | Role |
72
+ |---------|------|
73
+ | `@rebasepro/server-postgresql` | PostgreSQL database driver (Drizzle ORM) |
74
+ | `@rebasepro/server-mongodb` | MongoDB database driver |
75
+ | `@rebasepro/types` | Shared type definitions (`DataDriver`, `EntityCollection`, etc.) |
76
+ | `@rebasepro/client` | Client SDK used internally by the `rebase` singleton |
77
+ | `@rebasepro/common` | Shared utilities and default collections |
@@ -1,6 +1,14 @@
1
- import { AuthController, Entity, EntityCollection, User } from "@rebasepro/types";
2
- export declare function checkOperation<M extends Record<string, unknown>, USER extends User>(collection: EntityCollection<M>, authController: AuthController<USER>, entity: Entity<M> | null, targetOperation: "select" | "insert" | "update" | "delete"): boolean;
3
- export declare function canReadCollection<M extends Record<string, unknown>, USER extends User>(collection: EntityCollection<M>, authController: AuthController<USER>): boolean;
4
- export declare function canEditEntity<M extends Record<string, unknown>, USER extends User>(collection: EntityCollection<M>, authController: AuthController<USER>, path: string, entity: Entity<M> | null): boolean;
5
- export declare function canCreateEntity<M extends Record<string, unknown>, USER extends User>(collection: EntityCollection<M>, authController: AuthController<USER>, path: string, entity: Entity<M> | null): boolean;
6
- export declare function canDeleteEntity<M extends Record<string, unknown>, USER extends User>(collection: EntityCollection<M>, authController: AuthController<USER>, path: string, entity: Entity<M> | null): boolean;
1
+ import { Entity, EntityCollection, User } from "@rebasepro/types";
2
+ /**
3
+ * Minimal auth context for permission checking.
4
+ * Only requires the user object avoids forcing callers to construct
5
+ * a full AuthController just to check permissions.
6
+ */
7
+ export interface AuthContext<USER extends User = User> {
8
+ user: USER | null;
9
+ }
10
+ export declare function checkOperation<M extends Record<string, unknown>, USER extends User>(collection: EntityCollection<M>, authContext: AuthContext<USER>, entity: Entity<M> | null, targetOperation: "select" | "insert" | "update" | "delete"): boolean;
11
+ export declare function canReadCollection<M extends Record<string, unknown>, USER extends User>(collection: EntityCollection<M>, authContext: AuthContext<USER>): boolean;
12
+ export declare function canEditEntity<M extends Record<string, unknown>, USER extends User>(collection: EntityCollection<M>, authContext: AuthContext<USER>, path: string, entity: Entity<M> | null): boolean;
13
+ export declare function canCreateEntity<M extends Record<string, unknown>, USER extends User>(collection: EntityCollection<M>, authContext: AuthContext<USER>, path: string, entity: Entity<M> | null): boolean;
14
+ export declare function canDeleteEntity<M extends Record<string, unknown>, USER extends User>(collection: EntityCollection<M>, authContext: AuthContext<USER>, path: string, entity: Entity<M> | null): boolean;
package/dist/index.es.js CHANGED
@@ -2790,6 +2790,9 @@ class ApiError extends Error {
2790
2790
  return new ApiError(503, code2, message);
2791
2791
  }
2792
2792
  }
2793
+ function isRebaseApiError(error2) {
2794
+ return error2 instanceof Error;
2795
+ }
2793
2796
  const errorHandler = (err, c) => {
2794
2797
  const error2 = err;
2795
2798
  if (error2 instanceof ApiError || error2.name === "ApiError") {
@@ -3330,9 +3333,10 @@ class RestApiGenerator {
3330
3333
  }
3331
3334
  return c.json(response, 201);
3332
3335
  } catch (error2) {
3333
- const err = error2;
3334
- err.code = err.code || "BAD_REQUEST";
3335
- throw err;
3336
+ if (isRebaseApiError(error2) && !error2.code) {
3337
+ error2.code = "BAD_REQUEST";
3338
+ }
3339
+ throw error2;
3336
3340
  }
3337
3341
  });
3338
3342
  this.router.put(`${basePath}/:id`, async (c) => {
@@ -3368,9 +3372,10 @@ class RestApiGenerator {
3368
3372
  }
3369
3373
  return c.json(response);
3370
3374
  } catch (error2) {
3371
- const err = error2;
3372
- err.code = err.code || "BAD_REQUEST";
3373
- throw err;
3375
+ if (isRebaseApiError(error2) && !error2.code) {
3376
+ error2.code = "BAD_REQUEST";
3377
+ }
3378
+ throw error2;
3374
3379
  }
3375
3380
  });
3376
3381
  this.router.delete(`${basePath}/:id`, async (c) => {
@@ -3447,6 +3452,7 @@ class RestApiGenerator {
3447
3452
  if (!parsed) return next();
3448
3453
  const driver = c.get("driver") || this.driver;
3449
3454
  this.enforceApiKeyPermission(c, c.req.param("parent"));
3455
+ const hookCtx = this.buildHookContext(c, "GET");
3450
3456
  if (parsed.entityId === "count") {
3451
3457
  const queryDict = c.req.queries();
3452
3458
  const queryOptions = parseQueryOptions(queryDict);
@@ -3465,7 +3471,10 @@ class RestApiGenerator {
3465
3471
  entityId: parsed.entityId
3466
3472
  });
3467
3473
  if (!entity) throw ApiError.notFound("Entity not found");
3468
- return c.json(this.flattenEntity(entity));
3474
+ const flatResult = this.flattenEntity(entity);
3475
+ const hookResult = await this.applyAfterRead(c.req.param("parent"), flatResult, hookCtx);
3476
+ if (!hookResult) throw ApiError.notFound("Entity not found");
3477
+ return c.json(hookResult);
3469
3478
  } else {
3470
3479
  const queryDict = c.req.queries();
3471
3480
  const queryOptions = parseQueryOptions(queryDict);
@@ -3478,13 +3487,20 @@ class RestApiGenerator {
3478
3487
  order: queryOptions.orderBy?.[0]?.direction === "desc" ? "desc" : "asc",
3479
3488
  searchString
3480
3489
  });
3490
+ let flatEntities = entities.map((e) => this.flattenEntity(e));
3491
+ flatEntities = await this.applyAfterReadBatch(c.req.param("parent"), flatEntities, hookCtx);
3492
+ const total = driver.countEntities ? await driver.countEntities({
3493
+ path: parsed.collectionPath,
3494
+ filter: queryOptions.where,
3495
+ searchString
3496
+ }) : flatEntities.length;
3481
3497
  return c.json({
3482
- data: entities.map((e) => this.flattenEntity(e)),
3498
+ data: flatEntities,
3483
3499
  meta: {
3484
- total: entities.length,
3500
+ total,
3485
3501
  limit: queryOptions.limit,
3486
3502
  offset: queryOptions.offset,
3487
- hasMore: false
3503
+ hasMore: (queryOptions.offset || 0) + flatEntities.length < total
3488
3504
  }
3489
3505
  });
3490
3506
  }
@@ -3496,14 +3512,24 @@ class RestApiGenerator {
3496
3512
  const parsed = parseSubPath(rawPath);
3497
3513
  if (!parsed || parsed.entityId) return next();
3498
3514
  const driver = c.get("driver") || this.driver;
3515
+ const hookCtx = this.buildHookContext(c, "POST");
3499
3516
  this.enforceApiKeyPermission(c, c.req.param("parent"));
3500
- const body = await c.req.json().catch(() => ({}));
3517
+ let body = await c.req.json().catch(() => ({}));
3518
+ if (this.dataHooks?.beforeSave) {
3519
+ body = await this.dataHooks.beforeSave(parsed.collectionPath, body, void 0, hookCtx);
3520
+ }
3501
3521
  const entity = await driver.saveEntity({
3502
3522
  path: parsed.collectionPath,
3503
3523
  values: body,
3504
3524
  status: "new"
3505
3525
  });
3506
- return c.json(this.formatResponse(entity), 201);
3526
+ const response = this.formatResponse(entity);
3527
+ if (this.dataHooks?.afterSave) {
3528
+ Promise.resolve(this.dataHooks.afterSave(parsed.collectionPath, response, hookCtx)).catch((err) => {
3529
+ console.error("[BackendHooks] data.afterSave error:", err instanceof Error ? err.message : err);
3530
+ });
3531
+ }
3532
+ return c.json(response, 201);
3507
3533
  });
3508
3534
  this.router.put("/:parent/:parentId/:rest{.+}", async (c, next) => {
3509
3535
  const rest = c.req.param("rest");
@@ -3512,15 +3538,25 @@ class RestApiGenerator {
3512
3538
  const parsed = parseSubPath(rawPath);
3513
3539
  if (!parsed || !parsed.entityId) return next();
3514
3540
  const driver = c.get("driver") || this.driver;
3541
+ const hookCtx = this.buildHookContext(c, "PUT");
3515
3542
  this.enforceApiKeyPermission(c, c.req.param("parent"));
3516
- const body = await c.req.json().catch(() => ({}));
3543
+ let body = await c.req.json().catch(() => ({}));
3544
+ if (this.dataHooks?.beforeSave) {
3545
+ body = await this.dataHooks.beforeSave(parsed.collectionPath, body, parsed.entityId, hookCtx);
3546
+ }
3517
3547
  const entity = await driver.saveEntity({
3518
3548
  path: parsed.collectionPath,
3519
3549
  entityId: parsed.entityId,
3520
3550
  values: body,
3521
3551
  status: "existing"
3522
3552
  });
3523
- return c.json(this.formatResponse(entity));
3553
+ const response = this.formatResponse(entity);
3554
+ if (this.dataHooks?.afterSave) {
3555
+ Promise.resolve(this.dataHooks.afterSave(parsed.collectionPath, response, hookCtx)).catch((err) => {
3556
+ console.error("[BackendHooks] data.afterSave error:", err instanceof Error ? err.message : err);
3557
+ });
3558
+ }
3559
+ return c.json(response);
3524
3560
  });
3525
3561
  this.router.delete("/:parent/:parentId/:rest{.+}", async (c, next) => {
3526
3562
  const rest = c.req.param("rest");
@@ -3529,15 +3565,24 @@ class RestApiGenerator {
3529
3565
  const parsed = parseSubPath(rawPath);
3530
3566
  if (!parsed || !parsed.entityId) return next();
3531
3567
  const driver = c.get("driver") || this.driver;
3568
+ const hookCtx = this.buildHookContext(c, "DELETE");
3532
3569
  this.enforceApiKeyPermission(c, c.req.param("parent"));
3533
3570
  const existingEntity = await driver.fetchEntity({
3534
3571
  path: parsed.collectionPath,
3535
3572
  entityId: parsed.entityId
3536
3573
  });
3537
3574
  if (!existingEntity) throw ApiError.notFound("Entity not found");
3575
+ if (this.dataHooks?.beforeDelete) {
3576
+ await this.dataHooks.beforeDelete(parsed.collectionPath, parsed.entityId, hookCtx);
3577
+ }
3538
3578
  await driver.deleteEntity({
3539
3579
  entity: existingEntity
3540
3580
  });
3581
+ if (this.dataHooks?.afterDelete) {
3582
+ Promise.resolve(this.dataHooks.afterDelete(parsed.collectionPath, parsed.entityId, hookCtx)).catch((err) => {
3583
+ console.error("[BackendHooks] data.afterDelete error:", err instanceof Error ? err.message : err);
3584
+ });
3585
+ }
3541
3586
  return new Response(null, {
3542
3587
  status: 204
3543
3588
  });
@@ -13020,14 +13065,11 @@ function createAuthRoutes(config) {
13020
13065
  if (!factor || factor.userId !== userCtx.userId) {
13021
13066
  throw ApiError.notFound("MFA factor not found");
13022
13067
  }
13023
- const isRecoveryCode = code2.length > 6;
13024
- let isValid2 = false;
13025
- if (isRecoveryCode) {
13068
+ const secretBuffer = base32Decode(factor.secretEncrypted);
13069
+ let isValid2 = verifyTotp(secretBuffer, code2);
13070
+ if (!isValid2) {
13026
13071
  const codeHash = hashRecoveryCode(code2);
13027
13072
  isValid2 = await authRepo.useRecoveryCode(userCtx.userId, codeHash);
13028
- } else {
13029
- const secretBuffer = base32Decode(factor.secretEncrypted);
13030
- isValid2 = verifyTotp(secretBuffer, code2);
13031
13073
  }
13032
13074
  if (!isValid2) {
13033
13075
  throw ApiError.unauthorized("Invalid verification code", "INVALID_CODE");
@@ -13543,7 +13585,6 @@ function createBuiltinAuthAdapter(config) {
13543
13585
  if (!payload) {
13544
13586
  return null;
13545
13587
  }
13546
- const extendedPayload = payload;
13547
13588
  let roles = payload.roles || [];
13548
13589
  try {
13549
13590
  roles = await authRepository.getUserRoleIds(payload.userId);
@@ -13552,8 +13593,8 @@ function createBuiltinAuthAdapter(config) {
13552
13593
  const isAdmin = roles.some((r) => r === "admin" || r === "schema-admin");
13553
13594
  return {
13554
13595
  uid: payload.userId,
13555
- email: extendedPayload.email ?? "",
13556
- displayName: extendedPayload.displayName ?? null,
13596
+ email: payload.email ?? "",
13597
+ displayName: payload.displayName ?? null,
13557
13598
  roles,
13558
13599
  isAdmin,
13559
13600
  rawToken: token
@@ -13573,7 +13614,6 @@ function createBuiltinAuthAdapter(config) {
13573
13614
  if (!payload) {
13574
13615
  return null;
13575
13616
  }
13576
- const extendedPayload = payload;
13577
13617
  let roles = payload.roles || [];
13578
13618
  try {
13579
13619
  roles = await authRepository.getUserRoleIds(payload.userId);
@@ -13582,8 +13622,8 @@ function createBuiltinAuthAdapter(config) {
13582
13622
  const isAdmin = roles.some((r) => r === "admin" || r === "schema-admin");
13583
13623
  return {
13584
13624
  uid: payload.userId,
13585
- email: extendedPayload.email ?? "",
13586
- displayName: extendedPayload.displayName ?? null,
13625
+ email: payload.email ?? "",
13626
+ displayName: payload.displayName ?? null,
13587
13627
  roles,
13588
13628
  isAdmin,
13589
13629
  rawToken: token
@@ -38575,7 +38615,9 @@ async function _initializeRebaseBackend(config) {
38575
38615
  }
38576
38616
  if (bootstrapper.initializeRealtime) {
38577
38617
  const realtime = await bootstrapper.initializeRealtime({}, driverResult);
38578
- realtimeServices[b.id || bootstrapper.type] = realtime;
38618
+ if (realtime) {
38619
+ realtimeServices[b.id || bootstrapper.type] = realtime;
38620
+ }
38579
38621
  }
38580
38622
  }
38581
38623
  const driverRegistry = DefaultDriverRegistry.create(delegates);
@@ -39054,7 +39096,7 @@ async function _initializeRebaseBackend(config) {
39054
39096
  });
39055
39097
  }
39056
39098
  }
39057
- if (defaultBootstrapper.initializeWebsockets) {
39099
+ if (defaultBootstrapper.initializeWebsockets && defaultRealtimeService) {
39058
39100
  await defaultBootstrapper.initializeWebsockets(config.server, defaultRealtimeService, defaultDriver, config.auth, authAdapter);
39059
39101
  }
39060
39102
  logger.info("Rebase Backend Initialized");
@@ -39100,12 +39142,11 @@ async function _initializeRebaseBackend(config) {
39100
39142
  }
39101
39143
  for (const [key, rt] of Object.entries(realtimeServices)) {
39102
39144
  try {
39103
- const rtWithLifecycle = rt;
39104
- if (typeof rtWithLifecycle.destroy === "function") {
39105
- await rtWithLifecycle.destroy();
39145
+ if (typeof rt.destroy === "function") {
39146
+ await rt.destroy();
39106
39147
  logger.info(`Realtime service "${key}" destroyed`);
39107
- } else if (typeof rtWithLifecycle.stopListening === "function") {
39108
- await rtWithLifecycle.stopListening();
39148
+ } else if (typeof rt.stopListening === "function") {
39149
+ await rt.stopListening();
39109
39150
  logger.info(`Realtime service "${key}" LISTEN client stopped`);
39110
39151
  }
39111
39152
  } catch (err) {
@@ -51252,6 +51293,7 @@ export {
51252
51293
  isAuthAdapter,
51253
51294
  isDatabaseAdapter,
51254
51295
  isOperationAllowed,
51296
+ isRebaseApiError,
51255
51297
  isTransformableImage,
51256
51298
  listenWithPortRetry,
51257
51299
  loadCronJobsFromDirectory,