@rebasepro/server-core 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/package.json +14 -14
  2. package/src/api/errors.ts +16 -2
  3. package/src/api/graphql/graphql-schema-generator.ts +45 -10
  4. package/src/api/server.ts +10 -2
  5. package/src/api/types.ts +6 -0
  6. package/src/auth/adapter-middleware.ts +10 -2
  7. package/src/auth/admin-roles-route.ts +36 -0
  8. package/src/auth/admin-users-route.ts +302 -0
  9. package/src/auth/api-keys/api-key-middleware.ts +4 -3
  10. package/src/auth/api-keys/api-key-routes.ts +12 -2
  11. package/src/auth/api-keys/api-key-store.ts +83 -66
  12. package/src/auth/api-keys/api-key-types.ts +8 -0
  13. package/src/auth/apple-oauth.ts +2 -1
  14. package/src/auth/auth-hooks.ts +21 -0
  15. package/src/auth/bitbucket-oauth.ts +2 -1
  16. package/src/auth/builtin-auth-adapter.ts +28 -4
  17. package/src/auth/custom-auth-adapter.ts +2 -0
  18. package/src/auth/discord-oauth.ts +2 -1
  19. package/src/auth/facebook-oauth.ts +2 -1
  20. package/src/auth/github-oauth.ts +2 -1
  21. package/src/auth/gitlab-oauth.ts +2 -1
  22. package/src/auth/google-oauth.ts +8 -4
  23. package/src/auth/index.ts +2 -0
  24. package/src/auth/interfaces.ts +27 -0
  25. package/src/auth/linkedin-oauth.ts +2 -1
  26. package/src/auth/magic-link-routes.ts +167 -0
  27. package/src/auth/mfa-crypto.ts +91 -0
  28. package/src/auth/mfa-routes.ts +34 -10
  29. package/src/auth/microsoft-oauth.ts +2 -1
  30. package/src/auth/middleware.ts +10 -1
  31. package/src/auth/reset-password-admin.ts +17 -1
  32. package/src/auth/routes.ts +77 -8
  33. package/src/auth/session-routes.ts +15 -3
  34. package/src/auth/slack-oauth.ts +2 -1
  35. package/src/auth/spotify-oauth.ts +2 -1
  36. package/src/auth/twitter-oauth.ts +8 -1
  37. package/src/cron/cron-store.ts +25 -23
  38. package/src/email/index.ts +3 -2
  39. package/src/email/templates.ts +82 -0
  40. package/src/email/types.ts +16 -0
  41. package/src/init.ts +137 -5
  42. package/src/services/routed-realtime-service.ts +113 -0
  43. package/src/utils/dev-port.ts +13 -7
  44. package/test/auth-routes.test.ts +54 -4
  45. package/test/custom-auth-adapter.test.ts +20 -1
  46. package/test/env.test.ts +9 -19
  47. package/test/multi-datasource-routing.test.ts +113 -0
  48. package/test/routed-realtime-service.test.ts +86 -0
  49. package/test/transform-auth-response.test.ts +305 -0
  50. package/build-errors.txt +0 -52
  51. package/dist/api/ast-schema-editor.d.ts +0 -21
  52. package/dist/api/collections_for_test/callbacks_test_collection.d.ts +0 -2
  53. package/dist/api/errors.d.ts +0 -53
  54. package/dist/api/graphql/graphql-schema-generator.d.ts +0 -35
  55. package/dist/api/graphql/index.d.ts +0 -1
  56. package/dist/api/index.d.ts +0 -9
  57. package/dist/api/logs-routes.d.ts +0 -37
  58. package/dist/api/openapi-generator.d.ts +0 -16
  59. package/dist/api/rest/api-generator.d.ts +0 -88
  60. package/dist/api/rest/index.d.ts +0 -1
  61. package/dist/api/rest/query-parser.d.ts +0 -11
  62. package/dist/api/schema-editor-routes.d.ts +0 -3
  63. package/dist/api/server.d.ts +0 -40
  64. package/dist/api/types.d.ts +0 -98
  65. package/dist/auth/adapter-middleware.d.ts +0 -37
  66. package/dist/auth/admin-user-ops.d.ts +0 -79
  67. package/dist/auth/api-keys/api-key-middleware.d.ts +0 -39
  68. package/dist/auth/api-keys/api-key-permission-guard.d.ts +0 -32
  69. package/dist/auth/api-keys/api-key-routes.d.ts +0 -20
  70. package/dist/auth/api-keys/api-key-store.d.ts +0 -35
  71. package/dist/auth/api-keys/api-key-types.d.ts +0 -88
  72. package/dist/auth/api-keys/index.d.ts +0 -17
  73. package/dist/auth/apple-oauth.d.ts +0 -30
  74. package/dist/auth/auth-hooks.d.ts +0 -225
  75. package/dist/auth/bitbucket-oauth.d.ts +0 -11
  76. package/dist/auth/builtin-auth-adapter.d.ts +0 -49
  77. package/dist/auth/crypto-utils.d.ts +0 -16
  78. package/dist/auth/custom-auth-adapter.d.ts +0 -39
  79. package/dist/auth/discord-oauth.d.ts +0 -14
  80. package/dist/auth/facebook-oauth.d.ts +0 -14
  81. package/dist/auth/github-oauth.d.ts +0 -15
  82. package/dist/auth/gitlab-oauth.d.ts +0 -13
  83. package/dist/auth/google-oauth.d.ts +0 -47
  84. package/dist/auth/index.d.ts +0 -36
  85. package/dist/auth/interfaces.d.ts +0 -401
  86. package/dist/auth/jwt.d.ts +0 -55
  87. package/dist/auth/linkedin-oauth.d.ts +0 -18
  88. package/dist/auth/mfa-routes.d.ts +0 -6
  89. package/dist/auth/mfa.d.ts +0 -49
  90. package/dist/auth/microsoft-oauth.d.ts +0 -16
  91. package/dist/auth/middleware.d.ts +0 -106
  92. package/dist/auth/password.d.ts +0 -22
  93. package/dist/auth/rate-limiter.d.ts +0 -50
  94. package/dist/auth/reset-password-admin.d.ts +0 -29
  95. package/dist/auth/rls-scope.d.ts +0 -31
  96. package/dist/auth/routes.d.ts +0 -33
  97. package/dist/auth/session-routes.d.ts +0 -25
  98. package/dist/auth/slack-oauth.d.ts +0 -12
  99. package/dist/auth/spotify-oauth.d.ts +0 -12
  100. package/dist/auth/twitter-oauth.d.ts +0 -18
  101. package/dist/backend-CIxN4FVm.js +0 -15
  102. package/dist/backend-CIxN4FVm.js.map +0 -1
  103. package/dist/chunk-Dze3rakg.js +0 -42
  104. package/dist/collections/BackendCollectionRegistry.d.ts +0 -13
  105. package/dist/collections/loader.d.ts +0 -5
  106. package/dist/cron/cron-loader.d.ts +0 -17
  107. package/dist/cron/cron-routes.d.ts +0 -14
  108. package/dist/cron/cron-scheduler.d.ts +0 -106
  109. package/dist/cron/cron-store.d.ts +0 -32
  110. package/dist/cron/index.d.ts +0 -6
  111. package/dist/db/interfaces.d.ts +0 -18
  112. package/dist/dist-CZKP-Xz4.js +0 -832
  113. package/dist/dist-CZKP-Xz4.js.map +0 -1
  114. package/dist/email/index.d.ts +0 -6
  115. package/dist/email/smtp-email-service.d.ts +0 -25
  116. package/dist/email/templates.d.ts +0 -42
  117. package/dist/email/types.d.ts +0 -108
  118. package/dist/env.d.ts +0 -102
  119. package/dist/from-VbwD7xRf.js +0 -3849
  120. package/dist/from-VbwD7xRf.js.map +0 -1
  121. package/dist/functions/function-loader.d.ts +0 -17
  122. package/dist/functions/function-routes.d.ts +0 -10
  123. package/dist/functions/index.d.ts +0 -3
  124. package/dist/history/history-routes.d.ts +0 -23
  125. package/dist/history/index.d.ts +0 -1
  126. package/dist/index.d.ts +0 -33
  127. package/dist/index.es.js +0 -61731
  128. package/dist/index.es.js.map +0 -1
  129. package/dist/index.umd.js +0 -73221
  130. package/dist/index.umd.js.map +0 -1
  131. package/dist/init/docs.d.ts +0 -4
  132. package/dist/init/health.d.ts +0 -2
  133. package/dist/init/middlewares.d.ts +0 -10
  134. package/dist/init/shutdown.d.ts +0 -11
  135. package/dist/init/storage.d.ts +0 -5
  136. package/dist/init.d.ts +0 -258
  137. package/dist/jwt-DHcQRGC3.js +0 -4168
  138. package/dist/jwt-DHcQRGC3.js.map +0 -1
  139. package/dist/logger-BYU66ENZ.js +0 -94
  140. package/dist/logger-BYU66ENZ.js.map +0 -1
  141. package/dist/ms-BeBSuOXN.js +0 -125
  142. package/dist/ms-BeBSuOXN.js.map +0 -1
  143. package/dist/multipart-parser-CedBDOeC.js +0 -299
  144. package/dist/multipart-parser-CedBDOeC.js.map +0 -1
  145. package/dist/serve-spa.d.ts +0 -30
  146. package/dist/services/driver-registry.d.ts +0 -78
  147. package/dist/services/webhook-service.d.ts +0 -29
  148. package/dist/singleton.d.ts +0 -35
  149. package/dist/src-COaj0G3P.js +0 -1182
  150. package/dist/src-COaj0G3P.js.map +0 -1
  151. package/dist/storage/LocalStorageController.d.ts +0 -46
  152. package/dist/storage/S3StorageController.d.ts +0 -36
  153. package/dist/storage/image-transform.d.ts +0 -50
  154. package/dist/storage/index.d.ts +0 -28
  155. package/dist/storage/routes.d.ts +0 -38
  156. package/dist/storage/storage-registry.d.ts +0 -78
  157. package/dist/storage/tus-handler.d.ts +0 -51
  158. package/dist/storage/types.d.ts +0 -103
  159. package/dist/types/index.d.ts +0 -11
  160. package/dist/utils/dev-port.d.ts +0 -35
  161. package/dist/utils/logger.d.ts +0 -31
  162. package/dist/utils/logging.d.ts +0 -9
  163. package/dist/utils/request-id.d.ts +0 -4
  164. package/dist/utils/request-logger.d.ts +0 -19
  165. package/dist/utils/sql.d.ts +0 -27
