@rebasepro/server-core 0.2.5 → 0.4.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/dist/common/src/data/query_builder.d.ts +6 -2
- package/dist/index.es.js +306 -251
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +306 -251
- package/dist/index.umd.js.map +1 -1
- 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/email/types.d.ts +1 -0
- package/dist/server-core/src/init.d.ts +31 -1
- package/dist/types/src/controllers/data.d.ts +17 -3
- package/dist/types/src/controllers/email.d.ts +2 -0
- package/dist/types/src/types/collections.d.ts +9 -5
- package/dist/types/src/types/entity_views.d.ts +19 -28
- package/dist/types/src/types/properties.d.ts +2 -2
- package/package.json +5 -5
- package/src/api/rest/api-generator.ts +11 -9
- package/src/api/rest/query-parser.ts +184 -63
- package/src/api/types.ts +2 -1
- package/src/email/smtp-email-service.ts +31 -0
- package/src/email/types.ts +1 -0
- package/src/init.ts +123 -29
- package/src/storage/image-transform.ts +2 -1
- package/test/smtp-email-service.test.ts +169 -0
|
@@ -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/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) */
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { createTransport, Transporter } from "nodemailer";
|
|
2
2
|
import { EmailService, EmailSendOptions, EmailConfig } from "./types";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Safely parse a hostname from a URL string
|
|
6
|
+
*/
|
|
7
|
+
function getHostname(urlStr: string): string | undefined {
|
|
8
|
+
try {
|
|
9
|
+
const url = new URL(urlStr.includes("://") ? urlStr : `https://${urlStr}`);
|
|
10
|
+
return url.hostname;
|
|
11
|
+
} catch {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
4
16
|
/**
|
|
5
17
|
* SMTP Email Service implementation using Nodemailer
|
|
6
18
|
*/
|
|
@@ -12,7 +24,26 @@ export class SMTPEmailService implements EmailService {
|
|
|
12
24
|
this.config = config;
|
|
13
25
|
|
|
14
26
|
if (config.smtp) {
|
|
27
|
+
let smtpName = config.smtp.name;
|
|
28
|
+
if (!smtpName) {
|
|
29
|
+
const urlsToTry = [
|
|
30
|
+
process.env.FRONTEND_URL,
|
|
31
|
+
config.resetPasswordUrl,
|
|
32
|
+
config.verifyEmailUrl
|
|
33
|
+
];
|
|
34
|
+
for (const urlStr of urlsToTry) {
|
|
35
|
+
if (urlStr) {
|
|
36
|
+
const hostname = getHostname(urlStr);
|
|
37
|
+
if (hostname) {
|
|
38
|
+
smtpName = hostname;
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
15
45
|
this.transporter = createTransport({
|
|
46
|
+
name: smtpName,
|
|
16
47
|
host: config.smtp.host,
|
|
17
48
|
port: config.smtp.port,
|
|
18
49
|
secure: config.smtp.secure ?? (config.smtp.port === 465),
|
package/src/email/types.ts
CHANGED
package/src/init.ts
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
AuthAdapter,
|
|
3
|
+
BackendBootstrapper,
|
|
4
|
+
BackendHooks,
|
|
5
|
+
BootstrappedAuth,
|
|
6
|
+
DatabaseAdapter,
|
|
7
|
+
DataDriver,
|
|
8
|
+
EntityCollection,
|
|
9
|
+
HealthCheckResult,
|
|
10
|
+
InitializedDriver,
|
|
11
|
+
isPostgresCollection,
|
|
12
|
+
isSQLAdmin,
|
|
13
|
+
RealtimeProvider,
|
|
14
|
+
SecurityRule
|
|
15
|
+
} from "@rebasepro/types";
|
|
2
16
|
import { BackendCollectionRegistry } from "./collections/BackendCollectionRegistry";
|
|
3
17
|
import { loadCollectionsFromDirectory } from "./collections/loader";
|
|
4
|
-
import {
|
|
18
|
+
import { DEFAULT_DRIVER_ID, DefaultDriverRegistry, DriverRegistry } from "./services/driver-registry";
|
|
5
19
|
import { Server } from "http";
|
|
6
20
|
|
|
7
21
|
import { RestApiGenerator } from "./api/rest/api-generator";
|
|
@@ -16,22 +30,46 @@ import { HonoEnv } from "./api/types";
|
|
|
16
30
|
import { configureLogLevel } from "./utils/logging";
|
|
17
31
|
import { logger } from "./utils/logger";
|
|
18
32
|
import { requestLogger } from "./utils/request-logger";
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
33
|
+
import { configureJwt, requireAdmin, requireAuth } from "./auth";
|
|
34
|
+
import {
|
|
35
|
+
BackendStorageConfig,
|
|
36
|
+
createStorageController,
|
|
37
|
+
createStorageRoutes,
|
|
38
|
+
DEFAULT_STORAGE_ID,
|
|
39
|
+
DefaultStorageRegistry,
|
|
40
|
+
StorageController,
|
|
41
|
+
StorageRegistry
|
|
42
|
+
} from "./storage";
|
|
43
|
+
import type { ApiKeyStore } from "./auth/api-keys/api-key-store";
|
|
21
44
|
import { createApiKeyStore } from "./auth/api-keys/api-key-store";
|
|
22
45
|
import { createApiKeyRoutes } from "./auth/api-keys/api-key-routes";
|
|
23
|
-
import type { ApiKeyStore } from "./auth/api-keys/api-key-store";
|
|
24
46
|
import { createApiKeyRateLimiter } from "./auth/rate-limiter";
|
|
25
47
|
import { createRebaseClient } from "@rebasepro/client";
|
|
26
|
-
|
|
48
|
+
|
|
27
49
|
import { createHistoryRoutes } from "./history";
|
|
28
|
-
import { EmailConfig, createEmailService } from "./email";
|
|
29
50
|
import type { EmailService } from "./email";
|
|
51
|
+
import { createEmailService, EmailConfig } from "./email";
|
|
30
52
|
import type { OAuthProvider } from "./auth/interfaces";
|
|
31
53
|
import type { AuthHooks } from "./auth/auth-hooks";
|
|
32
54
|
import { _initRebase } from "./singleton";
|
|
33
55
|
|
|
34
56
|
export interface RebaseAuthConfig {
|
|
57
|
+
/**
|
|
58
|
+
* The collection that represents auth users.
|
|
59
|
+
*
|
|
60
|
+
* When provided, this collection's underlying database table is used
|
|
61
|
+
* for all auth operations (login, registration, password reset, etc.).
|
|
62
|
+
*
|
|
63
|
+
* Import the built-in default:
|
|
64
|
+
* ```ts
|
|
65
|
+
* import { defaultUsersCollection } from "@rebasepro/common";
|
|
66
|
+
* auth: { collection: defaultUsersCollection, jwtSecret: "..." }
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* Or pass your own collection with the required auth fields
|
|
70
|
+
* (email, passwordHash, displayName, etc.).
|
|
71
|
+
*/
|
|
72
|
+
collection?: EntityCollection;
|
|
35
73
|
jwtSecret?: string;
|
|
36
74
|
accessExpiresIn?: string;
|
|
37
75
|
refreshExpiresIn?: string;
|
|
@@ -84,6 +122,7 @@ export interface RebaseAuthConfig {
|
|
|
84
122
|
* ```
|
|
85
123
|
*/
|
|
86
124
|
hooks?: AuthHooks;
|
|
125
|
+
|
|
87
126
|
[key: string]: unknown;
|
|
88
127
|
}
|
|
89
128
|
|
|
@@ -136,6 +175,20 @@ export interface RebaseBackendConfig {
|
|
|
136
175
|
*/
|
|
137
176
|
storage?: BackendStorageConfig | StorageController | Record<string, BackendStorageConfig | StorageController>;
|
|
138
177
|
history?: unknown;
|
|
178
|
+
/**
|
|
179
|
+
* Default security rules applied to any collection that does not define
|
|
180
|
+
* its own `securityRules`. Opt-in — if not set, collections without
|
|
181
|
+
* explicit rules remain unrestricted (beyond `requireAuth`).
|
|
182
|
+
*
|
|
183
|
+
* @example
|
|
184
|
+
* ```ts
|
|
185
|
+
* defaultSecurityRules: [
|
|
186
|
+
* { operation: "select", access: "public" },
|
|
187
|
+
* { operations: ["insert", "update", "delete"], roles: ["admin"] }
|
|
188
|
+
* ]
|
|
189
|
+
* ```
|
|
190
|
+
*/
|
|
191
|
+
defaultSecurityRules?: SecurityRule[];
|
|
139
192
|
enableSwagger?: boolean;
|
|
140
193
|
functionsDir?: string;
|
|
141
194
|
cronsDir?: string;
|
|
@@ -219,11 +272,13 @@ export interface RebaseBackendInstance {
|
|
|
219
272
|
storageController?: StorageController;
|
|
220
273
|
collectionRegistry: BackendCollectionRegistry;
|
|
221
274
|
cronScheduler?: import("./cron").CronScheduler;
|
|
275
|
+
|
|
222
276
|
/**
|
|
223
277
|
* Deep health check that verifies database connectivity.
|
|
224
278
|
* Returns latency and component status.
|
|
225
279
|
*/
|
|
226
280
|
healthCheck(): Promise<HealthCheckResult>;
|
|
281
|
+
|
|
227
282
|
/**
|
|
228
283
|
* Graceful shutdown helper for the BaaS instance.
|
|
229
284
|
* Stops the cron scheduler and closes the HTTP server, allowing
|
|
@@ -289,15 +344,20 @@ async function _initializeRebaseBackend(config: RebaseBackendConfig): Promise<Re
|
|
|
289
344
|
let activeCollections = config.collections || [];
|
|
290
345
|
if (config.collectionsDir && activeCollections.length === 0) {
|
|
291
346
|
activeCollections = await loadCollectionsFromDirectory(config.collectionsDir);
|
|
292
|
-
logger.info("Auto-discovered collections", {
|
|
293
|
-
|
|
347
|
+
logger.info("Auto-discovered collections", {
|
|
348
|
+
count: activeCollections.length,
|
|
349
|
+
dir: config.collectionsDir
|
|
350
|
+
});
|
|
294
351
|
}
|
|
295
352
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
activeCollections
|
|
299
|
-
|
|
300
|
-
|
|
353
|
+
// Apply default security rules to collections that don't define their own
|
|
354
|
+
if (config.defaultSecurityRules?.length) {
|
|
355
|
+
for (const collection of activeCollections) {
|
|
356
|
+
if (isPostgresCollection(collection) && (!collection.securityRules || collection.securityRules.length === 0)) {
|
|
357
|
+
collection.securityRules = config.defaultSecurityRules;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
logger.info("Default security rules applied to collections without explicit rules");
|
|
301
361
|
}
|
|
302
362
|
|
|
303
363
|
const realtimeServices: Record<string, RealtimeProvider> = {};
|
|
@@ -341,8 +401,10 @@ dir: config.collectionsDir });
|
|
|
341
401
|
defaultDriverId = b.id || bootstrapper.type;
|
|
342
402
|
}
|
|
343
403
|
|
|
344
|
-
const driverResult = await bootstrapper.initializeDriver({
|
|
345
|
-
|
|
404
|
+
const driverResult = await bootstrapper.initializeDriver({
|
|
405
|
+
collections: activeCollections,
|
|
406
|
+
collectionRegistry
|
|
407
|
+
});
|
|
346
408
|
delegates[b.id || bootstrapper.type] = driverResult.driver;
|
|
347
409
|
|
|
348
410
|
if ((b.id || bootstrapper.type) === defaultDriverId || !defaultDriverResult) {
|
|
@@ -362,7 +424,9 @@ collectionRegistry });
|
|
|
362
424
|
if (!defaultDriver || !defaultDriverResult) {
|
|
363
425
|
throw new Error("Default driver not initialized by bootstrappers");
|
|
364
426
|
}
|
|
365
|
-
const defaultBootstrapper = bootstrappers.find(b => (b as BackendBootstrapper & {
|
|
427
|
+
const defaultBootstrapper = bootstrappers.find(b => (b as BackendBootstrapper & {
|
|
428
|
+
id?: string
|
|
429
|
+
}).id === defaultDriverId || b.type === defaultDriverId) || bootstrappers[0];
|
|
366
430
|
const defaultRealtimeService = defaultDriverResult.realtimeProvider;
|
|
367
431
|
|
|
368
432
|
// 2. Initialize Auth & History via the default driver's bootstrapper
|
|
@@ -527,7 +591,10 @@ collectionRegistry });
|
|
|
527
591
|
|
|
528
592
|
if (safeAuthConfig.linkedin?.clientId && safeAuthConfig.linkedin?.clientSecret) {
|
|
529
593
|
const { createLinkedinProvider } = await import("./auth");
|
|
530
|
-
oauthProviders.push(createLinkedinProvider(safeAuthConfig.linkedin as {
|
|
594
|
+
oauthProviders.push(createLinkedinProvider(safeAuthConfig.linkedin as {
|
|
595
|
+
clientId: string;
|
|
596
|
+
clientSecret: string
|
|
597
|
+
}));
|
|
531
598
|
}
|
|
532
599
|
|
|
533
600
|
if (safeAuthConfig.github?.clientId && safeAuthConfig.github?.clientSecret) {
|
|
@@ -797,6 +864,18 @@ collectionRegistry });
|
|
|
797
864
|
if (emailService) {
|
|
798
865
|
Object.assign(serverClient, { email: emailService });
|
|
799
866
|
logger.info("Email service attached to singleton", { configured: emailService.isConfigured() });
|
|
867
|
+
|
|
868
|
+
if (emailService.isConfigured() && typeof emailService.verifyConnection === "function") {
|
|
869
|
+
emailService.verifyConnection().then((success) => {
|
|
870
|
+
if (!success) {
|
|
871
|
+
logger.warn("Warning: SMTP connection verification failed. Email delivery may fail.");
|
|
872
|
+
} else {
|
|
873
|
+
logger.info("SMTP connection verified successfully.");
|
|
874
|
+
}
|
|
875
|
+
}).catch((err) => {
|
|
876
|
+
logger.warn("Warning: SMTP connection verification failed. Email delivery may fail.", { error: err });
|
|
877
|
+
});
|
|
878
|
+
}
|
|
800
879
|
}
|
|
801
880
|
|
|
802
881
|
// Attach raw SQL capability when the driver supports it (Postgres, MySQL).
|
|
@@ -860,8 +939,10 @@ collectionRegistry });
|
|
|
860
939
|
const fnRoutes = createFunctionRoutes(loadedFunctions);
|
|
861
940
|
functionsRouter.route("/", fnRoutes);
|
|
862
941
|
config.app.route(`${basePath}/functions`, functionsRouter);
|
|
863
|
-
logger.info("Mounted custom functions", {
|
|
864
|
-
|
|
942
|
+
logger.info("Mounted custom functions", {
|
|
943
|
+
count: loadedFunctions.length,
|
|
944
|
+
path: `${basePath}/functions`
|
|
945
|
+
});
|
|
865
946
|
}
|
|
866
947
|
}
|
|
867
948
|
|
|
@@ -910,13 +991,19 @@ path: `${basePath}/functions` });
|
|
|
910
991
|
// Start the scheduler
|
|
911
992
|
cronScheduler.start();
|
|
912
993
|
|
|
913
|
-
logger.info("Mounted cron jobs", {
|
|
914
|
-
|
|
994
|
+
logger.info("Mounted cron jobs", {
|
|
995
|
+
count: loadedCronJobs.length,
|
|
996
|
+
path: `${basePath}/cron`
|
|
997
|
+
});
|
|
915
998
|
}
|
|
916
999
|
}
|
|
917
1000
|
|
|
918
|
-
if ((defaultBootstrapper as BackendBootstrapper & {
|
|
919
|
-
|
|
1001
|
+
if ((defaultBootstrapper as BackendBootstrapper & {
|
|
1002
|
+
initializeWebsockets?: (...args: unknown[]) => unknown
|
|
1003
|
+
}).initializeWebsockets) {
|
|
1004
|
+
await (defaultBootstrapper as BackendBootstrapper & {
|
|
1005
|
+
initializeWebsockets: (...args: unknown[]) => unknown
|
|
1006
|
+
}).initializeWebsockets(config.server, defaultRealtimeService, defaultDriver, config.auth, authAdapter);
|
|
920
1007
|
}
|
|
921
1008
|
|
|
922
1009
|
logger.info("Rebase Backend Initialized");
|
|
@@ -931,12 +1018,16 @@ path: `${basePath}/cron` });
|
|
|
931
1018
|
await admin.executeSql("SELECT 1");
|
|
932
1019
|
} else {
|
|
933
1020
|
// Fallback: try a lightweight fetch to confirm driver is responsive
|
|
934
|
-
await defaultDriver.fetchCollection({
|
|
935
|
-
|
|
1021
|
+
await defaultDriver.fetchCollection({
|
|
1022
|
+
path: "__health_check_nonexistent__",
|
|
1023
|
+
limit: 1
|
|
1024
|
+
});
|
|
936
1025
|
}
|
|
937
1026
|
const latencyMs = Math.round(performance.now() - start);
|
|
938
|
-
return {
|
|
939
|
-
|
|
1027
|
+
return {
|
|
1028
|
+
healthy: true,
|
|
1029
|
+
latencyMs
|
|
1030
|
+
};
|
|
940
1031
|
} catch (error: unknown) {
|
|
941
1032
|
const latencyMs = Math.round(performance.now() - start);
|
|
942
1033
|
logger.error("Health check failed", {
|
|
@@ -970,7 +1061,10 @@ latencyMs };
|
|
|
970
1061
|
// timer callbacks don't fire against a closed pool.
|
|
971
1062
|
for (const [key, rt] of Object.entries(realtimeServices)) {
|
|
972
1063
|
try {
|
|
973
|
-
const rtWithLifecycle = rt as RealtimeProvider & {
|
|
1064
|
+
const rtWithLifecycle = rt as RealtimeProvider & {
|
|
1065
|
+
destroy?: () => Promise<void>;
|
|
1066
|
+
stopListening?: () => Promise<void>
|
|
1067
|
+
};
|
|
974
1068
|
if (typeof rtWithLifecycle.destroy === "function") {
|
|
975
1069
|
await rtWithLifecycle.destroy();
|
|
976
1070
|
logger.info(`Realtime service "${key}" destroyed`);
|