@rebasepro/server-core 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +62 -25
  2. package/dist/common/src/collections/default-collections.d.ts +5 -8
  3. package/dist/common/src/data/query_builder.d.ts +6 -2
  4. package/dist/common/src/util/permissions.d.ts +14 -6
  5. package/dist/index.es.js +393 -315
  6. package/dist/index.es.js.map +1 -1
  7. package/dist/index.umd.js +393 -315
  8. package/dist/index.umd.js.map +1 -1
  9. package/dist/server-core/src/api/errors.d.ts +15 -0
  10. package/dist/server-core/src/api/rest/query-parser.d.ts +2 -0
  11. package/dist/server-core/src/api/types.d.ts +2 -1
  12. package/dist/server-core/src/auth/jwt.d.ts +10 -0
  13. package/dist/server-core/src/email/types.d.ts +1 -0
  14. package/dist/server-core/src/init.d.ts +31 -1
  15. package/dist/types/src/controllers/auth.d.ts +2 -2
  16. package/dist/types/src/controllers/client.d.ts +25 -40
  17. package/dist/types/src/controllers/data.d.ts +21 -3
  18. package/dist/types/src/controllers/data_driver.d.ts +5 -0
  19. package/dist/types/src/controllers/email.d.ts +2 -0
  20. package/dist/types/src/types/auth_adapter.d.ts +3 -56
  21. package/dist/types/src/types/backend.d.ts +38 -3
  22. package/dist/types/src/types/backend_hooks.d.ts +2 -17
  23. package/dist/types/src/types/collections.d.ts +30 -6
  24. package/dist/types/src/types/entity_views.d.ts +19 -28
  25. package/dist/types/src/types/properties.d.ts +9 -15
  26. package/dist/types/src/types/user_management_delegate.d.ts +16 -53
  27. package/dist/types/src/users/index.d.ts +0 -1
  28. package/dist/types/src/users/user.d.ts +0 -1
  29. package/package.json +5 -5
  30. package/src/api/errors.ts +20 -1
  31. package/src/api/openapi-generator.ts +1 -1
  32. package/src/api/rest/api-generator.ts +82 -24
  33. package/src/api/rest/query-parser.ts +184 -63
  34. package/src/api/server.ts +1 -1
  35. package/src/api/types.ts +2 -1
  36. package/src/auth/admin-routes.ts +2 -91
  37. package/src/auth/builtin-auth-adapter.ts +9 -70
  38. package/src/auth/custom-auth-adapter.ts +1 -1
  39. package/src/auth/jwt.ts +10 -0
  40. package/src/auth/routes.ts +5 -9
  41. package/src/email/smtp-email-service.ts +31 -0
  42. package/src/email/types.ts +1 -0
  43. package/src/init.ts +135 -31
  44. package/src/storage/image-transform.ts +2 -1
  45. package/test/admin-routes.test.ts +0 -169
  46. package/test/backend-hooks-admin.test.ts +0 -25
  47. package/test/custom-auth-adapter.test.ts +2 -10
  48. package/test/smtp-email-service.test.ts +169 -0
  49. package/dist/types/src/users/roles.d.ts +0 -14
  50. package/test.ts +0 -6
@@ -1,4 +1,4 @@
1
- import type { VectorSearchParams } from "@rebasepro/types";
1
+ import type { VectorSearchParams, LogicalCondition, FilterCondition } from "@rebasepro/types";
2
2
  import { QueryOptions } from "../types";
3
3
 
