@rebasepro/server-core 0.6.1 → 0.8.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 (116) hide show
  1. package/dist/api/graphql/graphql-schema-generator.d.ts +9 -0
  2. package/dist/api/rest/api-generator.d.ts +2 -14
  3. package/dist/api/rest/query-parser.d.ts +2 -5
  4. package/dist/api/types.d.ts +8 -2
  5. package/dist/auth/adapter-middleware.d.ts +7 -1
  6. package/dist/auth/admin-roles-route.d.ts +18 -0
  7. package/dist/auth/admin-users-route.d.ts +28 -0
  8. package/dist/auth/api-keys/api-key-types.d.ts +8 -0
  9. package/dist/auth/auth-hooks.d.ts +17 -0
  10. package/dist/auth/builtin-auth-adapter.d.ts +3 -1
  11. package/dist/auth/index.d.ts +1 -0
  12. package/dist/auth/interfaces.d.ts +32 -2
  13. package/dist/auth/magic-link-routes.d.ts +30 -0
  14. package/dist/auth/mfa-crypto.d.ts +23 -0
  15. package/dist/auth/mfa-routes.d.ts +2 -1
  16. package/dist/auth/middleware.d.ts +9 -2
  17. package/dist/auth/routes.d.ts +3 -1
  18. package/dist/auth/session-routes.d.ts +2 -0
  19. package/dist/backend-CIxN4FVm.js.map +1 -1
  20. package/dist/base64-js-C_frYBkI.js +1687 -0
  21. package/dist/base64-js-C_frYBkI.js.map +1 -0
  22. package/dist/{dist-CZKP-Xz4.js → dist-DMO-zF6D.js} +2 -2
  23. package/dist/{dist-CZKP-Xz4.js.map → dist-DMO-zF6D.js.map} +1 -1
  24. package/dist/email/index.d.ts +2 -2
  25. package/dist/email/templates.d.ts +8 -0
  26. package/dist/email/types.d.ts +18 -0
  27. package/dist/functions/define-function.d.ts +50 -0
  28. package/dist/functions/index.d.ts +2 -0
  29. package/dist/index.d.ts +16 -18
  30. package/dist/index.es.js +1947 -2356
  31. package/dist/index.es.js.map +1 -1
  32. package/dist/index.umd.js +40615 -10203
  33. package/dist/index.umd.js.map +1 -1
  34. package/dist/init.d.ts +68 -12
  35. package/dist/jws-BqRRaK11.js +616 -0
  36. package/dist/jws-BqRRaK11.js.map +1 -0
  37. package/dist/{jwt-DHcQRGC3.js → jwt-BETC8a1J.js} +3 -611
  38. package/dist/jwt-BETC8a1J.js.map +1 -0
  39. package/dist/services/routed-realtime-service.d.ts +43 -0
  40. package/dist/{src-COaj0G3P.js → src-CB2PIpBe.js} +2 -2
  41. package/dist/{src-COaj0G3P.js.map → src-CB2PIpBe.js.map} +1 -1
  42. package/dist/src-DjzOT1kG.js +29746 -0
  43. package/dist/src-DjzOT1kG.js.map +1 -0
  44. package/dist/storage/GCSStorageController.d.ts +43 -0
  45. package/dist/storage/index.d.ts +5 -2
  46. package/dist/storage/routes.d.ts +33 -1
  47. package/dist/storage/tus-handler.d.ts +3 -1
  48. package/dist/storage/types.d.ts +28 -3
  49. package/dist/utils/dev-port.d.ts +2 -0
  50. package/package.json +13 -5
  51. package/src/api/errors.ts +16 -2
  52. package/src/api/graphql/graphql-schema-generator.ts +50 -15
  53. package/src/api/openapi-generator.ts +2 -2
  54. package/src/api/rest/api-generator.ts +44 -123
  55. package/src/api/rest/query-parser.ts +6 -23
  56. package/src/api/server.ts +10 -2
  57. package/src/api/types.ts +8 -2
  58. package/src/auth/adapter-middleware.ts +10 -2
  59. package/src/auth/admin-roles-route.ts +36 -0
  60. package/src/auth/admin-users-route.ts +302 -0
  61. package/src/auth/api-keys/api-key-middleware.ts +4 -3
  62. package/src/auth/api-keys/api-key-routes.ts +12 -2
  63. package/src/auth/api-keys/api-key-store.ts +83 -66
  64. package/src/auth/api-keys/api-key-types.ts +8 -0
  65. package/src/auth/apple-oauth.ts +2 -1
  66. package/src/auth/auth-hooks.ts +21 -0
  67. package/src/auth/bitbucket-oauth.ts +2 -1
  68. package/src/auth/builtin-auth-adapter.ts +29 -6
  69. package/src/auth/custom-auth-adapter.ts +2 -0
  70. package/src/auth/discord-oauth.ts +2 -1
  71. package/src/auth/facebook-oauth.ts +2 -1
  72. package/src/auth/github-oauth.ts +2 -1
  73. package/src/auth/gitlab-oauth.ts +2 -1
  74. package/src/auth/google-oauth.ts +8 -4
  75. package/src/auth/index.ts +2 -0
  76. package/src/auth/interfaces.ts +38 -2
  77. package/src/auth/linkedin-oauth.ts +2 -1
  78. package/src/auth/magic-link-routes.ts +167 -0
  79. package/src/auth/mfa-crypto.ts +91 -0
  80. package/src/auth/mfa-routes.ts +34 -10
  81. package/src/auth/microsoft-oauth.ts +2 -1
  82. package/src/auth/middleware.ts +14 -5
  83. package/src/auth/reset-password-admin.ts +17 -1
  84. package/src/auth/routes.ts +78 -10
  85. package/src/auth/session-routes.ts +15 -3
  86. package/src/auth/slack-oauth.ts +2 -1
  87. package/src/auth/spotify-oauth.ts +2 -1
  88. package/src/auth/twitter-oauth.ts +8 -1
  89. package/src/cron/cron-store.ts +25 -23
  90. package/src/email/index.ts +3 -2
  91. package/src/email/templates.ts +82 -0
  92. package/src/email/types.ts +16 -0
  93. package/src/functions/define-function.ts +59 -0
  94. package/src/functions/function-loader.ts +16 -0
  95. package/src/functions/index.ts +2 -0
  96. package/src/index.ts +70 -37
  97. package/src/init.ts +214 -25
  98. package/src/services/routed-realtime-service.ts +113 -0
  99. package/src/storage/GCSStorageController.ts +334 -0
  100. package/src/storage/index.ts +9 -3
  101. package/src/storage/routes.ts +191 -23
  102. package/src/storage/tus-handler.ts +16 -4
  103. package/src/storage/types.ts +25 -3
  104. package/src/utils/dev-port.ts +13 -7
  105. package/test/api-generator.test.ts +1 -1
  106. package/test/auth-config-types.test.ts +40 -0
  107. package/test/auth-routes.test.ts +54 -4
  108. package/test/custom-auth-adapter.test.ts +20 -1
  109. package/test/define-function.test.ts +45 -0
  110. package/test/env.test.ts +9 -19
  111. package/test/multi-datasource-routing.test.ts +113 -0
  112. package/test/routed-realtime-service.test.ts +86 -0
  113. package/test/storage-routes.test.ts +160 -0
  114. package/test/transform-auth-response.test.ts +305 -0
  115. package/dist/jwt-DHcQRGC3.js.map +0 -1
  116. package/test/backend-hooks-data.test.ts +0 -477
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Standalone admin endpoint for user management.
3
+ *
4
+ * Mounts:
5
+ * GET /users
6
+ * GET /users/:userId
7
+ * POST /users
8
+ * PUT /users/:userId
9
+ * DELETE /users/:userId
10
+ * POST /bootstrap
11
+ */
12
+
13
+ import { Hono } from "hono";
14
+ import { ApiError, errorHandler } from "../api/errors";
15
+ import type { AuthRepository } from "./interfaces";
16
+ import { createRequireAuth, requireAdmin } from "./middleware";
17
+ import type { AuthHooks } from "./auth-hooks";
18
+ import { resolveAuthHooks } from "./auth-hooks";
19
+ import { prepareAdminUserValues, finalizeAdminUserCreation } from "./admin-user-ops";
20
+ import type { EmailService, EmailConfig } from "../email";
21
+ import type { HonoEnv } from "../api/types";
22
+ import type { AdminUser, AuthCollectionConfig } from "@rebasepro/types";
23
+ import { logger } from "../utils/logger";
24
+
25
+ export interface AdminUsersRouteConfig {
26
+ authRepo: AuthRepository;
27
+ emailService?: EmailService;
28
+ emailConfig?: EmailConfig;
29
+ serviceKey?: string;
30
+ authHooks?: AuthHooks;
31
+ collectionAuthConfig?: AuthCollectionConfig;
32
+ isBootstrapCompleted?: () => Promise<boolean>;
33
+ setBootstrapCompleted?: () => Promise<void>;
34
+ }
35
+
36
+ export function createAdminUsersRoute(config: AdminUsersRouteConfig): Hono<HonoEnv> {
37
+ const router = new Hono<HonoEnv>();
38
+ const authRepo = config.authRepo;
39
+ const { emailService, emailConfig, collectionAuthConfig } = config;
40
+ const ops = resolveAuthHooks(config.authHooks);
41
+
42
+ function toAdminUser(
43
+ u: {
44
+ id: string;
45
+ email: string;
46
+ displayName?: string | null;
47
+ photoUrl?: string | null;
48
+ createdAt?: Date | string;
49
+ updatedAt?: Date | string;
50
+ },
51
+ roles: string[]
52
+ ): AdminUser {
53
+ return {
54
+ uid: u.id,
55
+ email: u.email,
56
+ displayName: u.displayName ?? null,
57
+ photoURL: u.photoUrl ?? null,
58
+ provider: "custom",
59
+ roles,
60
+ createdAt: u.createdAt instanceof Date ? u.createdAt.toISOString() : (u.createdAt ?? new Date().toISOString()),
61
+ updatedAt: u.updatedAt instanceof Date ? u.updatedAt.toISOString() : (u.updatedAt ?? new Date().toISOString())
62
+ };
63
+ }
64
+
65
+ router.onError(errorHandler);
66
+ router.use("/*", createRequireAuth({ serviceKey: config.serviceKey }));
67
+
68
+ router.post("/bootstrap", async (c) => {
69
+ const user = c.get("user");
70
+ if (!user || typeof user !== "object") {
71
+ throw ApiError.unauthorized("Not authenticated");
72
+ }
73
+
74
+ if (config.isBootstrapCompleted) {
75
+ const alreadyDone = await config.isBootstrapCompleted();
76
+ if (alreadyDone) {
77
+ throw ApiError.forbidden("Bootstrap has already been completed.", "BOOTSTRAP_COMPLETED");
78
+ }
79
+ }
80
+
81
+ const users = await authRepo.listUsers();
82
+ let hasAdmin = false;
83
+
84
+ for (const u of users) {
85
+ const roles = await authRepo.getUserRoleIds(u.id);
86
+ if (roles.includes("admin")) {
87
+ hasAdmin = true;
88
+ break;
89
+ }
90
+ }
91
+
92
+ if (hasAdmin) {
93
+ throw ApiError.forbidden("Admin users already exist. Bootstrap not allowed.", "BOOTSTRAP_COMPLETED");
94
+ }
95
+
96
+ const userId = "userId" in user ? (user as { userId: string }).userId : ("uid" in user ? (user as { uid: string }).uid : undefined);
97
+ if (!userId) {
98
+ throw ApiError.unauthorized("User ID not found in auth context");
99
+ }
100
+ const caller = await authRepo.getUserById(userId);
101
+ if (!caller) {
102
+ throw ApiError.notFound("Authenticated user does not exist in the database.", "USER_NOT_FOUND");
103
+ }
104
+ await authRepo.setUserRoles(userId, ["admin"]);
105
+
106
+ if (config.setBootstrapCompleted) {
107
+ await config.setBootstrapCompleted();
108
+ }
109
+
110
+ return c.json({
111
+ success: true,
112
+ message: "You are now an admin",
113
+ user: {
114
+ uid: userId,
115
+ roles: ["admin"]
116
+ }
117
+ });
118
+ });
119
+
120
+ router.get("/users", requireAdmin, async (c) => {
121
+ const limitParam = c.req.query("limit");
122
+ const offsetParam = c.req.query("offset");
123
+ const search = c.req.query("search");
124
+ const orderBy = c.req.query("orderBy");
125
+ const orderDir = c.req.query("orderDir") as "asc" | "desc" | undefined;
126
+
127
+ const limit = limitParam ? parseInt(limitParam, 10) : 25;
128
+ const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
129
+
130
+ const result = await authRepo.listUsersPaginated({
131
+ limit,
132
+ offset,
133
+ search: search || undefined,
134
+ orderBy: orderBy || undefined,
135
+ orderDir: orderDir || undefined,
136
+ roleId: c.req.query("role") || undefined
137
+ });
138
+
139
+ const usersWithRoles = await Promise.all(
140
+ result.users.map(async (u) => {
141
+ const roles = await authRepo.getUserRoleIds(u.id);
142
+ return toAdminUser(u, roles);
143
+ })
144
+ );
145
+
146
+ return c.json({
147
+ users: usersWithRoles,
148
+ total: result.total,
149
+ limit: result.limit,
150
+ offset: result.offset
151
+ });
152
+ });
153
+
154
+ router.get("/users/:userId", requireAdmin, async (c) => {
155
+ const userId = c.req.param("userId");
156
+ const result = await authRepo.getUserWithRoles(userId);
157
+
158
+ if (!result) {
159
+ throw ApiError.notFound("User not found");
160
+ }
161
+
162
+ const adminUser = toAdminUser(result.user, result.roles.map((r) => r.id));
163
+ return c.json({ user: adminUser });
164
+ });
165
+
166
+ router.post("/users", requireAdmin, async (c) => {
167
+ const body = await c.req.json();
168
+ const { email, roles } = body;
169
+ if (!email) {
170
+ throw ApiError.badRequest("Email is required");
171
+ }
172
+
173
+ const existing = await authRepo.getUserByEmail(email.toLowerCase());
174
+ if (existing) {
175
+ throw ApiError.conflict("A user with this email already exists");
176
+ }
177
+
178
+ const prepResult = await prepareAdminUserValues(body, {
179
+ authRepo,
180
+ emailService,
181
+ emailConfig,
182
+ resolvedHooks: ops,
183
+ collectionAuthConfig
184
+ });
185
+
186
+ const user = await authRepo.createUser({
187
+ email: prepResult.values.email as string,
188
+ passwordHash: prepResult.values.passwordHash as string | undefined,
189
+ displayName: prepResult.values.displayName as string | undefined,
190
+ photoUrl: prepResult.values.photoUrl as string | undefined,
191
+ metadata: prepResult.values.metadata as Record<string, unknown> | undefined
192
+ });
193
+
194
+ if (roles && Array.isArray(roles)) {
195
+ await authRepo.setUserRoles(user.id, roles);
196
+ }
197
+
198
+ const finalizeResult = await finalizeAdminUserCreation(
199
+ { id: user.id, values: prepResult.values },
200
+ prepResult.clearPassword,
201
+ {
202
+ authRepo,
203
+ emailService,
204
+ emailConfig,
205
+ resolvedHooks: ops,
206
+ collectionAuthConfig
207
+ }
208
+ );
209
+
210
+ const userRoles = await authRepo.getUserRoleIds(user.id);
211
+ const adminUser = toAdminUser(user, userRoles);
212
+
213
+ return c.json(
214
+ {
215
+ user: adminUser,
216
+ invitationSent: finalizeResult.invitationSent,
217
+ ...(finalizeResult.temporaryPassword ? { temporaryPassword: finalizeResult.temporaryPassword } : {})
218
+ },
219
+ 201
220
+ );
221
+ });
222
+
223
+ router.put("/users/:userId", requireAdmin, async (c) => {
224
+ const userId = c.req.param("userId");
225
+ const body = await c.req.json();
226
+ const { password, email, displayName, roles } = body;
227
+
228
+ const existing = await authRepo.getUserById(userId);
229
+ if (!existing) {
230
+ throw ApiError.notFound("User not found");
231
+ }
232
+
233
+ const updates: Record<string, unknown> = {};
234
+ if (email !== undefined) updates.email = email.toLowerCase();
235
+ if (displayName !== undefined) updates.displayName = displayName;
236
+
237
+ if (password) {
238
+ const validation = ops.validatePasswordStrength(password);
239
+ if (!validation.valid) {
240
+ throw ApiError.badRequest(`Password too weak: ${validation.errors.join(". ")}`);
241
+ }
242
+ updates.passwordHash = await ops.hashPassword(password);
243
+ }
244
+
245
+ if (Object.keys(updates).length > 0) {
246
+ await authRepo.updateUser(userId, updates);
247
+ }
248
+
249
+ if (roles !== undefined && Array.isArray(roles)) {
250
+ const currentRoles = await authRepo.getUserRoleIds(userId);
251
+ const wasAdmin = currentRoles.includes("admin");
252
+ const willBeAdmin = roles.includes("admin");
253
+
254
+ if (wasAdmin && !willBeAdmin) {
255
+ const adminUsers = await authRepo.listUsersPaginated({
256
+ roleId: "admin",
257
+ limit: 1
258
+ });
259
+ if (adminUsers.total <= 1) {
260
+ throw ApiError.forbidden("Cannot demote the last administrator", "LAST_ADMIN");
261
+ }
262
+ }
263
+ await authRepo.setUserRoles(userId, roles);
264
+ }
265
+
266
+ const result = await authRepo.getUserWithRoles(userId);
267
+ const adminUser = toAdminUser(result!.user, result!.roles.map((r) => r.id));
268
+
269
+ return c.json({ user: adminUser });
270
+ });
271
+
272
+ router.delete("/users/:userId", requireAdmin, async (c) => {
273
+ const userId = c.req.param("userId");
274
+ const authUser = c.get("user") as { uid?: string; userId?: string } | undefined;
275
+ const currentUserId = authUser?.uid || authUser?.userId;
276
+
277
+ if (currentUserId === userId) {
278
+ throw ApiError.badRequest("Cannot delete your own account", "SELF_DELETE");
279
+ }
280
+
281
+ const existing = await authRepo.getUserById(userId);
282
+ if (!existing) {
283
+ throw ApiError.notFound("User not found");
284
+ }
285
+
286
+ const roles = await authRepo.getUserRoleIds(userId);
287
+ if (roles.includes("admin")) {
288
+ const adminUsers = await authRepo.listUsersPaginated({
289
+ roleId: "admin",
290
+ limit: 1
291
+ });
292
+ if (adminUsers.total <= 1) {
293
+ throw ApiError.forbidden("Cannot delete the last administrator", "LAST_ADMIN");
294
+ }
295
+ }
296
+
297
+ await authRepo.deleteUser(userId);
298
+ return c.json({ success: true });
299
+ });
300
+
301
+ return router;
302
+ }
@@ -88,8 +88,8 @@ code: "UNAUTHORIZED" }
88
88
 