@@ -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 });
@@ -45,6 +45,7 @@ import {
45
45
  import type { PasswordValidationResult } from "./password";
46
46
  import type { AuthRepository, UserData, CreateUserData } from "./interfaces";
47
47
  import type { EmailService, EmailConfig } from "../email";
48
+ import type { AuthResponsePayload, TransformAuthResponseContext } from "@rebasepro/types";
48
49
 
49
50
  /**
50
51
  * Authentication method identifier for lifecycle hooks.
@@ -181,6 +182,26 @@ export interface AuthHooks {
181
182
  */
182
183
  customizeAccessToken?(claims: Record<string, unknown>, user: UserData): Promise<Record<string, unknown>>;
183
184
 
185
+ /**
186
+ * Transform the auth response before sending it to the client.
187
+ *
188
+ * Called after successful login, register, refresh, OAuth, anonymous,
189
+ * magic-link, and MFA flows. The hook receives the fully-formed
190
+ * response and returns a (potentially enriched) response.
191
+ *
192
+ * Use cases:
193
+ * - Inject tokens from external auth systems (Firebase Custom Tokens, etc.)
194
+ * - Add project-specific metadata to the response
195
+ * - Enrich the user object with data from external sources
196
+ *
197
+ * The hook runs in the request path — keep it fast.
198
+ * Heavy work should be offloaded to `onAuthenticated` (fire-and-forget).
199
+ */
200
+ transformAuthResponse?(
201
+ response: AuthResponsePayload,
202
+ context: TransformAuthResponseContext
203
+ ): Promise<AuthResponsePayload>;
204
+
184
205
  /**
185
206
  * Called after a successful password reset.
186
207
  *
@@ -72,7 +72,8 @@ export function createBitbucketProvider(config: { clientId: string; clientSecret
72
72
  providerId: p.uuid,
73
73
  email,
74
74
  displayName: p.display_name || p.username || null,
75
- photoUrl: p.links?.avatar?.href || null
75
+ photoUrl: p.links?.avatar?.href || null,
76
+ emailVerified: true
76
77
  };
77
78
  } catch (error) {
78
79
  logger.error("Bitbucket OAuth error", { error: error });
@@ -22,11 +22,13 @@ import type {
22
22
  BootstrappedAuth
23
23
  } from "@rebasepro/types";
24
24
 
25
- import type { Hono } from "hono";
25
+ import { Hono } from "hono";
26
26
  import { verifyAccessToken } from "./jwt";
27
27
  import type { AccessTokenPayload } from "./jwt";
28
28
  import { createAuthRoutes } from "./routes";
29
29
  import { createResetPasswordRoute } from "./reset-password-admin";
30
+ import { createAdminRolesRoute } from "./admin-roles-route";
31
+ import { createAdminUsersRoute } from "./admin-users-route";
30
32
  import { prepareAdminUserValues, finalizeAdminUserCreation } from "./admin-user-ops";
31
33
  import type { AuthRepository, OAuthProvider } from "./interfaces";
32
34
  import type { AuthHooks, ResolvedAuthHooks } from "./auth-hooks";
@@ -62,6 +64,8 @@ export interface BuiltinAuthAdapterConfig {
62
64
  authHooks?: AuthHooks;
63
65
  /** The parsed auth config from the collection (if `auth` is an object, not just `true`). */
64
66
  collectionAuthConfig?: import("@rebasepro/types").AuthCollectionConfig;
67
+ /** Enable magic link (passwordless email) login. Requires email service. */
68
+ enableMagicLink?: boolean;
65
69
  }
