@rebasepro/server-core 0.2.3 → 0.2.5

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 (116) hide show
  1. package/dist/common/src/collections/default-collections.d.ts +9 -0
  2. package/dist/common/src/collections/index.d.ts +1 -0
  3. package/dist/common/src/util/permissions.d.ts +1 -0
  4. package/dist/{index-BZoAtuqi.js → index-Cr1D21av.js} +11 -3
  5. package/dist/index-Cr1D21av.js.map +1 -0
  6. package/dist/index.es.js +2309 -370
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/index.umd.js +2298 -355
  9. package/dist/index.umd.js.map +1 -1
  10. package/dist/server-core/src/api/logs-routes.d.ts +37 -0
  11. package/dist/server-core/src/api/rest/api-generator.d.ts +6 -0
  12. package/dist/server-core/src/api/types.d.ts +6 -1
  13. package/dist/server-core/src/auth/adapter-middleware.d.ts +7 -3
  14. package/dist/server-core/src/auth/admin-routes.d.ts +3 -3
  15. package/dist/server-core/src/auth/api-keys/api-key-middleware.d.ts +39 -0
  16. package/dist/server-core/src/auth/api-keys/api-key-permission-guard.d.ts +32 -0
  17. package/dist/server-core/src/auth/api-keys/api-key-routes.d.ts +20 -0
  18. package/dist/server-core/src/auth/api-keys/api-key-store.d.ts +35 -0
  19. package/dist/server-core/src/auth/api-keys/api-key-types.d.ts +88 -0
  20. package/dist/server-core/src/auth/api-keys/index.d.ts +17 -0
  21. package/dist/server-core/src/auth/{auth-overrides.d.ts → auth-hooks.d.ts} +69 -11
  22. package/dist/server-core/src/auth/builtin-auth-adapter.d.ts +3 -3
  23. package/dist/server-core/src/auth/index.d.ts +5 -3
  24. package/dist/server-core/src/auth/interfaces.d.ts +93 -3
  25. package/dist/server-core/src/auth/jwt.d.ts +3 -1
  26. package/dist/server-core/src/auth/mfa.d.ts +49 -0
  27. package/dist/server-core/src/auth/middleware.d.ts +7 -0
  28. package/dist/server-core/src/auth/rate-limiter.d.ts +19 -0
  29. package/dist/server-core/src/auth/routes.d.ts +3 -3
  30. package/dist/server-core/src/env.d.ts +6 -0
  31. package/dist/server-core/src/index.d.ts +1 -0
  32. package/dist/server-core/src/init.d.ts +3 -3
  33. package/dist/server-core/src/services/webhook-service.d.ts +29 -0
  34. package/dist/server-core/src/storage/image-transform.d.ts +48 -0
  35. package/dist/server-core/src/storage/index.d.ts +3 -0
  36. package/dist/server-core/src/storage/tus-handler.d.ts +51 -0
  37. package/dist/types/src/controllers/auth.d.ts +4 -26
  38. package/dist/types/src/controllers/client.d.ts +25 -43
  39. package/dist/types/src/controllers/collection_registry.d.ts +1 -1
  40. package/dist/types/src/controllers/data.d.ts +4 -0
  41. package/dist/types/src/controllers/data_driver.d.ts +23 -0
  42. package/dist/types/src/controllers/registry.d.ts +5 -4
  43. package/dist/types/src/rebase_context.d.ts +1 -1
  44. package/dist/types/src/types/auth_adapter.d.ts +5 -60
  45. package/dist/types/src/types/backend.d.ts +2 -2
  46. package/dist/types/src/types/backend_hooks.d.ts +2 -17
  47. package/dist/types/src/types/collections.d.ts +0 -4
  48. package/dist/types/src/types/component_ref.d.ts +1 -1
  49. package/dist/types/src/types/cron.d.ts +1 -1
  50. package/dist/types/src/types/entity_views.d.ts +1 -0
  51. package/dist/types/src/types/export_import.d.ts +1 -1
  52. package/dist/types/src/types/formex.d.ts +2 -2
  53. package/dist/types/src/types/properties.d.ts +9 -7
  54. package/dist/types/src/types/translations.d.ts +28 -12
  55. package/dist/types/src/types/user_management_delegate.d.ts +22 -57
  56. package/dist/types/src/users/index.d.ts +0 -1
  57. package/dist/types/src/users/user.d.ts +0 -1
  58. package/package.json +8 -6
  59. package/src/api/ast-schema-editor.ts +4 -4
  60. package/src/api/errors.ts +16 -7
  61. package/src/api/logs-routes.ts +129 -0
  62. package/src/api/rest/api-generator.ts +42 -2
  63. package/src/api/rest/query-parser.ts +37 -1
  64. package/src/api/types.ts +6 -1
  65. package/src/auth/adapter-middleware.ts +20 -4
  66. package/src/auth/admin-routes.ts +36 -100
  67. package/src/auth/api-keys/api-key-middleware.ts +126 -0
  68. package/src/auth/api-keys/api-key-permission-guard.ts +64 -0
  69. package/src/auth/api-keys/api-key-routes.ts +183 -0
  70. package/src/auth/api-keys/api-key-store.ts +317 -0
  71. package/src/auth/api-keys/api-key-types.ts +94 -0
  72. package/src/auth/api-keys/index.ts +37 -0
  73. package/src/auth/{auth-overrides.ts → auth-hooks.ts} +81 -14
  74. package/src/auth/builtin-auth-adapter.ts +35 -73
  75. package/src/auth/custom-auth-adapter.ts +1 -1
  76. package/src/auth/index.ts +7 -3
  77. package/src/auth/interfaces.ts +111 -3
  78. package/src/auth/jwt.ts +19 -5
  79. package/src/auth/mfa.ts +160 -0
  80. package/src/auth/middleware.ts +20 -1
  81. package/src/auth/rate-limiter.ts +92 -0
  82. package/src/auth/routes.ts +455 -24
  83. package/src/cron/cron-loader.ts +5 -10
  84. package/src/cron/cron-scheduler.ts +11 -12
  85. package/src/cron/cron-store.ts +8 -7
  86. package/src/env.ts +2 -0
  87. package/src/functions/function-loader.ts +6 -9
  88. package/src/index.ts +1 -2
  89. package/src/init.ts +56 -8
  90. package/src/serve-spa.ts +5 -4
  91. package/src/services/webhook-service.ts +155 -0
  92. package/src/storage/image-transform.ts +202 -0
  93. package/src/storage/index.ts +3 -0
  94. package/src/storage/routes.ts +56 -3
  95. package/src/storage/tus-handler.ts +315 -0
  96. package/src/utils/dev-port.ts +14 -0
  97. package/src/utils/logging.ts +9 -7
  98. package/test/admin-routes.test.ts +62 -164
  99. package/test/api-generator.test.ts +0 -1
  100. package/test/api-key-permission-guard.test.ts +132 -0
  101. package/test/ast-schema-editor.test.ts +26 -0
  102. package/test/auth-routes.test.ts +1 -2
  103. package/test/backend-hooks-admin.test.ts +3 -29
  104. package/test/custom-auth-adapter.test.ts +2 -10
  105. package/test/email-templates.test.ts +169 -0
  106. package/test/function-loader.test.ts +124 -0
  107. package/test/jwt.test.ts +4 -2
  108. package/test/mfa.test.ts +197 -0
  109. package/test/middleware.test.ts +10 -5
  110. package/test/webhook-service.test.ts +249 -0
  111. package/vite.config.ts +3 -2
  112. package/dist/index-BZoAtuqi.js.map +0 -1
  113. package/dist/server-core/src/bootstrappers/index.d.ts +0 -0
  114. package/dist/types/src/users/roles.d.ts +0 -22
  115. package/src/bootstrappers/index.ts +0 -1
  116. package/src/singleton.test.ts +0 -28
