@rebasepro/server-core 0.0.1-canary.eae7889 → 0.1.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 (132) hide show
  1. package/app/frontend/node_modules/esbuild/LICENSE.md +21 -0
  2. package/app/frontend/node_modules/esbuild/README.md +3 -0
  3. package/app/frontend/node_modules/esbuild/bin/esbuild +220 -0
  4. package/app/frontend/node_modules/esbuild/install.js +285 -0
  5. package/app/frontend/node_modules/esbuild/lib/main.d.ts +705 -0
  6. package/app/frontend/node_modules/esbuild/lib/main.js +2239 -0
  7. package/app/frontend/node_modules/esbuild/package.json +46 -0
  8. package/dist/index.es.js +1186 -1673
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/index.umd.js +1185 -1672
  11. package/dist/index.umd.js.map +1 -1
  12. package/dist/server-core/src/api/rest/api-generator.d.ts +15 -3
  13. package/dist/server-core/src/auth/admin-routes.d.ts +5 -0
  14. package/dist/server-core/src/auth/google-oauth.d.ts +36 -3
  15. package/dist/server-core/src/auth/index.d.ts +1 -0
  16. package/dist/server-core/src/cron/cron-scheduler.d.ts +45 -0
  17. package/dist/server-core/src/cron/index.d.ts +1 -1
  18. package/dist/server-core/src/init.d.ts +11 -1
  19. package/dist/types/src/controllers/auth.d.ts +8 -2
  20. package/dist/types/src/controllers/client.d.ts +13 -0
  21. package/dist/types/src/controllers/collection_registry.d.ts +2 -1
  22. package/dist/types/src/controllers/data_driver.d.ts +36 -1
  23. package/dist/types/src/controllers/navigation.d.ts +18 -6
  24. package/dist/types/src/controllers/registry.d.ts +9 -1
  25. package/dist/types/src/controllers/side_entity_controller.d.ts +7 -0
  26. package/dist/types/src/rebase_context.d.ts +17 -0
  27. package/dist/types/src/types/backend_hooks.d.ts +187 -0
  28. package/dist/types/src/types/collections.d.ts +31 -11
  29. package/dist/types/src/types/component_ref.d.ts +47 -0
  30. package/dist/types/src/types/cron.d.ts +1 -1
  31. package/dist/types/src/types/entity_views.d.ts +6 -7
  32. package/dist/types/src/types/formex.d.ts +40 -0
  33. package/dist/types/src/types/index.d.ts +3 -0
  34. package/dist/types/src/types/plugins.d.ts +6 -3
  35. package/dist/types/src/types/properties.d.ts +72 -88
  36. package/dist/types/src/types/slots.d.ts +20 -10
  37. package/dist/types/src/types/translations.d.ts +6 -0
  38. package/examples/firebase/node_modules/esbuild/LICENSE.md +21 -0
  39. package/examples/firebase/node_modules/esbuild/README.md +3 -0
  40. package/examples/firebase/node_modules/esbuild/bin/esbuild +220 -0
  41. package/examples/firebase/node_modules/esbuild/install.js +285 -0
  42. package/examples/firebase/node_modules/esbuild/lib/main.d.ts +705 -0
  43. package/examples/firebase/node_modules/esbuild/lib/main.js +2239 -0
  44. package/examples/firebase/node_modules/esbuild/package.json +46 -0
  45. package/examples/medmot-staging/frontend/node_modules/esbuild/LICENSE.md +21 -0
  46. package/examples/medmot-staging/frontend/node_modules/esbuild/README.md +3 -0
  47. package/examples/medmot-staging/frontend/node_modules/esbuild/bin/esbuild +220 -0
  48. package/examples/medmot-staging/frontend/node_modules/esbuild/install.js +285 -0
  49. package/examples/medmot-staging/frontend/node_modules/esbuild/lib/main.d.ts +705 -0
  50. package/examples/medmot-staging/frontend/node_modules/esbuild/lib/main.js +2239 -0
  51. package/examples/medmot-staging/frontend/node_modules/esbuild/package.json +46 -0
  52. package/examples/sdk-demo/node_modules/esbuild/LICENSE.md +21 -0
  53. package/examples/sdk-demo/node_modules/esbuild/README.md +3 -0
  54. package/examples/sdk-demo/node_modules/esbuild/bin/esbuild +223 -0
  55. package/examples/sdk-demo/node_modules/esbuild/install.js +289 -0
  56. package/examples/sdk-demo/node_modules/esbuild/lib/main.d.ts +716 -0
  57. package/examples/sdk-demo/node_modules/esbuild/lib/main.js +2242 -0
  58. package/examples/sdk-demo/node_modules/esbuild/package.json +49 -0
  59. package/package.json +9 -9
  60. package/packages/client/node_modules/esbuild/LICENSE.md +21 -0
  61. package/packages/client/node_modules/esbuild/README.md +3 -0
  62. package/packages/client/node_modules/esbuild/bin/esbuild +220 -0
  63. package/packages/client/node_modules/esbuild/install.js +285 -0
  64. package/packages/client/node_modules/esbuild/lib/main.d.ts +705 -0
  65. package/packages/client/node_modules/esbuild/lib/main.js +2239 -0
  66. package/packages/client/node_modules/esbuild/package.json +46 -0
  67. package/packages/client-postgresql/node_modules/esbuild/LICENSE.md +21 -0
  68. package/packages/client-postgresql/node_modules/esbuild/README.md +3 -0
  69. package/packages/client-postgresql/node_modules/esbuild/bin/esbuild +220 -0
  70. package/packages/client-postgresql/node_modules/esbuild/install.js +285 -0
  71. package/packages/client-postgresql/node_modules/esbuild/lib/main.d.ts +705 -0
  72. package/packages/client-postgresql/node_modules/esbuild/lib/main.js +2239 -0
  73. package/packages/client-postgresql/node_modules/esbuild/package.json +46 -0
  74. package/packages/common/node_modules/esbuild/LICENSE.md +21 -0
  75. package/packages/common/node_modules/esbuild/README.md +3 -0
  76. package/packages/common/node_modules/esbuild/bin/esbuild +220 -0
  77. package/packages/common/node_modules/esbuild/install.js +285 -0
  78. package/packages/common/node_modules/esbuild/lib/main.d.ts +705 -0
  79. package/packages/common/node_modules/esbuild/lib/main.js +2239 -0
  80. package/packages/common/node_modules/esbuild/package.json +46 -0
  81. package/packages/server-mongodb/node_modules/esbuild/LICENSE.md +21 -0
  82. package/packages/server-mongodb/node_modules/esbuild/README.md +3 -0
  83. package/packages/server-mongodb/node_modules/esbuild/bin/esbuild +220 -0
  84. package/packages/server-mongodb/node_modules/esbuild/install.js +285 -0
  85. package/packages/server-mongodb/node_modules/esbuild/lib/main.d.ts +705 -0
  86. package/packages/server-mongodb/node_modules/esbuild/lib/main.js +2239 -0
  87. package/packages/server-mongodb/node_modules/esbuild/package.json +46 -0
  88. package/packages/server-postgresql/node_modules/esbuild/LICENSE.md +21 -0
  89. package/packages/server-postgresql/node_modules/esbuild/README.md +3 -0
  90. package/packages/server-postgresql/node_modules/esbuild/bin/esbuild +220 -0
  91. package/packages/server-postgresql/node_modules/esbuild/install.js +285 -0
  92. package/packages/server-postgresql/node_modules/esbuild/lib/main.d.ts +705 -0
  93. package/packages/server-postgresql/node_modules/esbuild/lib/main.js +2239 -0
  94. package/packages/server-postgresql/node_modules/esbuild/package.json +46 -0
  95. package/packages/types/node_modules/esbuild/LICENSE.md +21 -0
  96. package/packages/types/node_modules/esbuild/README.md +3 -0
  97. package/packages/types/node_modules/esbuild/bin/esbuild +220 -0
  98. package/packages/types/node_modules/esbuild/install.js +285 -0
  99. package/packages/types/node_modules/esbuild/lib/main.d.ts +705 -0
  100. package/packages/types/node_modules/esbuild/lib/main.js +2239 -0
  101. package/packages/types/node_modules/esbuild/package.json +46 -0
  102. package/packages/utils/node_modules/esbuild/LICENSE.md +21 -0
  103. package/packages/utils/node_modules/esbuild/README.md +3 -0
  104. package/packages/utils/node_modules/esbuild/bin/esbuild +220 -0
  105. package/packages/utils/node_modules/esbuild/install.js +285 -0
  106. package/packages/utils/node_modules/esbuild/lib/main.d.ts +705 -0
  107. package/packages/utils/node_modules/esbuild/lib/main.js +2239 -0
  108. package/packages/utils/node_modules/esbuild/package.json +46 -0
  109. package/src/api/errors.ts +3 -2
  110. package/src/api/rest/api-generator-count.test.ts +113 -0
  111. package/src/api/rest/api-generator.ts +123 -22
  112. package/src/api/server.ts +8 -4
  113. package/src/auth/admin-routes.ts +133 -57
  114. package/src/auth/apple-oauth.ts +8 -18
  115. package/src/auth/google-oauth.ts +192 -22
  116. package/src/auth/index.ts +1 -0
  117. package/src/auth/rate-limiter.ts +9 -5
  118. package/src/auth/routes.ts +25 -5
  119. package/src/collections/loader.ts +3 -3
  120. package/src/cron/cron-scheduler.test.ts +301 -175
  121. package/src/cron/cron-scheduler.ts +220 -57
  122. package/src/cron/index.ts +1 -1
  123. package/src/init.ts +27 -5
  124. package/src/storage/LocalStorageController.ts +37 -13
  125. package/src/storage/S3StorageController.ts +4 -1
  126. package/src/storage/routes.ts +51 -5
  127. package/test/backend-hooks-admin.test.ts +394 -0
  128. package/test/backend-hooks-data.test.ts +408 -0
  129. package/history_diff.log +0 -385
  130. package/scratch.ts +0 -9
  131. package/test-ast.ts +0 -28
  132. package/test_output.txt +0 -1133
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "esbuild",
3
+ "version": "0.21.5",
4
+ "description": "An extremely fast JavaScript and CSS bundler and minifier.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/evanw/esbuild.git"
8
+ },
9
+ "scripts": {
10
+ "postinstall": "node install.js"
11
+ },
12
+ "main": "lib/main.js",
13
+ "types": "lib/main.d.ts",
14
+ "engines": {
15
+ "node": ">=12"
16
+ },
17
+ "bin": {
18
+ "esbuild": "bin/esbuild"
19
+ },
20
+ "optionalDependencies": {
21
+ "@esbuild/aix-ppc64": "0.21.5",
22
+ "@esbuild/android-arm": "0.21.5",
23
+ "@esbuild/android-arm64": "0.21.5",
24
+ "@esbuild/android-x64": "0.21.5",
25
+ "@esbuild/darwin-arm64": "0.21.5",
26
+ "@esbuild/darwin-x64": "0.21.5",
27
+ "@esbuild/freebsd-arm64": "0.21.5",
28
+ "@esbuild/freebsd-x64": "0.21.5",
29
+ "@esbuild/linux-arm": "0.21.5",
30
+ "@esbuild/linux-arm64": "0.21.5",
31
+ "@esbuild/linux-ia32": "0.21.5",
32
+ "@esbuild/linux-loong64": "0.21.5",
33
+ "@esbuild/linux-mips64el": "0.21.5",
34
+ "@esbuild/linux-ppc64": "0.21.5",
35
+ "@esbuild/linux-riscv64": "0.21.5",
36
+ "@esbuild/linux-s390x": "0.21.5",
37
+ "@esbuild/linux-x64": "0.21.5",
38
+ "@esbuild/netbsd-x64": "0.21.5",
39
+ "@esbuild/openbsd-x64": "0.21.5",
40
+ "@esbuild/sunos-x64": "0.21.5",
41
+ "@esbuild/win32-arm64": "0.21.5",
42
+ "@esbuild/win32-ia32": "0.21.5",
43
+ "@esbuild/win32-x64": "0.21.5"
44
+ },
45
+ "license": "MIT"
46
+ }
package/src/api/errors.ts CHANGED
@@ -120,10 +120,11 @@ export const errorHandler: ErrorHandler = (err, c) => {
120
120
  `❌ [API] ${c.req.method} ${c.req.path} → ${statusCode} ${code}: ${logMessage}`
121
121
  );