89
89
  // Set user identity — API keys represent service accounts
90
90
  const userId = `api-key:${apiKey.id}`;
91
- c.set("user", { userId,
92
- roles: [] });
91
+ const roles: string[] = apiKey.admin ? ["admin", "service"] : ["service"];
92
+ c.set("user", { userId, roles });
93
93
 
94
94
  // Expose masked key metadata for downstream permission checks
95
95
  const masked: ApiKeyMasked = {
@@ -97,6 +97,7 @@ roles: [] });
97
97
  name: apiKey.name,
98
98
  key_prefix: apiKey.key_prefix,
99
99
  permissions: apiKey.permissions,
100
+ admin: apiKey.admin,
100
101
  rate_limit: apiKey.rate_limit,
101
102
  created_by: apiKey.created_by,
102
103
  created_at: apiKey.created_at,
@@ -112,7 +113,7 @@ roles: [] });
112
113
  try {
113
114
  const scopedDriver = await scopeDataDriver(driver, {
114
115
  uid: userId,
115
- roles: ["service"]
116
+ roles
116
117
  });
117
118
  c.set("driver", scopedDriver);
118
119
  } catch (error) {
@@ -59,7 +59,7 @@ export function createApiKeyRoutes(options: ApiKeyRouteOptions): Hono<HonoEnv> {
59
59
  // POST / — Create a new API key
60
60
  router.post("/", async (c) => {
61
61
  const body = await c.req.json() as Record<string, unknown>;
62
- const { name, permissions, rate_limit, expires_at } = body;
62
+ const { name, permissions, admin, rate_limit, expires_at } = body;
63
63
 
64
64
  if (!name || typeof name !== "string" || name.trim().length === 0) {
65
65
  throw ApiError.badRequest("Name is required", "INVALID_INPUT");
@@ -72,6 +72,10 @@ export function createApiKeyRoutes(options: ApiKeyRouteOptions): Hono<HonoEnv> {
72
72
  );
73
73
  }
74
74
 
75
+ if (admin !== undefined && typeof admin !== "boolean") {
76
+ throw ApiError.badRequest("admin must be a boolean", "INVALID_INPUT");
77
+ }
78
+
75
79
  if (rate_limit !== undefined && rate_limit !== null) {
76
80
  if (typeof rate_limit !== "number" || rate_limit < 1 || !Number.isInteger(rate_limit)) {
77
81
  throw ApiError.badRequest("rate_limit must be a positive integer or null", "INVALID_INPUT");
@@ -96,6 +100,7 @@ export function createApiKeyRoutes(options: ApiKeyRouteOptions): Hono<HonoEnv> {
96
100
  const request: CreateApiKeyRequest = {
97
101
  name: name.trim(),
98
102
  permissions: permissions as ApiKeyPermission[],
103
+ admin: (admin as boolean) ?? false,
99
104
  rate_limit: (rate_limit as number | null) ?? null,
100
105
  expires_at: (expires_at as string | null) ?? null
101
106
  };
@@ -121,7 +126,7 @@ export function createApiKeyRoutes(options: ApiKeyRouteOptions): Hono<HonoEnv> {
121
126
  router.put("/:id", async (c) => {
122
127
  const id = c.req.param("id");
123
128
  const body = await c.req.json() as Record<string, unknown>;
124
- const { name, permissions, rate_limit, expires_at } = body;
129
+ const { name, permissions, admin, rate_limit, expires_at } = body;
125
130
 
126
131
  // Validate fields if provided
127
132
  if (name !== undefined) {
@@ -139,6 +144,10 @@ export function createApiKeyRoutes(options: ApiKeyRouteOptions): Hono<HonoEnv> {
139
144
  }
140
145
  }
141
146
 
147
+ if (admin !== undefined && typeof admin !== "boolean") {
148
+ throw ApiError.badRequest("admin must be a boolean", "INVALID_INPUT");
149
+ }
150
+
142
151
  if (rate_limit !== undefined && rate_limit !== null) {
143
152
  if (typeof rate_limit !== "number" || rate_limit < 1 || !Number.isInteger(rate_limit)) {
144
153
  throw ApiError.badRequest("rate_limit must be a positive integer or null", "INVALID_INPUT");
@@ -155,6 +164,7 @@ export function createApiKeyRoutes(options: ApiKeyRouteOptions): Hono<HonoEnv> {
155
164
  const updates: UpdateApiKeyRequest = {};
156
165
  if (name !== undefined) updates.name = (name as string).trim();
157
166
  if (permissions !== undefined) updates.permissions = permissions as ApiKeyPermission[];
167
+ if (admin !== undefined) updates.admin = admin as boolean;
158
168
  if (rate_limit !== undefined) updates.rate_limit = rate_limit as number | null;
159
169
  if (expires_at !== undefined) updates.expires_at = expires_at as string | null;
160
170
 
@@ -59,6 +59,7 @@ function toMasked(row: ApiKey): ApiKeyMasked {
59
59
  name: row.name,
60
60
  key_prefix: row.key_prefix,
61
61
  permissions: row.permissions,
62
+ admin: row.admin,
62
63
  rate_limit: row.rate_limit,
63
64
  created_by: row.created_by,
64
65
  created_at: row.created_at,
@@ -88,6 +89,7 @@ function rowToApiKey(row: Record<string, unknown>): ApiKey {
88
89
  key_prefix: row.key_prefix as string,
89
90
  key_hash: row.key_hash as string,
90
91
  permissions,
92
+ admin: Boolean(row.admin),
91
93
  rate_limit: row.rate_limit !== null && row.rate_limit !== undefined
92
94
  ? Number(row.rate_limit)
93
95
  : null,
@@ -100,13 +102,6 @@ function rowToApiKey(row: Record<string, unknown>): ApiKey {
100
102
  };
101
103
  }
102
104
 
103
- /**
104
- * Escape a string for safe inline SQL usage.
105
- */
106
- function esc(value: string): string {
107
- return value.replace(/'/g, "''");
108
- }
109
-
110
105
  // ─── Public API ──────────────────────────────────────────────────────
111
106
 
112
107
  export interface ApiKeyStore {
@@ -147,7 +142,8 @@ export function createApiKeyStore(driver: DataDriver): ApiKeyStore | undefined {
147
142
  return undefined;
148
143
  }
149
144
 
150
- const exec = admin.executeSql.bind(admin);
145
+ const exec = (sqlText: string, options?: { params?: unknown[] }) =>
146
+ admin.executeSql(sqlText, options?.params ? { params: options.params } : undefined);
151
147
 
152
148
  return {
153
149
  // ── Schema bootstrap ────────────────────────────────────────
@@ -161,6 +157,7 @@ export function createApiKeyStore(driver: DataDriver): ApiKeyStore | undefined {
161
157
  key_prefix TEXT NOT NULL,
162
158
  key_hash TEXT NOT NULL UNIQUE,
163
159
  permissions JSONB NOT NULL DEFAULT '[]'::jsonb,
160
+ admin BOOLEAN NOT NULL DEFAULT FALSE,
164
161
  rate_limit INTEGER,
165
162
  created_by TEXT NOT NULL,
166
163
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@@ -181,6 +178,12 @@ export function createApiKeyStore(driver: DataDriver): ApiKeyStore | undefined {
181
178
  ON ${TABLE}(key_prefix)
182
179
  `);
183
180
 
181
+ // Migration: add admin column to existing tables
182
+ await exec(`
183
+ ALTER TABLE ${TABLE}
184
+ ADD COLUMN IF NOT EXISTS admin BOOLEAN NOT NULL DEFAULT FALSE
185
+ `);
186
+
184
187
  logger.info("✅ API keys table ready");
185
188
  } catch (err) {
186
189
  logger.error("❌ Failed to create API keys table", { error: err });
@@ -194,26 +197,22 @@ export function createApiKeyStore(driver: DataDriver): ApiKeyStore | undefined {
194
197
  const hash = hashKey(plaintext);
195
198
  const prefix = keyPrefix(plaintext);
196
199
  const permissionsJson = JSON.stringify(request.permissions);
197
- const rateLimitSql = request.rate_limit !== null && request.rate_limit !== undefined
198
- ? String(request.rate_limit)
199
- : "NULL";
200
- const expiresAtSql = request.expires_at
201
- ? `'${esc(request.expires_at)}'`
202
- : "NULL";
203
200
 
204
- const rows = await exec(`
205
- INSERT INTO ${TABLE} (name, key_prefix, key_hash, permissions, rate_limit, created_by, expires_at)
206
- VALUES (
207
- '${esc(request.name)}',
208
- '${esc(prefix)}',
209
- '${esc(hash)}',
210
- '${esc(permissionsJson)}'::jsonb,
211
- ${rateLimitSql},
212
- '${esc(createdBy)}',
213
- ${expiresAtSql}
214
- )
215
- RETURNING *
216
- `);
201
+ const rows = await exec(
202
+ `INSERT INTO ${TABLE} (name, key_prefix, key_hash, permissions, admin, rate_limit, created_by, expires_at)
203
+ VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7, $8)
204
+ RETURNING *`,
205
+ { params: [
206
+ request.name,
207
+ prefix,
208
+ hash,
209
+ permissionsJson,
210
+ request.admin ?? false,
211
+ request.rate_limit ?? null,
212
+ createdBy,
213
+ request.expires_at ?? null
214
+ ]}
215
+ );
217
216
 
218
217
  const apiKey = rowToApiKey(rows[0]);
219
218
  return {
@@ -224,11 +223,12 @@ export function createApiKeyStore(driver: DataDriver): ApiKeyStore | undefined {
224
223
 
225
224
  // ── Lookup by hash ──────────────────────────────────────────
226
225
  async findByKeyHash(hash: string): Promise<ApiKey | null> {
227
- const rows = await exec(`
228
- SELECT * FROM ${TABLE}
229
- WHERE key_hash = '${esc(hash)}'
230
- LIMIT 1
231
- `);
226
+ const rows = await exec(
227
+ `SELECT * FROM ${TABLE}
228
+ WHERE key_hash = $1
229
+ LIMIT 1`,
230
+ { params: [hash] }
231
+ );
232
232
  if (rows.length === 0) return null;
233
233
  return rowToApiKey(rows[0]);
234
234
  },
@@ -244,11 +244,12 @@ export function createApiKeyStore(driver: DataDriver): ApiKeyStore | undefined {
244
244
 
245
245
  // ── Get by ID (masked) ──────────────────────────────────────
246
246
  async getApiKeyById(id: string): Promise<ApiKeyMasked | null> {
247
- const rows = await exec(`
248
- SELECT * FROM ${TABLE}
249
- WHERE id = '${esc(id)}'
250
- LIMIT 1
251
- `);
247
+ const rows = await exec(
248
+ `SELECT * FROM ${TABLE}
249
+ WHERE id = $1
250
+ LIMIT 1`,
251
+ { params: [id] }
252
+ );
252
253
  if (rows.length === 0) return null;
253
254
  return toMasked(rowToApiKey(rows[0]));
254
255
  },
@@ -256,26 +257,36 @@ export function createApiKeyStore(driver: DataDriver): ApiKeyStore | undefined {
256
257
  // ── Update ──────────────────────────────────────────────────
257
258
  async updateApiKey(id: string, updates: UpdateApiKeyRequest): Promise<ApiKeyMasked | null> {
258
259
  const setClauses: string[] = [];
260
+ const params: unknown[] = [];
261
+ let paramIdx = 1;
259
262
 
260
263
  if (updates.name !== undefined) {
261
- setClauses.push(`name = '${esc(updates.name)}'`);
264
+ setClauses.push(`name = $${paramIdx++}`);
265
+ params.push(updates.name);
262
266
  }
263
267
  if (updates.permissions !== undefined) {
264
- setClauses.push(`permissions = '${esc(JSON.stringify(updates.permissions))}'::jsonb`);
268
+ setClauses.push(`permissions = $${paramIdx++}::jsonb`);
269
+ params.push(JSON.stringify(updates.permissions));
270
+ }
271
+ if (updates.admin !== undefined) {
272
+ setClauses.push(`admin = $${paramIdx++}`);
273
+ params.push(updates.admin);
265
274
  }
266
275
  if (updates.rate_limit !== undefined) {
267
- setClauses.push(
268
- updates.rate_limit !== null
269
- ? `rate_limit = ${updates.rate_limit}`
270
- : "rate_limit = NULL"
271
- );
276
+ if (updates.rate_limit !== null) {
277
+ setClauses.push(`rate_limit = $${paramIdx++}`);
278
+ params.push(updates.rate_limit);
279
+ } else {
280
+ setClauses.push("rate_limit = NULL");
281
+ }
272
282
  }
273
283
  if (updates.expires_at !== undefined) {
274
- setClauses.push(
275
- updates.expires_at !== null
276
- ? `expires_at = '${esc(updates.expires_at)}'`
277
- : "expires_at = NULL"
278
- );
284
+ if (updates.expires_at !== null) {
285
+ setClauses.push(`expires_at = $${paramIdx++}`);
286
+ params.push(updates.expires_at);
287
+ } else {
288
+ setClauses.push("expires_at = NULL");
289
+ }
279
290
  }
280
291
 
281
292
  if (setClauses.length === 0) {
@@ -284,12 +295,16 @@ export function createApiKeyStore(driver: DataDriver): ApiKeyStore | undefined {
284
295
 
285
296
  setClauses.push("updated_at = NOW()");
286
297
 
287
- const rows = await exec(`
288
- UPDATE ${TABLE}
289
- SET ${setClauses.join(", ")}
290
- WHERE id = '${esc(id)}'
291
- RETURNING *
292
- `);
298
+ // The WHERE id = $N uses the next available param index
299
+ params.push(id);
300
+
301
+ const rows = await exec(
302
+ `UPDATE ${TABLE}
303
+ SET ${setClauses.join(", ")}
304
+ WHERE id = $${paramIdx}
305
+ RETURNING *`,
306
+ { params }
307
+ );
293
308
 
294
309
  if (rows.length === 0) return null;
295
310
  return toMasked(rowToApiKey(rows[0]));
@@ -297,23 +312,25 @@ export function createApiKeyStore(driver: DataDriver): ApiKeyStore | undefined {
297
312
 
298
313
  // ── Revoke (soft-delete) ────────────────────────────────────
299
314
  async revokeApiKey(id: string): Promise<boolean> {
300
- const rows = await exec(`
301
- UPDATE ${TABLE}
302
- SET revoked_at = NOW(), updated_at = NOW()
303
- WHERE id = '${esc(id)}' AND revoked_at IS NULL
304
- RETURNING id
305
- `);
315
+ const rows = await exec(
316
+ `UPDATE ${TABLE}
317
+ SET revoked_at = NOW(), updated_at = NOW()
318
+ WHERE id = $1 AND revoked_at IS NULL
319
+ RETURNING id`,
320
+ { params: [id] }
321
+ );
306
322
  return rows.length > 0;
307
323
  },
308
324
 
309
325
  // ── Touch last_used_at ──────────────────────────────────────
310
326
  async updateLastUsed(id: string): Promise<void> {
311
327
  try {
312
- await exec(`
313
- UPDATE ${TABLE}
314
- SET last_used_at = NOW()
315
- WHERE id = '${esc(id)}'
316
- `);
328
+ await exec(
329
+ `UPDATE ${TABLE}
330
+ SET last_used_at = NOW()
331
+ WHERE id = $1`,
332
+ { params: [id] }
333
+ );
317
334
  } catch (err) {
318
335
  // Non-blocking — don't fail requests because of a usage timestamp update
319
336
  logger.error("[api-key-store] Failed to update last_used_at", { error: err });
@@ -32,6 +32,8 @@ export interface ApiKey {
32
32
  /** SHA-256 hash of the full plaintext key. */
33
33
  key_hash: string;
34
34
  permissions: ApiKeyPermission[];
35
+ /** When true, the key is granted the `admin` role (access to admin routes). */
36
+ admin: boolean;
35
37
  /** Requests per 15-minute window. `null` means unlimited. */
36
38
  rate_limit: number | null;
37
39
  created_by: string;
@@ -51,6 +53,8 @@ export interface ApiKeyMasked {
51
53
  name: string;
52
54
  key_prefix: string;
53
55
  permissions: ApiKeyPermission[];
56
+ /** When true, the key is granted the `admin` role (access to admin routes). */
57
+ admin: boolean;
54
58
  rate_limit: number | null;
55
59
  created_by: string;
56
60
  created_at: string;
@@ -66,6 +70,8 @@ export interface ApiKeyMasked {
66
70
  export interface CreateApiKeyRequest {
67
71
  name: string;
68
72
  permissions: ApiKeyPermission[];
73
+ /** When true, grants the `admin` role — allows access to admin routes. */
74
+ admin?: boolean;
69
75
  /** Requests per 15-minute window. Omit or `null` for unlimited. */
70
76
  rate_limit?: number | null;
71
77
  /** ISO-8601 expiration timestamp. Omit for no expiration. */
@@ -79,6 +85,8 @@ export interface CreateApiKeyRequest {
79
85
  export interface UpdateApiKeyRequest {
80
86
  name?: string;
81
87
  permissions?: ApiKeyPermission[];
88
+ /** When true, grants the `admin` role — allows access to admin routes. */
89
+ admin?: boolean;
82
90
  rate_limit?: number | null;
83
91
  expires_at?: string | null;
84
92
  }
@@ -110,7 +110,8 @@ export function createAppleProvider(config: {
110
110
  providerId: decoded.sub,
111
111
  email,
112
112
  displayName,
113
- photoUrl: null // Apple does not provide a profile photo
113
+ photoUrl: null, // Apple does not provide a profile photo
114
+ emailVerified: decoded.email_verified === true || decoded.email_verified === "true"
114
115
  };
115
116
  } catch (error) {
116
117
  logger.error("Apple OAuth error", { error: error });