66
70
 
67
71
  /**
@@ -81,7 +85,8 @@ export function createBuiltinAuthAdapter(config: BuiltinAuthAdapterConfig): Auth
81
85
  oauthProviders = [],
82
86
  serviceKey,
83
87
  authHooks,
84
- collectionAuthConfig
88
+ collectionAuthConfig,
89
+ enableMagicLink = false
85
90
  } = config;
86
91
 
87
92
  const resolvedOps = resolveAuthHooks(authHooks);
@@ -188,12 +193,26 @@ export function createBuiltinAuthAdapter(config: BuiltinAuthAdapterConfig): Auth
188
193
  allowRegistration,
189
194
  defaultRole,
190
195
  oauthProviders,
191
- authHooks
196
+ authHooks,
197
+ enableMagicLink
192
198
  });
193
199
  },
194
200
 
195
201
  createAdminRoutes(): Hono<HonoEnv> | undefined {
196
- return createResetPasswordRoute({
202
+ const router = new Hono<HonoEnv>();
203
+ const resetPasswordRoute = createResetPasswordRoute({
204
+ authRepo: authRepository,
205
+ emailService,
206
+ emailConfig,
207
+ serviceKey,
208
+ authHooks,
209
+ collectionAuthConfig
210
+ });
211
+ const rolesRoute = createAdminRolesRoute({
212
+ authRepo: authRepository,
213
+ serviceKey
214
+ });
215
+ const adminUsersRoute = createAdminUsersRoute({
197
216
  authRepo: authRepository,
198
217
  emailService,
199
218
  emailConfig,
@@ -201,6 +220,10 @@ export function createBuiltinAuthAdapter(config: BuiltinAuthAdapterConfig): Auth
201
220
  authHooks,
202
221
  collectionAuthConfig
203
222
  });
223
+ router.route("/", resetPasswordRoute);
224
+ router.route("/", rolesRoute);
225
+ router.route("/", adminUsersRoute);
226
+ return router;
204
227
  },
205
228
 
206
229
  async prepareUserCreation(values, collectionAuth) {
@@ -245,6 +268,7 @@ export function createBuiltinAuthAdapter(config: BuiltinAuthAdapterConfig): Auth
245
268
  sessionManagement: true,
246
269
  profileUpdate: true,
247
270
  emailVerification: !!emailService?.isConfigured(),
271
+ magicLink: enableMagicLink && !!emailService?.isConfigured(),
248
272
  enabledProviders,
249
273
  needsSetup
250
274
  };
@@ -51,6 +51,7 @@ export function createCustomAuthAdapter(options: CustomAuthAdapterOptions): Auth
51
51
  sessionManagement: false,
52
52
  profileUpdate: false,
53
53
  emailVerification: false,
54
+ magicLink: false,
54
55
  enabledProviders: [],
55
56
  ...options.capabilities
56
57
  };
@@ -76,6 +77,7 @@ export function createCustomAuthAdapter(options: CustomAuthAdapterOptions): Auth
76
77
 
77
78
  userManagement: options.userManagement,
78
79
 
80
+ transformAuthResponse: options.transformAuthResponse,
79
81
 
80
82
  getCapabilities() {
81
83
  return defaultCapabilities;
@@ -73,7 +73,8 @@ export function createDiscordProvider(config: { clientId: string; clientSecret:
73
73
  providerId: profileData.id,
74
74
  email: profileData.email,
75
75
  displayName: profileData.global_name || profileData.username || null,
76
- photoUrl
76
+ photoUrl,
77
+ emailVerified: profileData.verified === true
77
78
  };
78
79
  } catch (error) {
79
80
  logger.error("Discord OAuth error", { error: error });
@@ -62,7 +62,8 @@ export function createFacebookProvider(config: { clientId: string; clientSecret:
62
62
  providerId: profileData.id,
63
63
  email: profileData.email,
64
64
  displayName: profileData.name || null,
65
- photoUrl: profileData.picture?.data?.url || null
65
+ photoUrl: profileData.picture?.data?.url || null,
66
+ emailVerified: true
66
67
  };
67
68
  } catch (error) {
68
69
  logger.error("Facebook OAuth error", { error: error });
@@ -100,7 +100,8 @@ export function createGitHubProvider(config: { clientId: string; clientSecret: s
100
100
  providerId: String(profileData.id),
101
101
  email,
102
102
  displayName: profileData.name || profileData.login || null,
103
- photoUrl: profileData.avatar_url || null
103
+ photoUrl: profileData.avatar_url || null,
104
+ emailVerified: true
104
105
  };
105
106
  } catch (error) {
106
107
  logger.error("GitHub OAuth error", { error: error });
@@ -60,7 +60,8 @@ export function createGitLabProvider(config: {
60
60
  providerId: String(p.id),
61
61
  email: p.email,
62
62
  displayName: p.name || p.username || null,
63
- photoUrl: p.avatar_url || null
63
+ photoUrl: p.avatar_url || null,
64
+ emailVerified: true
64
65
  };
65
66
  } catch (error) {
66
67
  logger.error("GitLab OAuth error", { error: error });
@@ -89,7 +89,8 @@ export function createGoogleProvider(config: GoogleProviderConfig | string): OAu
89
89
  providerId: content.sub,
90
90
  email: content.email || "",
91
91
  displayName: content.name || null,
92
- photoUrl: content.picture || null
92
+ photoUrl: content.picture || null,
93
+ emailVerified: true
93
94
  };
94
95
  }
95
96
 
@@ -115,7 +116,8 @@ export function createGoogleProvider(config: GoogleProviderConfig | string): OAu
115
116
  providerId: info.sub,
116
117
  email: info.email,
117
118
  displayName: info.name || null,
118
- photoUrl: info.picture || null
119
+ photoUrl: info.picture || null,
120
+ emailVerified: true
119
121
  };
120
122
  }
121
123
 
@@ -176,7 +178,8 @@ export function createGoogleProvider(config: GoogleProviderConfig | string): OAu
176
178
  providerId: content.sub,
177
179
  email: content.email || "",
178
180
  displayName: content.name || null,
179
- photoUrl: content.picture || null
181
+ photoUrl: content.picture || null,
182
+ emailVerified: true
180
183
  };
181
184
  }
182
185
 
@@ -202,7 +205,8 @@ export function createGoogleProvider(config: GoogleProviderConfig | string): OAu
202
205
  providerId: info.sub,
203
206
  email: info.email,
204
207
  displayName: info.name || null,
205
- photoUrl: info.picture || null
208
+ photoUrl: info.picture || null,
209
+ emailVerified: true
206
210
  };
207
211
  }
208
212
 
package/src/auth/index.ts CHANGED
@@ -35,6 +35,8 @@ export type { AuthMiddlewareOptions, AuthResult } from "./middleware";
35
35
  export { createAuthRoutes } from "./routes";
36
36
  export type { AuthModuleConfig } from "./routes";
37
37
 
38
+ export { mountMagicLinkRoutes } from "./magic-link-routes";
39
+
38
40
  export { createResetPasswordRoute } from "./reset-password-admin";
39
41
  export type { ResetPasswordRouteConfig } from "./reset-password-admin";
40
42
 
@@ -60,6 +60,8 @@ export interface OAuthProviderProfile {
60
60
  email: string;
61
61
  displayName?: string | null;
62
62
  photoUrl?: string | null;
63
+ /** Whether the OAuth provider has verified the user's email address */
64
+ emailVerified?: boolean;
63
65
  }