@@ -0,0 +1,129 @@
1
+ import { Hono } from "hono";
2
+ import type { MiddlewareHandler } from "hono";
3
+ import type { HonoEnv } from "./types";
4
+
5
+ export interface LogEntry {
6
+ id: string;
7
+ timestamp: string;
8
+ level: "debug" | "info" | "warn" | "error";
9
+ source: "api" | "auth" | "storage" | "realtime" | "system";
10
+ message: string;
11
+ metadata?: Record<string, unknown>;
12
+ }
13
+
14
+ class LogRingBuffer {
15
+ private buffer: LogEntry[] = [];
16
+ private maxSize: number;
17
+ private idCounter = 0;
18
+
19
+ constructor(maxSize = 10000) {
20
+ this.maxSize = maxSize;
21
+ }
22
+
23
+ push(entry: Omit<LogEntry, "id">): void {
24
+ const id = `log_${++this.idCounter}`;
25
+ this.buffer.push({ ...entry, id });
26
+ if (this.buffer.length > this.maxSize) {
27
+ this.buffer.shift();
28
+ }
29
+ }
30
+
31
+ query(options: {
32
+ level?: string;
33
+ source?: string;
34
+ search?: string;
35
+ limit?: number;
36
+ offset?: number;
37
+ since?: string;
38
+ }): { entries: LogEntry[]; total: number } {
39
+ let filtered = this.buffer;
40
+
41
+ if (options.level) {
42
+ filtered = filtered.filter(e => e.level === options.level);
43
+ }
44
+ if (options.source) {
45
+ filtered = filtered.filter(e => e.source === options.source);
46
+ }
47
+ if (options.search) {
48
+ const searchLower = options.search.toLowerCase();
49
+ filtered = filtered.filter(e => e.message.toLowerCase().includes(searchLower));
50
+ }
51
+ if (options.since) {
52
+ const sinceValue = options.since;
53
+ filtered = filtered.filter(e => e.timestamp >= sinceValue);
54
+ }
55
+
56
+ // Newest first
57
+ const sorted = [...filtered].reverse();
58
+ const total = sorted.length;
59
+ const limit = options.limit || 100;
60
+ const offset = options.offset || 0;
61
+
62
+ return {
63
+ entries: sorted.slice(offset, offset + limit),
64
+ total
65
+ };
66
+ }
67
+
68
+ getLatest(count = 50): LogEntry[] {
69
+ return this.buffer.slice(-count).reverse();
70
+ }
71
+ }
72
+
73
+ // Global singleton
74
+ export const logBuffer = new LogRingBuffer();
75
+
76
+ /** Add a log entry */
77
+ export function addLog(
78
+ level: LogEntry["level"],
79
+ source: LogEntry["source"],
80
+ message: string,
81
+ metadata?: Record<string, unknown>
82
+ ): void {
83
+ logBuffer.push({
84
+ timestamp: new Date().toISOString(),
85
+ level,
86
+ source,
87
+ message,
88
+ metadata
89
+ });
90
+ }
91
+
92
+ /** Hono middleware to log API requests */
93
+ export function logMiddleware(): MiddlewareHandler<HonoEnv> {
94
+ return async (c, next) => {
95
+ const start = Date.now();
96
+ await next();
97
+ const duration = Date.now() - start;
98
+ addLog("info", "api", `${c.req.method} ${c.req.path} ${c.res.status} ${duration}ms`, {
99
+ method: c.req.method,
100
+ path: c.req.path,
101
+ status: c.res.status,
102
+ duration
103
+ });
104
+ };
105
+ }
106
+
107
+ const app = new Hono<HonoEnv>();
108
+
109
+ // GET /api/logs — Query logs
110
+ app.get("/", (c) => {
111
+ const query = c.req.query();
112
+ const result = logBuffer.query({
113
+ level: query.level,
114
+ source: query.source,
115
+ search: query.search,
116
+ limit: query.limit ? parseInt(query.limit) : undefined,
117
+ offset: query.offset ? parseInt(query.offset) : undefined,
118
+ since: query.since
119
+ });
120
+ return c.json(result);
121
+ });
122
+
123
+ // GET /api/logs/latest — Get latest logs (for real-time)
124
+ app.get("/latest", (c) => {
125
+ const count = parseInt(c.req.query("count") || "50");
126
+ return c.json({ entries: logBuffer.getLatest(count) });
127
+ });
128
+
129
+ export default app;
@@ -3,6 +3,8 @@ import { DataDriver, Entity, EntityCollection, FetchCollectionProps, DataHooks,
3
3
  import { QueryOptions, HonoEnv } from "../types";
4
4
  import { ApiError } from "../errors";
5
5
  import { parseQueryOptions } from "./query-parser";
6
+ import { httpMethodToOperation, isOperationAllowed } from "../../auth/api-keys/api-key-permission-guard";
7
+ import type { ApiKeyMasked } from "../../auth/api-keys/api-key-types";
6
8
 
7
9
 
8
10
  /**
@@ -47,6 +49,27 @@ export class RestApiGenerator {
47
49
  return this.router;
48
50
  }
49
51
 
52
+ /**
53
+ * Check API key permissions for a collection operation.
54
+ * Throws 403 if the key doesn't have the required permission.
55
+ * No-ops if the request is not authenticated via an API key.
56
+ */
57
+ private enforceApiKeyPermission(
58
+ c: { get: (key: string) => unknown; req: { method: string } },
59
+ collectionSlug: string,
60
+ ): void {
61
+ const apiKey = c.get("apiKey") as ApiKeyMasked | undefined;
62
+ if (!apiKey) return; // Not an API key request — skip
63
+
64
+ const operation = httpMethodToOperation(c.req.method);
65
+ if (!isOperationAllowed(apiKey.permissions, collectionSlug, operation)) {
66
+ throw ApiError.forbidden(
67
+ `API key does not have "${operation}" permission for collection "${collectionSlug}"`,
68
+ "API_KEY_FORBIDDEN",
69
+ );
70
+ }
71
+ }
72
+
50
73
  /**
51
74
  * Get the typed RestFetchService from a driver if it exposes one (for include support).
52
75
  */
@@ -63,6 +86,7 @@ export class RestApiGenerator {
63
86
 
64
87
  // GET /collection/count - Count entities (with optional filters)
65
88
  this.router.get(`${basePath}/count`, async (c) => {
89
+ this.enforceApiKeyPermission(c, collection.slug);
66
90
  const queryDict = c.req.query();
67
91
  const queryOptions = parseQueryOptions(queryDict);
68
92
  const searchString = queryDict.searchString as string | undefined;
@@ -74,6 +98,7 @@ export class RestApiGenerator {
74
98
 
75
99
  // GET /collection - List entities
76
100
  this.router.get(basePath, async (c) => {
101
+ this.enforceApiKeyPermission(c, collection.slug);
77
102
  const queryDict = c.req.query();
78
103
  const queryOptions = parseQueryOptions(queryDict);
79
104
  const searchString = queryDict.searchString as string | undefined;
@@ -93,7 +118,8 @@ export class RestApiGenerator {
93
118
  offset: queryOptions.offset,
94
119
  orderBy: queryOptions.orderBy?.[0]?.field,
95
120
  order: queryOptions.orderBy?.[0]?.direction === "desc" ? "desc" : "asc",
96
- searchString
121
+ searchString,
122
+ vectorSearch: queryOptions.vectorSearch
97
123
  },
98
124
  queryOptions.include
99
125
  );
@@ -133,6 +159,7 @@ export class RestApiGenerator {
133
159
 
134
160
  // GET /collection/:id - Get single entity
135
161
  this.router.get(`${basePath}/:id`, async (c) => {
162
+ this.enforceApiKeyPermission(c, collection.slug);
136
163
  const id = c.req.param("id");
137
164
  const queryDict = c.req.query();
138
165
  const queryOptions = parseQueryOptions(queryDict);
@@ -179,6 +206,7 @@ export class RestApiGenerator {
179
206
  // POST /collection - Create entity
180
207
  this.router.post(basePath, async (c) => {
181
208
  try {
209
+ this.enforceApiKeyPermission(c, collection.slug);
182
210
  const driver = c.get("driver") || this.driver;
183
211
  const path = collection.slug;
184
212
  const hookCtx = this.buildHookContext(c, "POST");
@@ -215,6 +243,7 @@ export class RestApiGenerator {
215
243
  // PUT /collection/:id - Update entity
216
244
  this.router.put(`${basePath}/:id`, async (c) => {
217
245
  try {
246
+ this.enforceApiKeyPermission(c, collection.slug);
218
247
  const id = c.req.param("id");
219
248
  const driver = c.get("driver") || this.driver;
220
249
  const hookCtx = this.buildHookContext(c, "PUT");
@@ -261,6 +290,7 @@ export class RestApiGenerator {
261
290
 
262
291
  // DELETE /collection/:id - Delete entity
263
292
  this.router.delete(`${basePath}/:id`, async (c) => {
293
+ this.enforceApiKeyPermission(c, collection.slug);
264
294
  const id = c.req.param("id");
265
295
  const driver = c.get("driver") || this.driver;
266
296
  const hookCtx = this.buildHookContext(c, "DELETE");
@@ -354,6 +384,8 @@ entityId };
354
384
 
355
385
  const driver = c.get("driver") || this.driver;
356
386
 
387
+ this.enforceApiKeyPermission(c, c.req.param("parent"));
388
+
357
389
  if (parsed.entityId === "count") {
358
390
  // GET /parent/:parentId/child/count — count child entities
359
391
  const queryDict = c.req.query();
@@ -407,6 +439,8 @@ entityId };
407
439
  if (!parsed || parsed.entityId) return next();
408
440
 
409
441
  const driver = c.get("driver") || this.driver;
442
+
443
+ this.enforceApiKeyPermission(c, c.req.param("parent"));
410
444
  const body = await c.req.json().catch(() => ({}));
411
445
 
412
446
  const entity = await driver.saveEntity({
@@ -427,6 +461,9 @@ entityId };
427
461
  if (!parsed || !parsed.entityId) return next();
428
462
 
429
463
  const driver = c.get("driver") || this.driver;
464
+
465
+ this.enforceApiKeyPermission(c, c.req.param("parent"));
466
+
430
467
  const body = await c.req.json().catch(() => ({}));
431
468
 
432
469
  const entity = await driver.saveEntity({
@@ -449,6 +486,8 @@ entityId };
449
486
 
450
487
  const driver = c.get("driver") || this.driver;
451
488
 
489
+ this.enforceApiKeyPermission(c, c.req.param("parent"));
490
+
452
491
  const existingEntity = await driver.fetchEntity({
453
492
  path: parsed.collectionPath,
454
493
  entityId: parsed.entityId
@@ -520,7 +559,8 @@ entityId };
520
559
  orderBy: queryOptions.orderBy?.[0]?.field,
521
560
  order: queryOptions.orderBy?.[0]?.direction === "desc" ? "desc" : "asc",
522
561
  startAfter: queryOptions.offset ? String(queryOptions.offset) : undefined,
523
- searchString
562
+ searchString,
563
+ vectorSearch: queryOptions.vectorSearch
524
564
  });
525
565
 
526
566
  return entities.map(entity => this.flattenEntity(entity));
@@ -1,3 +1,4 @@
1
+ import type { VectorSearchParams } from "@rebasepro/types";
1
2
  import { QueryOptions } from "../types";
2
3
 
3
4
  /**
@@ -39,7 +40,7 @@ export function parseQueryOptions(query: Record<string, unknown>): QueryOptions
39
40
 
40
41
 
41
42
  // PostgREST-style filtering: ?field=op.value
42
- const reservedQueryKeys = ["limit", "offset", "page", "orderBy", "include", "fields", "searchString"];
43
+ const reservedQueryKeys = ["limit", "offset", "page", "orderBy", "include", "fields", "searchString", "vector_search", "vector", "vector_distance", "vector_threshold"];
43
44
  for (const [key, rawValue] of Object.entries(query)) {
44
45
  if (reservedQueryKeys.includes(key)) continue;
45
46
 
@@ -133,5 +134,40 @@ export function parseQueryOptions(query: Record<string, unknown>): QueryOptions
133
134
  options.fields = fieldsStr.split(",").map(s => s.trim()).filter(Boolean);
134
135
  }
135
136
 
137
+ // Vector similarity search
138
+ if (query.vector_search && query.vector) {
139
+ const vectorStr = String(query.vector);
140
+ let queryVector: number[];
141
+ try {
142
+ queryVector = JSON.parse(vectorStr) as number[];
143
+ if (!Array.isArray(queryVector) || !queryVector.every(v => typeof v === "number")) {
144
+ throw new Error("Expected array of numbers");
145
+ }
146
+ } catch {
147
+ throw new Error("Invalid vector format. Expected JSON array of numbers, e.g. [0.1,0.2,0.3]");
148
+ }
149
+
150
+ const distanceParam = query.vector_distance ? String(query.vector_distance) : "cosine";
151
+ if (distanceParam !== "cosine" && distanceParam !== "l2" && distanceParam !== "inner_product") {
152
+ throw new Error(`Invalid vector_distance: ${distanceParam}. Expected: cosine, l2, or inner_product`);
153
+ }
154
+
155
+ const vectorSearch: VectorSearchParams = {
156
+ property: String(query.vector_search),
157
+ vector: queryVector,
158
+ distance: distanceParam,
159
+ };
160
+
161
+ if (query.vector_threshold) {
162
+ const threshold = parseFloat(String(query.vector_threshold));
163
+ if (isNaN(threshold)) {
164
+ throw new Error("Invalid vector_threshold. Expected a number.");
165
+ }
166
+ vectorSearch.threshold = threshold;
167
+ }
168
+
169
+ options.vectorSearch = vectorSearch;
170
+ }
171
+
136
172
  return options;
137
173
  }
package/src/api/types.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { EntityCollection } from "@rebasepro/types";
1
+ import { EntityCollection, VectorSearchParams } from "@rebasepro/types";
2
2
  import { AuthResult } from "../auth/middleware";
3
3
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
4
4
  import { DataDriver } from "@rebasepro/types";
5
+ import type { ApiKeyMasked } from "../auth/api-keys/api-key-types";
5
6
 
6
7
  /**
7
8
  * Hono Environment Variables
@@ -11,6 +12,8 @@ export type HonoEnv = {
11
12
  Variables: {
12
13
  user?: AuthResult | { userId?: string, roles?: string[] };
13
14
  driver?: DataDriver;
15
+ /** Set when the request is authenticated via a Service API Key. */
16
+ apiKey?: ApiKeyMasked;
14
17
  }
15
18
  };
16
19
 
@@ -78,6 +81,8 @@ export interface QueryOptions {
78
81
  include?: string[];
79
82
  /** Columns to return in the response (field-level selection) */
80
83
  fields?: string[];
84
+ /** Vector similarity search configuration */
85
+ vectorSearch?: VectorSearchParams;
81
86
  }
82
87
 
83
88
  /**
@@ -6,9 +6,10 @@
6
6
  * an `AuthAdapter` to `initializeRebaseBackend()`.
7
7
  *
8
8
  * The middleware:
9
- * 1. Calls `adapter.verifyRequest(request)` to resolve the user
10
- * 2. Scopes the DataDriver via `withAuth()` for RLS
11
- * 3. Enforces auth (401) when `requireAuth` is true and no user is found
9
+ * 1. Checks for API key tokens (`rk_` prefix) first these are Rebase-level
10
+ * 2. Falls back to `adapter.verifyRequest(request)` to resolve the user
11
+ * 3. Scopes the DataDriver via `withAuth()` for RLS
12
+ * 4. Enforces auth (401) when `requireAuth` is true and no user is found
12
13
  *
13
14
  * The behavior is identical to `createAuthMiddleware()` — only the
14
15
  * token verification strategy is pluggable.
@@ -17,7 +18,9 @@
17
18
  import type { MiddlewareHandler } from "hono";
18
19
  import type { DataDriver, AuthAdapter } from "@rebasepro/types";
19
20
  import type { HonoEnv } from "../api/types";
21
+ import type { ApiKeyStore } from "./api-keys/api-key-store";
20
22
  import { scopeDataDriver } from "./rls-scope";
23
+ import { validateApiKey } from "./api-keys/api-key-middleware";
21
24
 
22
25
  export interface AdapterAuthMiddlewareOptions {
23
26
  /** The auth adapter to delegate verification to. */
@@ -29,15 +32,28 @@ export interface AdapterAuthMiddlewareOptions {
29
32
  * Defaults to `true` (secure by default).
30
33
  */
31
34
  requireAuth?: boolean;
35
+ /** Optional API key store — when provided, `rk_` bearer tokens are accepted. */
36
+ apiKeyStore?: ApiKeyStore;
32
37
  }
33
38
 
34
39
  /**
35
40
  * Create a Hono middleware that uses an `AuthAdapter` for request verification.
36
41
  */
37
42
  export function createAdapterAuthMiddleware(options: AdapterAuthMiddlewareOptions): MiddlewareHandler<HonoEnv> {
38
- const { adapter, driver, requireAuth: enforceAuth = true } = options;
43
+ const { adapter, driver, requireAuth: enforceAuth = true, apiKeyStore } = options;
39
44
 
40
45
  return async (c, next) => {
46
+ // ── API Key check (Rebase-level, independent of auth adapter) ────
47
+ if (apiKeyStore) {
48
+ const authHeader = c.req.header("authorization") || "";
49
+ const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
50
+ if (token.startsWith("rk_")) {
51
+ const result = await validateApiKey(c, token, { store: apiKeyStore, driver });
52
+ if (result === true) return next();
53
+ return result;
54
+ }
55
+ }
56
+
41
57
  let authenticatedUser = null;
42
58
 
43
59
  try {
@@ -2,10 +2,10 @@ import { Hono } from "hono";
2
2
  import { ApiError, errorHandler } from "../api/errors";
3
3
  import type { AuthRepository } from "./interfaces";
4
4
  import { requireAuth, requireAdmin, createRequireAuth } from "./middleware";
5
- import type { AuthOverrides } from "./auth-overrides";
6
- import { resolveAuthOverrides } from "./auth-overrides";
5
+ import type { AuthHooks } from "./auth-hooks";
6
+ import { resolveAuthHooks } from "./auth-hooks";
7
7
  import { AuthModuleConfig } from "./routes";
8
- import type { BackendHooks, AdminUser, AdminRole, BackendHookContext } from "@rebasepro/types";
8
+ import type { BackendHooks, AdminUser, BackendHookContext } from "@rebasepro/types";
9
9
 
10
10
  interface AdminRouteOptions extends AuthModuleConfig {
11
11
  serviceKey?: string;
@@ -19,10 +19,10 @@ interface AdminRouteOptions extends AuthModuleConfig {
19
19
  */
20
20
  hooks?: BackendHooks;
21
21
  /**
22
- * Auth overrides for customizing password hashing, credential
22
+ * Auth hooks for customizing password hashing, credential
23
23
  * verification, lifecycle hooks, etc.
24
24
  */
25
- overrides?: AuthOverrides;
25
+ authHooks?: AuthHooks;
26
26
  }
27
27
  import { HonoEnv } from "../api/types";
28
28
  import { randomBytes, createHash } from "crypto";
@@ -74,8 +74,8 @@ function hashToken(token: string): string {
74
74
  export function createAdminRoutes(config: AdminRouteOptions): Hono<HonoEnv> {
75
75
  const router = new Hono<HonoEnv>();
76
76
  const authRepo = config.authRepo;
77
- const { emailService, emailConfig, hooks, overrides } = config;
78
- const ops = resolveAuthOverrides(overrides);
77
+ const { emailService, emailConfig, hooks, authHooks } = config;
78
+ const ops = resolveAuthHooks(authHooks);
79
79
 
80
80
  /** Build a BackendHookContext from Hono's context object */
81
81
  function buildHookContext(c: { get: (key: string) => unknown }, method: BackendHookContext["method"]): BackendHookContext {
@@ -99,12 +99,7 @@ export function createAdminRoutes(config: AdminRouteOptions): Hono<HonoEnv> {
99
99
  return results.filter((u): u is AdminUser => u !== null);
100
100
  }
101
101
 
102
- /** Apply roles.afterRead hook to an array and filter nulls */
103
- async function applyRoleAfterReadBatch(roles: AdminRole[], ctx: BackendHookContext): Promise<AdminRole[]> {
104
- if (!hooks?.roles?.afterRead) return roles;
105
- const results = await Promise.all(roles.map(r => hooks!.roles!.afterRead!(r, ctx)));
106
- return results.filter((r): r is AdminRole => r !== null);
107
- }
102
+
108
103
 
109
104
  /** Convert a DB user record + role IDs into the AdminUser API shape */
110
105
  function toAdminUser(u: { id: string; email: string; displayName?: string | null; photoUrl?: string | null; createdAt?: Date | string; updatedAt?: Date | string }, roles: string[]): AdminUser {
@@ -174,6 +169,10 @@ export function createAdminRoutes(config: AdminRouteOptions): Hono<HonoEnv> {
174
169
  if (!userId) {
175
170
  throw ApiError.unauthorized("User ID not found in auth context");
176
171
  }
172
+ const caller = await authRepo.getUserById(userId);
173
+ if (!caller) {
174
+ throw ApiError.notFound("Authenticated user does not exist in the database. Please sign out and sign in/register again.", "USER_NOT_FOUND");
175
+ }
177
176
  await authRepo.setUserRoles(userId, ["admin"]);
178
177
 
179
178
  // ── Set persistent flag ──────────────────────────────────────
@@ -456,6 +455,19 @@ displayName: existing.displayName }, appName);
456
455
  }
457
456
 
458
457
  if (roles !== undefined && Array.isArray(roles)) {
458
+ const currentRoles = await authRepo.getUserRoleIds(userId);
459
+ const wasAdmin = currentRoles.includes("admin");
460
+ const willBeAdmin = roles.includes("admin");
461
+
462
+ if (wasAdmin && !willBeAdmin) {
463
+ const adminUsers = await authRepo.listUsersPaginated({
464
+ roleId: "admin",
465
+ limit: 1
466
+ });
467
+ if (adminUsers.total <= 1) {
468
+ throw ApiError.forbidden("Cannot demote the last administrator", "LAST_ADMIN");
469
+ }
470
+ }
459
471
  await authRepo.setUserRoles(userId, roles);
460
472
  }
461
473
 
@@ -487,6 +499,17 @@ displayName: existing.displayName }, appName);
487
499
  throw ApiError.notFound("User not found");
488
500
  }
489
501
 
502
+ const roles = await authRepo.getUserRoleIds(userId);
503
+ if (roles.includes("admin")) {
504
+ const adminUsers = await authRepo.listUsersPaginated({
505
+ roleId: "admin",
506
+ limit: 1
507
+ });
508
+ if (adminUsers.total <= 1) {
509
+ throw ApiError.forbidden("Cannot delete the last administrator", "LAST_ADMIN");
510
+ }
511
+ }
512
+
490
513
  // Apply beforeDelete hook (throw to abort)
491
514
  const hookCtx = buildHookContext(c, "DELETE");
492
515
  if (hooks?.users?.beforeDelete) {
@@ -505,94 +528,7 @@ displayName: existing.displayName }, appName);
505
528
  return c.json({ success: true });
506
529
  });
507
530
 
508
- router.get("/roles", requireAdmin, async (c) => {
509
- const roles = await authRepo.listRoles();
510
- const hookCtx = buildHookContext(c, "GET");
511
-
512
- let adminRoles: AdminRole[] = roles.map(r => ({
513
- id: r.id,
514
- name: r.name,
515
- isAdmin: r.isAdmin,
516
- defaultPermissions: r.defaultPermissions,
517
- config: r.config
518
- }));
519
-
520
- adminRoles = await applyRoleAfterReadBatch(adminRoles, hookCtx);
521
-
522
- return c.json({ roles: adminRoles });
523
- });
524
-
525
- router.get("/roles/:roleId", requireAdmin, async (c) => {
526
- const roleId = c.req.param("roleId");
527
- const role = await authRepo.getRoleById(roleId);
528
-
529
- if (!role) {
530
- throw ApiError.notFound("Role not found");
531
- }
532
-
533
- return c.json({ role });
534
- });
535
-
536
- router.post("/roles", requireAdmin, async (c) => {
537
- const body = await c.req.json();
538
- const { id, name, isAdmin, defaultPermissions, config } = body;
539
-
540
- if (!id || !name) {
541
- throw ApiError.badRequest("Role ID and name are required", "INVALID_INPUT");
542
- }
543
-
544
- const existing = await authRepo.getRoleById(id);
545
- if (existing) {
546
- throw ApiError.conflict("Role already exists", "ROLE_EXISTS");
547
- }
548
-
549
- const role = await authRepo.createRole({
550
- id,
551
- name,
552
- isAdmin: isAdmin ?? false,
553
- defaultPermissions: defaultPermissions ?? null,
554
- config: config ?? null
555
- });
556
-
557
- return c.json({ role }, 201);
558
- });
559
-
560
- router.put("/roles/:roleId", requireAdmin, async (c) => {
561
- const roleId = c.req.param("roleId");
562
- const body = await c.req.json();
563
- const { name, isAdmin, defaultPermissions, config } = body;
564
-
565
- const existing = await authRepo.getRoleById(roleId);
566
- if (!existing) {
567
- throw ApiError.notFound("Role not found");
568
- }
569
-
570
- const role = await authRepo.updateRole(roleId, {
571
- name,
572
- isAdmin,
573
- defaultPermissions,
574
- config
575
- });
576
-
577
- return c.json({ role });
578
- });
579
-
580
- router.delete("/roles/:roleId", requireAdmin, async (c) => {
581
- const roleId = c.req.param("roleId");
582
-
583
- if (["admin", "editor", "viewer"].includes(roleId)) {
584
- throw ApiError.badRequest("Cannot delete built-in roles", "BUILTIN_ROLE");
585
- }
586
-
587
- const existing = await authRepo.getRoleById(roleId);
588
- if (!existing) {
589
- throw ApiError.notFound("Role not found");
590
- }
591
-
592
- await authRepo.deleteRole(roleId);
593
531
 
594
- return c.json({ success: true });
595
- });
596
532
 
597
533
  return router;
598
534
  }