4
4
  /**
@@ -20,6 +20,86 @@ export function mapOperator(op: string): string | null {
20
20
  }
21
21
  }
22
22
 
23
+ function getLastValue(val: unknown): unknown {
24
+ if (Array.isArray(val)) {
25
+ return val[val.length - 1];
26
+ }
27
+ return val;
28
+ }
29
+
30
+ export function parseLogicalString(str: string): FilterCondition | LogicalCondition {
31
+ str = str.trim();
32
+ if (str.startsWith("or(") && str.endsWith(")")) {
33
+ const inner = str.slice(3, -1);
34
+ return { type: "or", conditions: parseLogicalList(inner) };
35
+ }
36
+ if (str.startsWith("and(") && str.endsWith(")")) {
37
+ const inner = str.slice(4, -1);
38
+ return { type: "and", conditions: parseLogicalList(inner) };
39
+ }
40
+
41
+ // It's a leaf condition: field.op.val
42
+ const firstDot = str.indexOf(".");
43
+ if (firstDot === -1) {
44
+ return { column: str, operator: "==", value: true };
45
+ }
46
+ const field = str.substring(0, firstDot);
47
+ const rest = str.substring(firstDot + 1);
48
+ const secondDot = rest.indexOf(".");
49
+ let op = "eq";
50
+ let valStr = rest;
51
+ if (secondDot !== -1) {
52
+ op = rest.substring(0, secondDot);
53
+ valStr = rest.substring(secondDot + 1);
54
+ } else {
55
+ op = "eq";
56
+ valStr = rest;
57
+ }
58
+
59
+ const rebaseOp = (mapOperator(op) || "==") as FilterCondition["operator"];
60
+ let parsedVal: unknown = valStr;
61
+ if (valStr === "true") parsedVal = true;
62
+ else if (valStr === "false") parsedVal = false;
63
+ else if (valStr === "null") parsedVal = null;
64
+ else if (!isNaN(Number(valStr)) && valStr.trim() !== "") parsedVal = Number(valStr);
65
+ else if (valStr.startsWith("(")) {
66
+ const arrayContent = valStr.endsWith(")") ? valStr.slice(1, -1) : valStr.slice(1);
67
+ parsedVal = arrayContent.split(",").map(v => {
68
+ const trimmed = v.trim();
69
+ if (!isNaN(Number(trimmed)) && trimmed !== "") return Number(trimmed);
70
+ if (trimmed === "true") return true;
71
+ if (trimmed === "false") return false;
72
+ if (trimmed === "null") return null;
73
+ return trimmed;
74
+ });
75
+ }
76
+
77
+ return { column: field, operator: rebaseOp, value: parsedVal };
78
+ }
79
+
80
+ function parseLogicalList(str: string): (FilterCondition | LogicalCondition)[] {
81
+ const list: (FilterCondition | LogicalCondition)[] = [];
82
+ let depth = 0;
83
+ let current = "";
84
+
85
+ for (let i = 0; i < str.length; i++) {
86
+ const char = str[i];
87
+ if (char === "(") depth++;
88
+ if (char === ")") depth--;
89
+
90
+ if (char === "," && depth === 0) {
91
+ list.push(parseLogicalString(current));
92
+ current = "";
93
+ } else {
94
+ current += char;
95
+ }
96
+ }
97
+ if (current) {
98
+ list.push(parseLogicalString(current));
99
+ }
100
+ return list;
101
+ }
102
+
23
103
  /**
24
104
  * Parse query parameters into QueryOptions
25
105
  */
@@ -27,10 +107,15 @@ export function parseQueryOptions(query: Record<string, unknown>): QueryOptions
27
107
  const options: QueryOptions = {};
28
108
 
29
109
  // Pagination