122
122
 
123
- // Suppress the huge stack trace for known missing schema errors (it's noisy and not a code bug)
123
+ // Suppress the huge stack trace for known DB errors (it's noisy and leaks SQL)
124
124
  const causePg = (error.cause && typeof error.cause === "object") ? (error.cause as PgLikeError) : undefined;
125
125
  const pgErrorCode = causePg?.code || error.code;
126
- if (pgErrorCode !== "42703" && pgErrorCode !== "42P01") {
126
+ const suppressStack = pgErrorCode === "42703" || pgErrorCode === "42P01" || (statusCode < 500 && code === "BAD_REQUEST");
127
+ if (!suppressStack) {
127
128
  console.error(error.stack || error);
128
129
  }
129
130
 
@@ -0,0 +1,113 @@
1
+ import { jest } from "@jest/globals";
2
+ import { RestApiGenerator } from "./api-generator";
3
+ import type { DataDriver, EntityCollection, FetchCollectionProps } from "@rebasepro/types";
4
+
5
+ /**
6
+ * Minimal mock DataDriver for testing.
7
+ */
8
+ function createMockDriver(overrides?: Partial<DataDriver>): DataDriver {
9
+ return {
10
+ fetchCollection: jest.fn().mockResolvedValue([]),
11
+ fetchEntity: jest.fn().mockResolvedValue(null),
12
+ saveEntity: jest.fn().mockResolvedValue({ id: "1", path: "test", values: {} }),
13
+ deleteEntity: jest.fn().mockResolvedValue(undefined),
14
+ countEntities: jest.fn().mockResolvedValue(0),
15
+ ...overrides,
16
+ } as unknown as DataDriver;
17
+ }
18
+
19
+ function createTestCollection(slug: string): EntityCollection {
20
+ return {
21
+ slug,
22
+ name: slug.charAt(0).toUpperCase() + slug.slice(1),
23
+ path: slug,
24
+ properties: {},
25
+ } as unknown as EntityCollection;
26
+ }
27
+
28
+ describe("RestApiGenerator - Count Endpoint", () => {
29
+ let driver: DataDriver;
30
+ let collection: EntityCollection;
31
+
32
+ beforeEach(() => {
33
+ driver = createMockDriver({
34
+ countEntities: jest.fn().mockResolvedValue(42),
35
+ });
36
+ collection = createTestCollection("products");
37
+ });
38
+
39
+ it("GET /products/count should return a count object", async () => {
40
+ const generator = new RestApiGenerator([collection], driver);
41
+ const app = generator.generateRoutes();
42
+
43
+ const res = await app.request("/products/count");
44
+ expect(res.status).toBe(200);
45
+
46
+ const json = await res.json() as { count: number };
47
+ expect(json.count).toBe(42);
48
+ });
49
+
50
+ it("should pass filters to countEntities driver", async () => {
51
+ const generator = new RestApiGenerator([collection], driver);
52
+ const app = generator.generateRoutes();
53
+
54
+ const res = await app.request("/products/count?status=eq.active");
55
+ expect(res.status).toBe(200);
56
+
57
+ const json = await res.json() as { count: number };
58
+ expect(json.count).toBe(42);
59
+
60
+ // Verify countEntities was called with the filter
61
+ expect(driver.countEntities).toHaveBeenCalled();
62
+ const callArgs = (driver.countEntities as ReturnType<typeof jest.fn>).mock.calls[0][0] as FetchCollectionProps;
63
+ expect(callArgs.path).toBe("products");
64
+ expect(callArgs.filter).toHaveProperty("status");
65
+ });
66
+
67
+ it("should pass searchString to countEntities driver", async () => {
68
+ const generator = new RestApiGenerator([collection], driver);
69
+ const app = generator.generateRoutes();
70
+
71
+ const res = await app.request("/products/count?searchString=widget");
72
+ expect(res.status).toBe(200);
73
+
74
+ const json = await res.json() as { count: number };
75
+ expect(json.count).toBe(42);
76
+
77
+ expect(driver.countEntities).toHaveBeenCalled();
78
+ const callArgs = (driver.countEntities as ReturnType<typeof jest.fn>).mock.calls[0][0] as FetchCollectionProps;
79
+ expect(callArgs.searchString).toBe("widget");
80
+ });
81
+
82
+ it("should return 0 when countEntities is not available on driver", async () => {
83
+ const driverWithoutCount = createMockDriver({ countEntities: undefined });
84
+ const generator = new RestApiGenerator([collection], driverWithoutCount);
85
+ const app = generator.generateRoutes();
86
+
87
+ const res = await app.request("/products/count");
88
+ expect(res.status).toBe(200);
89
+
90
+ const json = await res.json() as { count: number };
91
+ expect(json.count).toBe(0);
92
+ });
93
+
94
+ it("GET /products/count should not be confused with GET /products/:id", async () => {
95
+ // Ensure the count route is registered before the :id route
96
+ const fetchEntity = jest.fn().mockResolvedValue(null);
97
+ const driverCustom = createMockDriver({
98
+ countEntities: jest.fn().mockResolvedValue(99),
99
+ fetchEntity,
100
+ });
101
+ const generator = new RestApiGenerator([collection], driverCustom);
102
+ const app = generator.generateRoutes();
103
+
104
+ const res = await app.request("/products/count");
105
+ expect(res.status).toBe(200);
106
+
107
+ const json = await res.json() as { count: number };
108
+ expect(json.count).toBe(99);
109
+
110
+ // fetchEntity should NOT have been called (i.e. "count" was not treated as an entity ID)
111
+ expect(fetchEntity).not.toHaveBeenCalled();
112
+ });
113
+ });
@@ -1,5 +1,5 @@
1
1
  import { Hono } from "hono";
2
- import { DataDriver, Entity, EntityCollection, FetchCollectionProps } from "@rebasepro/types";
2
+ import { DataDriver, Entity, EntityCollection, FetchCollectionProps, DataHooks, BackendHookContext, RestFetchService } from "@rebasepro/types";
3
3
  import { QueryOptions, HonoEnv } from "../types";
4
4
  import { ApiError } from "../errors";
5
5
  import { parseQueryOptions } from "./query-parser";
@@ -13,13 +13,24 @@ export class RestApiGenerator {
13
13
  private collections: EntityCollection[];
14
14
  private router: Hono<HonoEnv>;
15
15
  private driver: DataDriver;
16
+ private dataHooks?: DataHooks;
16
17
 
17
- constructor(collections: EntityCollection[], driver: DataDriver) {
18
+ constructor(collections: EntityCollection[], driver: DataDriver, dataHooks?: DataHooks) {
18
19
  this.collections = collections;
19
20
  this.driver = driver;
21
+ this.dataHooks = dataHooks;
20
22
  this.router = new Hono<HonoEnv>();
21
23
  }
22
24
 
25
+ /** Build a BackendHookContext from a Hono context */
26
+ private buildHookContext(c: { get: (key: string) => unknown }, method: BackendHookContext["method"]): BackendHookContext {
27
+ const user = c.get("user") as { userId: string; roles?: string[] } | undefined;
28
+ return {
29
+ requestUser: user ? { userId: user.userId, roles: user.roles ?? [] } : undefined,
30
+ method
31
+ };
32
+ }
33
+
23
34
  /**
24
35
  * Generate REST routes using existing DataDriver
25
36
  */
@@ -37,16 +48,10 @@ export class RestApiGenerator {
37
48
  }
38
49
 
39
50
  /**
40
- * Get the EntityFetchService from a driver if it exposes one (for include support)
51
+ * Get the typed RestFetchService from a driver if it exposes one (for include support).
41
52
  */
42
- private getFetchService(driver: DataDriver): Record<string, (...args: unknown[]) => unknown> | null {
43
- if ("entityService" in driver && typeof driver.entityService === "object" && driver.entityService) {
44
- const es = driver.entityService as Record<string, unknown>;
45
- if (typeof es.getFetchService === "function") {
46
- return es.getFetchService();
47
- }
48
- }
49
- return null;
53
+ private getFetchService(driver: DataDriver): RestFetchService | undefined {
54
+ return driver.restFetchService;
50
55
  }
51
56
 
52
57
  /**
@@ -56,6 +61,17 @@ export class RestApiGenerator {
56
61
  const basePath = `/${collection.slug}`;
57
62
  const resolvedCollection = collection;
58
63
 
64
+ // GET /collection/count - Count entities (with optional filters)
65
+ this.router.get(`${basePath}/count`, async (c) => {
66
+ const queryDict = c.req.query();
67
+ const queryOptions = parseQueryOptions(queryDict);
68
+ const searchString = queryDict.searchString as string | undefined;
69
+ const driver = c.get("driver") || this.driver;
70
+
71
+ const total = await this.countRawEntities(driver, resolvedCollection, queryOptions, searchString);
72
+ return c.json({ count: total });
73
+ });
74
+
59
75
  // GET /collection - List entities
60
76
  this.router.get(basePath, async (c) => {
61
77
  const queryDict = c.req.query();
@@ -64,11 +80,12 @@ export class RestApiGenerator {
64
80
 
65
81
  const driver = c.get("driver") || this.driver;
66
82
  const fetchService = this.getFetchService(driver);
83
+ const hookCtx = this.buildHookContext(c, "GET");
67
84
 
68
85
  // Use include-aware path when available
69
86
  if (fetchService) {
70
87
  const collectionPath = collection.slug;
71
- const entities = await fetchService.fetchCollectionForRest(
88
+ let entities = await fetchService.fetchCollectionForRest(
72
89
  collectionPath,
73
90
  {
74
91
  filter: queryOptions.where as FetchCollectionProps["filter"],
@@ -81,6 +98,8 @@ export class RestApiGenerator {
81
98
  queryOptions.include
82
99
  );
83
100
 
101
+ entities = await this.applyAfterReadBatch(collection.slug, entities, hookCtx);
102
+
84
103
  const total = await this.countRawEntities(driver, resolvedCollection, queryOptions, searchString);
85
104
 
86
105
  return c.json({
@@ -89,13 +108,15 @@ export class RestApiGenerator {
89
108
  total,
90
109
  limit: queryOptions.limit,
91
110
  offset: queryOptions.offset,
92
- hasMore: (queryOptions.offset || 0) + (entities as unknown[]).length < total
111
+ hasMore: (queryOptions.offset || 0) + entities.length < total
93
112
  }
94
113
  });
95
114
  }
96
115
 
97
116
  // Fallback path
98
- const entities = await this.fetchRawCollection(driver, resolvedCollection, queryOptions, searchString);
117
+ let entities = await this.fetchRawCollection(driver, resolvedCollection, queryOptions, searchString);
118
+
119
+ entities = await this.applyAfterReadBatch(collection.slug, entities, hookCtx);
99
120
 
100
121
  const total = await this.countRawEntities(driver, resolvedCollection, queryOptions, searchString);
101
122
 
@@ -105,7 +126,7 @@ export class RestApiGenerator {
105
126
  total,
106
127
  limit: queryOptions.limit,
107
128
  offset: queryOptions.offset,
108
- hasMore: (queryOptions.offset || 0) + (entities as unknown[]).length < total
129
+ hasMore: (queryOptions.offset || 0) + entities.length < total
109
130
  }
110
131
  });
111
132
  });
@@ -117,11 +138,12 @@ export class RestApiGenerator {
117
138
  const queryOptions = parseQueryOptions(queryDict);
118
139
  const driver = c.get("driver") || this.driver;
119
140
  const fetchService = this.getFetchService(driver);
141
+ const hookCtx = this.buildHookContext(c, "GET");
120
142
 
121
143
  // Use include-aware path when available
122
144
  if (fetchService) {
123
145
  const collectionPath = collection.slug;
124
- const entity = await fetchService.fetchEntityForRest(
146
+ let entity = await fetchService.fetchEntityForRest(
125
147
  collectionPath,
126
148
  String(id),
127
149
  queryOptions.include
@@ -131,16 +153,26 @@ export class RestApiGenerator {
131
153
  throw ApiError.notFound("Entity not found");
132
154
  }
133
155
 
156
+ entity = await this.applyAfterRead(collection.slug, entity, hookCtx);
157
+ if (!entity) {
158
+ throw ApiError.notFound("Entity not found");
159
+ }
160
+
134
161
  return c.json(entity);
135
162
  }
136
163
 
137
164
  // Fallback
138
- const entity = await this.fetchRawEntity(driver, resolvedCollection, String(id));
165
+ let entity = await this.fetchRawEntity(driver, resolvedCollection, String(id));
139
166
 
140
167
  if (!entity) {
141
168
  throw ApiError.notFound("Entity not found");
142
169
  }
143
170
 
171
+ entity = await this.applyAfterRead(collection.slug, entity, hookCtx);
172
+ if (!entity) {
173
+ throw ApiError.notFound("Entity not found");
174
+ }
175
+
144
176
  return c.json(entity);
145
177
  });
146
178
 
@@ -149,8 +181,13 @@ export class RestApiGenerator {
149
181
  try {
150
182
  const driver = c.get("driver") || this.driver;
151
183
  const path = collection.slug;
184
+ const hookCtx = this.buildHookContext(c, "POST");
152
185
 
153
- const body = await c.req.json().catch(() => ({}));
186
+ let body = await c.req.json().catch(() => ({}));
187
+
188
+ if (this.dataHooks?.beforeSave) {
189
+ body = await this.dataHooks.beforeSave(path, body, undefined, hookCtx);
190
+ }
154
191
 
155
192
  const entity = await driver.saveEntity({
156
193
  path,
@@ -159,7 +196,15 @@ export class RestApiGenerator {
159
196
  status: "new"
160
197
  });
161
198
 
162
- return c.json(this.formatResponse(entity), 201);
199
+ const response = this.formatResponse(entity);
200
+
201
+ if (this.dataHooks?.afterSave) {
202
+ Promise.resolve(this.dataHooks.afterSave(path, response as Record<string, unknown>, hookCtx)).catch(err => {
203
+ console.error("[BackendHooks] data.afterSave error:", err instanceof Error ? err.message : err);
204
+ });
205
+ }
206
+
207
+ return c.json(response, 201);
163
208
  } catch (error) {
164
209
  const err = error as Error & { code?: string };
165
210
  err.code = err.code || "BAD_REQUEST";
@@ -172,6 +217,7 @@ export class RestApiGenerator {
172
217
  try {
173
218
  const id = c.req.param("id");
174
219
  const driver = c.get("driver") || this.driver;
220
+ const hookCtx = this.buildHookContext(c, "PUT");
175
221
 
176
222
  const existingEntity = await driver.fetchEntity({
177
223
  path: collection.slug,
@@ -183,7 +229,11 @@ export class RestApiGenerator {
183
229
  throw ApiError.notFound("Entity not found");
184
230
  }
185
231
 
186
- const body = await c.req.json().catch(() => ({}));
232
+ let body = await c.req.json().catch(() => ({}));
233
+
234
+ if (this.dataHooks?.beforeSave) {
235
+ body = await this.dataHooks.beforeSave(collection.slug, body, String(id), hookCtx);
236
+ }
187
237
 
188
238
  const entity = await driver.saveEntity({
189
239
  path: collection.slug,
@@ -193,7 +243,15 @@ export class RestApiGenerator {
193
243
  status: "existing"
194
244
  });
195
245
 
196
- return c.json(this.formatResponse(entity));
246
+ const response = this.formatResponse(entity);
247
+
248
+ if (this.dataHooks?.afterSave) {
249
+ Promise.resolve(this.dataHooks.afterSave(collection.slug, response as Record<string, unknown>, hookCtx)).catch(err => {
250
+ console.error("[BackendHooks] data.afterSave error:", err instanceof Error ? err.message : err);
251
+ });
252
+ }
253
+
254
+ return c.json(response);
197
255
  } catch (error) {
198
256
  const err = error as Error & { code?: string };
199
257
  err.code = err.code || "BAD_REQUEST";
@@ -205,6 +263,7 @@ export class RestApiGenerator {
205
263
  this.router.delete(`${basePath}/:id`, async (c) => {
206
264
  const id = c.req.param("id");
207
265
  const driver = c.get("driver") || this.driver;
266
+ const hookCtx = this.buildHookContext(c, "DELETE");
208
267
 
209
268
  const existingEntity = await driver.fetchEntity({
210
269
  path: collection.slug,
@@ -216,11 +275,21 @@ export class RestApiGenerator {
216
275
  throw ApiError.notFound("Entity not found");
217
276
  }
218
277
 
278
+ if (this.dataHooks?.beforeDelete) {
279
+ await this.dataHooks.beforeDelete(collection.slug, String(id), hookCtx);
280
+ }
281
+
219
282
  await driver.deleteEntity({
220
283
  entity: existingEntity,
221
284
  collection: resolvedCollection
222
285
  });
223
286
 
287
+ if (this.dataHooks?.afterDelete) {
288
+ Promise.resolve(this.dataHooks.afterDelete(collection.slug, String(id), hookCtx)).catch(err => {
289
+ console.error("[BackendHooks] data.afterDelete error:", err instanceof Error ? err.message : err);
290
+ });
291
+ }
292
+
224
293
  return new Response(null, { status: 204 });
225
294
  });
226
295
  }
@@ -285,7 +354,19 @@ entityId };
285
354
 
286
355
  const driver = c.get("driver") || this.driver;
287
356
 
288
- if (parsed.entityId) {
357
+ if (parsed.entityId === "count") {
358
+ // GET /parent/:parentId/child/count — count child entities
359
+ const queryDict = c.req.query();
360
+ const queryOptions = parseQueryOptions(queryDict);
361
+
362
+ const total = driver.countEntities ? await driver.countEntities({
363
+ path: parsed.collectionPath,
364
+ filter: queryOptions.where as FetchCollectionProps["filter"],
365
+ searchString: queryDict.searchString as string | undefined
366
+ }) : 0;
367
+
368
+ return c.json({ count: total });
369
+ } else if (parsed.entityId) {
289
370
  // GET /parent/:parentId/child/:id — single entity
290
371
  const entity = await driver.fetchEntity({
291
372
  path: parsed.collectionPath,
@@ -469,4 +550,24 @@ entityId };
469
550
 
470
551
  return entity ? this.flattenEntity(entity) : null;
471
552
  }
553
+
554
+ /**
555
+ * Apply data.afterRead hook to a single entity.
556
+ * Returns the transformed entity, or null to filter it out.
557
+ */
558
+ private async applyAfterRead(slug: string, entity: Record<string, unknown>, ctx: BackendHookContext): Promise<Record<string, unknown> | null> {
559
+ if (!this.dataHooks?.afterRead) return entity;
560
+ return this.dataHooks.afterRead(slug, entity, ctx);
561
+ }
562
+
563
+ /**
564
+ * Apply data.afterRead hook to an array of entities, filtering out nulls.
565
+ */
566
+ private async applyAfterReadBatch(slug: string, entities: Record<string, unknown>[], ctx: BackendHookContext): Promise<Record<string, unknown>[]> {
567
+ if (!this.dataHooks?.afterRead) return entities;
568
+ const results = await Promise.all(
569
+ entities.map(e => this.applyAfterRead(slug, e, ctx))
570
+ );
571
+ return results.filter((e): e is Record<string, unknown> => e !== null);
572
+ }
472
573
  }
package/src/api/server.ts CHANGED
@@ -69,8 +69,11 @@ export class RebaseApiServer {
69
69
  * Setup Hono middleware
70
70
  */
71
71
  private setupMiddleware(): void {
72
- // Security headers
73
- this.router.use("/*", secureHeaders());
72
+ // Security headers — use same-origin-allow-popups for COOP so that
73
+ // OAuth popup flows (Google, etc.) can postMessage back to the opener.
74
+ this.router.use("/*", secureHeaders({
75
+ crossOriginOpenerPolicy: "same-origin-allow-popups"
76
+ }));
74
77
 
75
78
  // CORS — only applied if explicitly configured via `cors` option.
76
79
  // If omitted, the user is expected to configure CORS on their own
@@ -178,10 +181,11 @@ export class RebaseApiServer {
178
181
  if (process.env.NODE_ENV === "production") {
179
182
  console.warn("[RebaseApiServer] Schema Editor is disabled in production environments for security.");
180
183
  } else {
181
- const schemaEditorRoutes = createSchemaEditorRoutes(this.config.collectionsDir);
182
- this.router.route(`${basePath}/schema-editor`, schemaEditorRoutes);
183
184
  // Auth middlewares applied to schema-editor via the router prefix
185
+ // MUST be declared before .route() so they execute first
184
186
  this.router.use(`${basePath}/schema-editor/*`, requireAuth, requireAdmin);
187
+ const schemaEditorRoutes = createSchemaEditorRoutes(this.config.collectionsDir);
188
+ this.router.route(`${basePath}/schema-editor`, schemaEditorRoutes);
185
189
  }
186
190
  }
187
191