64
66
 
65
67
  /**
@@ -129,6 +131,14 @@ export interface PasswordResetTokenInfo {
129
131
  expiresAt: Date;
130
132
  }
131
133
 
134
+ /**
135
+ * Magic link token info
136
+ */
137
+ export interface MagicLinkTokenInfo {
138
+ userId: string;
139
+ expiresAt: Date;
140
+ }
141
+
132
142
  // =============================================================================
133
143
  // AUTH REPOSITORY INTERFACES
134
144
  // =============================================================================
@@ -357,6 +367,23 @@ export interface TokenRepository {
357
367
  * Clean up expired tokens
358
368
  */
359
369
  deleteExpiredTokens(): Promise<void>;
370
+
371
+ // Magic link tokens
372
+
373
+ /**
374
+ * Create a magic link token
375
+ */
376
+ createMagicLinkToken(userId: string, tokenHash: string, expiresAt: Date): Promise<void>;
377
+
378
+ /**
379
+ * Find a valid (not expired, not used) magic link token by hash
380
+ */
381
+ findValidMagicLinkToken(tokenHash: string): Promise<MagicLinkTokenInfo | null>;
382
+
383
+ /**
384
+ * Mark a magic link token as used
385
+ */
386
+ markMagicLinkTokenUsed(tokenHash: string): Promise<void>;
360
387
  }
361
388
 
362
389
  // =============================================================================
@@ -71,7 +71,8 @@ export function createLinkedinProvider(config: { clientId: string, clientSecret:
71
71
  providerId: profileData.sub,
72
72
  email: profileData.email,
73
73
  displayName: profileData.name || null,
74
- photoUrl: profileData.picture || null
74
+ photoUrl: profileData.picture || null,
75
+ emailVerified: profileData.email_verified === true
75
76
  };
76
77
  } catch (error) {
77
78
  logger.error("LinkedIn OAuth error", { error: error });