@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.
- package/README.md +62 -25
- package/dist/common/src/collections/default-collections.d.ts +5 -8
- package/dist/common/src/data/query_builder.d.ts +6 -2
- package/dist/common/src/util/permissions.d.ts +14 -6
- package/dist/index.es.js +393 -315
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +393 -315
- package/dist/index.umd.js.map +1 -1
- package/dist/server-core/src/api/errors.d.ts +15 -0
- package/dist/server-core/src/api/rest/query-parser.d.ts +2 -0
- package/dist/server-core/src/api/types.d.ts +2 -1
- package/dist/server-core/src/auth/jwt.d.ts +10 -0
- package/dist/server-core/src/email/types.d.ts +1 -0
- package/dist/server-core/src/init.d.ts +31 -1
- package/dist/types/src/controllers/auth.d.ts +2 -2
- package/dist/types/src/controllers/client.d.ts +25 -40
- package/dist/types/src/controllers/data.d.ts +21 -3
- package/dist/types/src/controllers/data_driver.d.ts +5 -0
- package/dist/types/src/controllers/email.d.ts +2 -0
- package/dist/types/src/types/auth_adapter.d.ts +3 -56
- package/dist/types/src/types/backend.d.ts +38 -3
- package/dist/types/src/types/backend_hooks.d.ts +2 -17
- package/dist/types/src/types/collections.d.ts +30 -6
- package/dist/types/src/types/entity_views.d.ts +19 -28
- package/dist/types/src/types/properties.d.ts +9 -15
- package/dist/types/src/types/user_management_delegate.d.ts +16 -53
- package/dist/types/src/users/index.d.ts +0 -1
- package/dist/types/src/users/user.d.ts +0 -1
- package/package.json +5 -5
- package/src/api/errors.ts +20 -1
- package/src/api/openapi-generator.ts +1 -1
- package/src/api/rest/api-generator.ts +82 -24
- package/src/api/rest/query-parser.ts +184 -63
- package/src/api/server.ts +1 -1
- package/src/api/types.ts +2 -1
- package/src/auth/admin-routes.ts +2 -91
- package/src/auth/builtin-auth-adapter.ts +9 -70
- package/src/auth/custom-auth-adapter.ts +1 -1
- package/src/auth/jwt.ts +10 -0
- package/src/auth/routes.ts +5 -9
- package/src/email/smtp-email-service.ts +31 -0
- package/src/email/types.ts +1 -0
- package/src/init.ts +135 -31
- package/src/storage/image-transform.ts +2 -1
- package/test/admin-routes.test.ts +0 -169
- package/test/backend-hooks-admin.test.ts +0 -25
- package/test/custom-auth-adapter.test.ts +2 -10
- package/test/smtp-email-service.test.ts +169 -0
- package/dist/types/src/users/roles.d.ts +0 -14
- 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
|
-
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
193
|
+
// Implicit eq
|
|
79
194
|
let parsedVal: string | number | boolean | null = value;
|
|
80
|
-
if (
|
|
81
|
-
|
|
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
|
-
|
|
215
|
+
const orderByVal = getLastValue(query.orderBy);
|
|
216
|
+
if (orderByVal) {
|
|
102
217
|
try {
|
|
103
|
-
options.orderBy = typeof
|
|
104
|
-
? JSON.parse(
|
|
105
|
-
:
|
|
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
|
|
109
|
-
const [field, direction] =
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
|
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(
|
|
276
|
+
property: String(vectorSearchVal),
|
|
157
277
|
vector: queryVector,
|
|
158
278
|
distance: distanceParam,
|
|
159
279
|
};
|
|
160
280
|
|
|
161
|
-
|
|
162
|
-
|
|
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:
|
|
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) */
|
package/src/auth/admin-routes.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
145
|
-
displayName:
|
|
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
|
-
|
|
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:
|
|
188
|
-
displayName:
|
|
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
|
-
|
|
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<
|
|
331
|
-
|
|
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
|
-
}
|
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 = {
|
package/src/auth/routes.ts
CHANGED
|
@@ -1171,18 +1171,14 @@ message: "Session revoked successfully" });
|
|
|
1171
1171
|
throw ApiError.notFound("MFA factor not found");
|
|
1172
1172
|
}
|
|
1173
1173
|
|
|
1174
|
-
//
|
|
1175
|
-
const
|
|
1176
|
-
let isValid =
|
|
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
|
|
1179
|
-
|
|
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) {
|