30
- if (query.limit) options.limit = parseInt(String(query.limit));
31
- if (query.offset) options.offset = parseInt(String(query.offset));
32
- if (query.page) {
33
- const page = parseInt(String(query.page));
110
+ const limitVal = getLastValue(query.limit);
111
+ if (limitVal) options.limit = parseInt(String(limitVal));
112
+
113
+ const offsetVal = getLastValue(query.offset);
114
+ if (offsetVal) options.offset = parseInt(String(offsetVal));
115
+
116
+ const pageVal = getLastValue(query.page);
117
+ if (pageVal) {
118
+ const page = parseInt(String(pageVal));
34
119
  const limit = options.limit || 20;
35
120
  options.offset = (page - 1) * limit;
36
121
  }
@@ -38,59 +123,88 @@ export function parseQueryOptions(query: Record<string, unknown>): QueryOptions
38
123
  // Filtering
39
124
  options.where = {};
40
125
 
126
+ // Handle logical conditions
127
+ const orVal = getLastValue(query.or);
128
+ const andVal = getLastValue(query.and);
129
+ if (orVal) {
130
+ let orStr = String(orVal).trim();
131
+ if (orStr.startsWith("(") && orStr.endsWith(")")) {
132
+ orStr = orStr.slice(1, -1);
133
+ }
134
+ options.logical = {
135
+ type: "or",
136
+ conditions: parseLogicalList(orStr)
137
+ };
138
+ } else if (andVal) {
139
+ let andStr = String(andVal).trim();
140
+ if (andStr.startsWith("(") && andStr.endsWith(")")) {
141
+ andStr = andStr.slice(1, -1);
142
+ }
143
+ options.logical = {
144
+ type: "and",
145
+ conditions: parseLogicalList(andStr)
146
+ };
147
+ }
41
148
 
42
149
  // PostgREST-style filtering: ?field=op.value
43
- const reservedQueryKeys = ["limit", "offset", "page", "orderBy", "include", "fields", "searchString", "vector_search", "vector", "vector_distance", "vector_threshold"];
150
+ const reservedQueryKeys = ["limit", "offset", "page", "orderBy", "include", "fields", "searchString", "vector_search", "vector", "vector_distance", "vector_threshold", "or", "and"];
44
151
  for (const [key, rawValue] of Object.entries(query)) {
45
152
  if (reservedQueryKeys.includes(key)) continue;
46
153
 
47
- const value = Array.isArray(rawValue) ? rawValue[rawValue.length - 1] : rawValue;
48
-
49
- if (typeof value === "string") {
50
- const parts = value.split(".");
51
- if (parts.length >= 2) {
52
- const op = parts[0];
53
- const val = parts.slice(1).join(".");
54
- const rebaseOp = mapOperator(op);
55
-
56
- if (rebaseOp) {
57
- let parsedVal: string | number | boolean | null | (string | number | boolean | null)[] = val;
58
- // Attempt to parse primitive types or arrays
59
- if (val === "true") parsedVal = true;
60
- else if (val === "false") parsedVal = false;
61
- else if (val === "null") parsedVal = null;
62
- else if (!isNaN(Number(val)) && val.trim() !== "") parsedVal = Number(val);
63
- else if (val.startsWith("(")) {
64
- // Array for 'in' or 'not-in' ops (e.g. (1,2,3) or (a,b,c))
65
- const arrayContent = val.endsWith(")") ? val.slice(1, -1) : val.slice(1);
66
- parsedVal = arrayContent.split(",").map(v => {
67
- const trimmed = v.trim();
68
- if (!isNaN(Number(trimmed)) && trimmed !== "") return Number(trimmed);
69
- if (trimmed === "true") return true;
70
- if (trimmed === "false") return false;
71
- if (trimmed === "null") return null;
72
- return trimmed;
73
- });
74
- }
154
+ const rawValues = Array.isArray(rawValue) ? rawValue : [rawValue];
155
+ const conditions: [string, unknown][] = [];
156
+
157
+ for (const value of rawValues) {
158
+ if (typeof value === "string") {
159
+ const parts = value.split(".");
160
+ if (parts.length >= 2) {
161
+ const op = parts[0];
162
+ const val = parts.slice(1).join(".");
163
+ const rebaseOp = mapOperator(op);
75
164
 
76
- options.where[key] = [rebaseOp, parsedVal];
165
+ if (rebaseOp) {
166
+ let parsedVal: string | number | boolean | null | (string | number | boolean | null)[] = val;
167
+ // Attempt to parse primitive types or arrays
168
+ if (val === "true") parsedVal = true;
169
+ else if (val === "false") parsedVal = false;
170
+ else if (val === "null") parsedVal = null;
171
+ else if (!isNaN(Number(val)) && val.trim() !== "") parsedVal = Number(val);
172
+ else if (val.startsWith("(")) {
173
+ // Array for 'in' or 'not-in' ops (e.g. (1,2,3) or (a,b,c))
174
+ const arrayContent = val.endsWith(")") ? val.slice(1, -1) : val.slice(1);
175
+ parsedVal = arrayContent.split(",").map(v => {
176
+ const trimmed = v.trim();
177
+ if (!isNaN(Number(trimmed)) && trimmed !== "") return Number(trimmed);
178
+ if (trimmed === "true") return true;
179
+ if (trimmed === "false") return false;
180
+ if (trimmed === "null") return null;
181
+ return trimmed;
182
+ });
183
+ }
184
+
185
+ conditions.push([rebaseOp, parsedVal]);
186
+ } else {
187
+ // Fallback: assume implicit eq if the dot wasn't an operator (e.g. email or float)
188
+ let parsedVal: string | number | boolean | null = value;
189
+ if (!isNaN(Number(value)) && value.trim() !== "") parsedVal = Number(value);
190
+ conditions.push(["==", parsedVal]);
191
+ }
77
192
  } else {
78
- // Fallback: assume implicit eq if the dot wasn't an operator (e.g. email or float)
193
+ // Implicit eq
79
194
  let parsedVal: string | number | boolean | null = value;
80
- if (!isNaN(Number(value)) && value.trim() !== "") parsedVal = Number(value);
81
- options.where[key] = ["==", parsedVal];
195
+ if (value === "true") parsedVal = true;
196
+ else if (value === "false") parsedVal = false;
197
+ else if (value === "null") parsedVal = null;
198
+ else if (!isNaN(Number(value)) && value.trim() !== "") parsedVal = Number(value);
199
+
200
+ conditions.push(["==", parsedVal]);
82
201
  }
83
- } else {
84
- // Implicit eq
85
- let parsedVal: string | number | boolean | null = value;
86
- if (value === "true") parsedVal = true;
87
- else if (value === "false") parsedVal = false;
88
- else if (value === "null") parsedVal = null;
89
- else if (!isNaN(Number(value)) && value.trim() !== "") parsedVal = Number(value);
90
-
91
- options.where[key] = ["==", parsedVal];
92
202
  }
93
203
  }
204
+
205
+ if (conditions.length > 0) {
206
+ options.where[key] = conditions.length === 1 ? conditions[0] : conditions;
207
+ }
94
208
  }
95
209
 
96
210
  if (Object.keys(options.where).length === 0) {
@@ -98,15 +212,16 @@ export function parseQueryOptions(query: Record<string, unknown>): QueryOptions
98
212
  }
99
213
 
100
214
  // Sorting
101
- if (query.orderBy) {
215
+ const orderByVal = getLastValue(query.orderBy);
216
+ if (orderByVal) {
102
217
  try {
103
- options.orderBy = typeof query.orderBy === "string"
104
- ? JSON.parse(query.orderBy)
105
- : query.orderBy;
218
+ options.orderBy = typeof orderByVal === "string"
219
+ ? JSON.parse(orderByVal)
220
+ : orderByVal;
106
221
  } catch {
107
222
  // Try simple format: "field:direction"
108
- if (typeof query.orderBy === "string") {
109
- const [field, direction] = query.orderBy.split(":");
223
+ if (typeof orderByVal === "string") {
224
+ const [field, direction] = orderByVal.split(":");
110
225
  const dir = (direction === "desc" ? "desc" : "asc") as "asc" | "desc";
111
226
  options.orderBy = [
112
227
  {
@@ -119,8 +234,9 @@ export function parseQueryOptions(query: Record<string, unknown>): QueryOptions
119
234
  }
120
235
 
121
236
  // Relation includes
122
- if (query.include) {
123
- const includeStr = String(query.include).trim();
237
+ const includeVal = getLastValue(query.include);
238
+ if (includeVal) {
239
+ const includeStr = String(includeVal).trim();
124
240
  if (includeStr === "*") {
125
241
  options.include = ["*"];
126
242
  } else {
@@ -129,14 +245,17 @@ export function parseQueryOptions(query: Record<string, unknown>): QueryOptions
129
245
  }
130
246
 
131
247
  // Field selection
132
- if (query.fields) {
133
- const fieldsStr = String(query.fields).trim();
248
+ const fieldsVal = getLastValue(query.fields);
249
+ if (fieldsVal) {
250
+ const fieldsStr = String(fieldsVal).trim();
134
251
  options.fields = fieldsStr.split(",").map(s => s.trim()).filter(Boolean);
135
252
  }
136
253
 
137
254
  // Vector similarity search
138
- if (query.vector_search && query.vector) {
139
- const vectorStr = String(query.vector);
255
+ const vectorSearchVal = getLastValue(query.vector_search);
256
+ const vectorVal = getLastValue(query.vector);
257
+ if (vectorSearchVal && vectorVal) {
258
+ const vectorStr = String(vectorVal);
140
259
  let queryVector: number[];
141
260
  try {
142
261
  queryVector = JSON.parse(vectorStr) as number[];
@@ -147,19 +266,21 @@ export function parseQueryOptions(query: Record<string, unknown>): QueryOptions
147
266
  throw new Error("Invalid vector format. Expected JSON array of numbers, e.g. [0.1,0.2,0.3]");
148
267
  }
149
268
 
150
- const distanceParam = query.vector_distance ? String(query.vector_distance) : "cosine";
269
+ const distanceParamVal = getLastValue(query.vector_distance);
270
+ const distanceParam = distanceParamVal ? String(distanceParamVal) : "cosine";
151
271
  if (distanceParam !== "cosine" && distanceParam !== "l2" && distanceParam !== "inner_product") {
152
272
  throw new Error(`Invalid vector_distance: ${distanceParam}. Expected: cosine, l2, or inner_product`);
153
273
  }
154
274
 
155
275
  const vectorSearch: VectorSearchParams = {
156
- property: String(query.vector_search),
276
+ property: String(vectorSearchVal),
157
277
  vector: queryVector,
158
278
  distance: distanceParam,
159
279
  };
160
280
 
161
- if (query.vector_threshold) {
162
- const threshold = parseFloat(String(query.vector_threshold));
281
+ const thresholdVal = getLastValue(query.vector_threshold);
282
+ if (thresholdVal) {
283
+ const threshold = parseFloat(String(thresholdVal));
163
284
  if (isNaN(threshold)) {
164
285
  throw new Error("Invalid vector_threshold. Expected a number.");
165
286
  }
package/src/api/server.ts CHANGED
@@ -120,7 +120,7 @@ export class RebaseApiServer {
120
120
  singularName: col.singularName,
121
121
  description: col.description,
122
122
  properties: Object.keys(col.properties),
123
- relations: (col as EntityCollection & { relations?: Relation[] }).relations?.map((r: Relation) => ({
123
+ relations: col.relations?.map((r: Relation) => ({
124
124
  relationName: r.relationName,
125
125
  target: typeof r.target === "function" ? r.target().slug : r.target,
126
126
  cardinality: r.cardinality,
package/src/api/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { EntityCollection, VectorSearchParams } from "@rebasepro/types";
1
+ import { EntityCollection, VectorSearchParams, LogicalCondition } 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";
@@ -77,6 +77,7 @@ export interface QueryOptions {
77
77
  limit?: number;
78
78
  offset?: number;
79
79
  where?: Record<string, unknown>;
80
+ logical?: LogicalCondition;
80
81
  orderBy?: Array<{ field: string; direction: "asc" | "desc" }>;
81
82
  include?: string[];
82
83
  /** Columns to return in the response (field-level selection) */
@@ -5,7 +5,7 @@ import { requireAuth, requireAdmin, createRequireAuth } from "./middleware";
5
5
  import type { AuthHooks } from "./auth-hooks";
6
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;
@@ -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 {
@@ -533,91 +528,7 @@ displayName: existing.displayName }, appName);
533
528
  return c.json({ success: true });
534
529
  });
535
530
 
536
- router.get("/roles", requireAdmin, async (c) => {
537
- const roles = await authRepo.listRoles();
538
- const hookCtx = buildHookContext(c, "GET");
539
-
540
- let adminRoles: AdminRole[] = roles.map(r => ({
541
- id: r.id,
542
- name: r.name,
543
- isAdmin: r.isAdmin,
544
- defaultPermissions: r.defaultPermissions
545
- }));
546
-
547
- adminRoles = await applyRoleAfterReadBatch(adminRoles, hookCtx);
548
-
549
- return c.json({ roles: adminRoles });
550
- });
551
-
552
- router.get("/roles/:roleId", requireAdmin, async (c) => {
553
- const roleId = c.req.param("roleId");
554
- const role = await authRepo.getRoleById(roleId);
555
-
556
- if (!role) {
557
- throw ApiError.notFound("Role not found");
558
- }
559
-
560
- return c.json({ role });
561
- });
562
-
563
- router.post("/roles", requireAdmin, async (c) => {
564
- const body = await c.req.json();
565
- const { id, name, isAdmin, defaultPermissions } = body;
566
-
567
- if (!id || !name) {
568
- throw ApiError.badRequest("Role ID and name are required", "INVALID_INPUT");
569
- }
570
-
571
- const existing = await authRepo.getRoleById(id);
572
- if (existing) {
573
- throw ApiError.conflict("Role already exists", "ROLE_EXISTS");
574
- }
575
-
576
- const role = await authRepo.createRole({
577
- id,
578
- name,
579
- isAdmin: isAdmin ?? false,
580
- defaultPermissions: defaultPermissions ?? null
581
- });
582
531
 
583
- return c.json({ role }, 201);
584
- });
585
-
586
- router.put("/roles/:roleId", requireAdmin, async (c) => {
587
- const roleId = c.req.param("roleId");
588
- const body = await c.req.json();
589
- const { name, isAdmin, defaultPermissions } = body;
590
-
591
- const existing = await authRepo.getRoleById(roleId);
592
- if (!existing) {
593
- throw ApiError.notFound("Role not found");
594
- }
595
-
596
- const role = await authRepo.updateRole(roleId, {
597
- name,
598
- isAdmin,
599
- defaultPermissions
600
- });
601
-
602
- return c.json({ role });
603
- });
604
-
605
- router.delete("/roles/:roleId", requireAdmin, async (c) => {
606
- const roleId = c.req.param("roleId");
607
-
608
- if (["admin", "editor", "viewer"].includes(roleId)) {
609
- throw ApiError.badRequest("Cannot delete built-in roles", "BUILTIN_ROLE");
610
- }
611
-
612
- const existing = await authRepo.getRoleById(roleId);
613
- if (!existing) {
614
- throw ApiError.notFound("Role not found");
615
- }
616
-
617
- await authRepo.deleteRole(roleId);
618
-
619
- return c.json({ success: true });
620
- });
621
532
 
622
533
  return router;
623
534
  }
@@ -15,13 +15,10 @@ import type {
15
15
  AuthenticatedUser,
16
16
  AuthAdapterCapabilities,
17
17
  UserManagementAdapter,
18
- RoleManagementAdapter,
19
18
  AuthUserListOptions,
20
19
  AuthUserListResult,
21
20
  AuthUserData,
22
21
  AuthCreateUserData,
23
- AuthRoleData,
24
- AuthCreateRoleData,
25
22
  BootstrappedAuth,
26
23
  BackendHooks,
27
24
  } from "@rebasepro/types";
@@ -122,17 +119,10 @@ export function createBuiltinAuthAdapter(config: BuiltinAuthAdapterConfig): Auth
122
119
  return null;
123
120
  }
124
121
 
125
- // The decoded JWT may contain additional claims beyond the typed payload
126
- const extendedPayload = payload as AccessTokenPayload & {
127
- email?: string;
128
- displayName?: string;
129
- };
130
-
131
122
  // Resolve roles from the repository
132
123
  let roles: string[] = payload.roles || [];
133
124
  try {
134
- const userRoles = await authRepository.getUserRoles(payload.userId);
135
- roles = userRoles.map((r) => r.id);
125
+ roles = await authRepository.getUserRoleIds(payload.userId);
136
126
  } catch {
137
127
  // Fall back to token roles if repository lookup fails
138
128
  }
@@ -141,8 +131,8 @@ export function createBuiltinAuthAdapter(config: BuiltinAuthAdapterConfig): Auth
141
131
 
142
132
  return {
143
133
  uid: payload.userId,
144
- email: extendedPayload.email ?? "",
145
- displayName: extendedPayload.displayName ?? null,
134
+ email: payload.email ?? "",
135
+ displayName: payload.displayName ?? null,
146
136
  roles,
147
137
  isAdmin,
148
138
  rawToken: token,
@@ -167,15 +157,9 @@ export function createBuiltinAuthAdapter(config: BuiltinAuthAdapterConfig): Auth
167
157
  return null;
168
158
  }
169
159
 
170
- const extendedPayload = payload as AccessTokenPayload & {
171
- email?: string;
172
- displayName?: string;
173
- };
174
-
175
160
  let roles: string[] = payload.roles || [];
176
161
  try {
177
- const userRoles = await authRepository.getUserRoles(payload.userId);
178
- roles = userRoles.map((r) => r.id);
162
+ roles = await authRepository.getUserRoleIds(payload.userId);
179
163
  } catch {
180
164
  // Fall back to token roles if repository lookup fails
181
165
  }
@@ -184,8 +168,8 @@ export function createBuiltinAuthAdapter(config: BuiltinAuthAdapterConfig): Auth
184
168
 
185
169
  return {
186
170
  uid: payload.userId,
187
- email: extendedPayload.email ?? "",
188
- displayName: extendedPayload.displayName ?? null,
171
+ email: payload.email ?? "",
172
+ displayName: payload.displayName ?? null,
189
173
  roles,
190
174
  isAdmin,
191
175
  rawToken: token,
@@ -194,7 +178,7 @@ export function createBuiltinAuthAdapter(config: BuiltinAuthAdapterConfig): Auth
194
178
 
195
179
  userManagement: createUserManagementFromRepo(authRepository, resolvedOps, authHooks),
196
180
 
197
- roleManagement: createRoleManagementFromRepo(authRepository),
181
+
198
182
 
199
183
  createAuthRoutes(): Hono<HonoEnv> | undefined {
200
184
  return createAuthRoutes({
@@ -327,9 +311,8 @@ function createUserManagementFromRepo(repo: AuthRepository, resolvedOps: Resolve
327
311
  }
328
312
  },
329
313
 
330
- async getUserRoles(userId: string): Promise<AuthRoleData[]> {
331
- const roles = await repo.getUserRoles(userId);
332
- return roles.map(toAuthRoleData);
314
+ async getUserRoles(userId: string): Promise<string[]> {
315
+ return repo.getUserRoleIds(userId);
333
316
  },
334
317
 
335
318
  async setUserRoles(userId: string, roleIds: string[]): Promise<void> {
@@ -338,40 +321,6 @@ function createUserManagementFromRepo(repo: AuthRepository, resolvedOps: Resolve
338
321
  };
339
322
  }
340
323
 
341
- function createRoleManagementFromRepo(repo: AuthRepository): RoleManagementAdapter {
342
- return {
343
- async listRoles(): Promise<AuthRoleData[]> {
344
- const roles = await repo.listRoles();
345
- return roles.map(toAuthRoleData);
346
- },
347
-
348
- async getRoleById(id: string): Promise<AuthRoleData | null> {
349
- const role = await repo.getRoleById(id);
350
- return role ? toAuthRoleData(role) : null;
351
- },
352
-
353
- async createRole(data: AuthCreateRoleData): Promise<AuthRoleData> {
354
- const role = await repo.createRole({
355
- id: data.id,
356
- name: data.name,
357
- isAdmin: data.isAdmin,
358
- defaultPermissions: data.defaultPermissions,
359
- collectionPermissions: data.collectionPermissions,
360
- });
361
- return toAuthRoleData(role);
362
- },
363
-
364
- async updateRole(id: string, data: Partial<AuthRoleData>): Promise<AuthRoleData | null> {
365
- const role = await repo.updateRole(id, data);
366
- return role ? toAuthRoleData(role) : null;
367
- },
368
-
369
- async deleteRole(id: string): Promise<void> {
370
- await repo.deleteRole(id);
371
- },
372
- };
373
- }
374
-
375
324
  function toAuthUserData(user: { id: string; email: string; displayName?: string | null; photoUrl?: string | null; emailVerified?: boolean; metadata?: Record<string, unknown>; createdAt?: Date; updatedAt?: Date }): AuthUserData {
376
325
  return {
377
326
  id: user.id,
@@ -384,13 +333,3 @@ function toAuthUserData(user: { id: string; email: string; displayName?: string
384
333
  updatedAt: user.updatedAt,
385
334
  };
386
335
  }
387
-
388
- function toAuthRoleData(role: { id: string; name: string; isAdmin: boolean; defaultPermissions?: unknown; collectionPermissions?: unknown }): AuthRoleData {
389
- return {
390
- id: role.id,
391
- name: role.name,
392
- isAdmin: role.isAdmin,
393
- defaultPermissions: role.defaultPermissions as AuthRoleData["defaultPermissions"],
394
- collectionPermissions: role.collectionPermissions as AuthRoleData["collectionPermissions"],
395
- };
396
- }
@@ -76,7 +76,7 @@ export function createCustomAuthAdapter(options: CustomAuthAdapterOptions): Auth
76
76
 
77
77
  userManagement: options.userManagement,
78
78
 
79
- roleManagement: options.roleManagement,
79
+
80
80
 
81
81
  getCapabilities() {
82
82
  return defaultCapabilities;
package/src/auth/jwt.ts CHANGED
@@ -13,6 +13,16 @@ export interface AccessTokenPayload {
13
13
  uid?: string;
14
14
  /** Authentication Assurance Level: aal1 = password/oauth, aal2 = MFA verified */
15
15
  aal?: "aal1" | "aal2";
16
+ /** Email claim from the JWT, if present */
17
+ email?: string;
18
+ /** Display name claim from the JWT, if present */
19
+ displayName?: string;
20
+ /** Photo URL claim from the JWT, if present */
21
+ photoURL?: string;
22
+ /** Whether MFA has been verified for this session */
23
+ mfa_verified?: boolean;
24
+ /** Authentication Methods Reference — list of methods used (e.g. 'pwd', 'otp') */
25
+ amr?: string[];
16
26
  }
17
27
 
18
28
  let jwtConfig: JwtConfig = {
@@ -1171,18 +1171,14 @@ message: "Session revoked successfully" });
1171
1171
  throw ApiError.notFound("MFA factor not found");
1172
1172
  }
1173
1173
 
1174
- // Check if this is a recovery code (10 chars with hyphen) or TOTP (6 digits)
1175
- const isRecoveryCode = code.length > 6;
1176
- let isValid = false;
1174
+ // Try TOTP verification first (standard 6-digit codes)
1175
+ const secretBuffer = base32Decode(factor.secretEncrypted);
1176
+ let isValid = verifyTotp(secretBuffer, code);
1177
1177
 
1178
- if (isRecoveryCode) {
1179
- // Try recovery code
1178
+ // Fall back to recovery code verification if TOTP didn't match
1179
+ if (!isValid) {
1180
1180
  const codeHash = hashRecoveryCode(code);
1181
1181
  isValid = await authRepo.useRecoveryCode(userCtx.userId, codeHash);
1182
- } else {
1183
- // Verify TOTP
1184
- const secretBuffer = base32Decode(factor.secretEncrypted);
1185
- isValid = verifyTotp(secretBuffer, code);
1186
1182
  }
1187
1183
 
1188
1184
  if (!isValid) {