@getstrata/core 1.0.0 → 1.0.2
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/CHANGELOG.md +52 -33
- package/README.md +36 -43
- package/dist/core/database/baseRepository.d.ts +2 -1
- package/dist/core/database/dialect.d.ts +5 -1
- package/dist/core/database/errors.d.ts +4 -0
- package/dist/core/database/mysqlConnection.d.ts +3 -1
- package/dist/core/errors/http.d.ts +4 -1
- package/dist/core/http/clientIp.d.ts +6 -3
- package/dist/core/http/webErrorResponse.d.ts +3 -1
- package/dist/core/runtime/appEnv.d.ts +4 -0
- package/dist/core/tenant/tenancyConfig.d.ts +1 -0
- package/dist/entries/audit/exportAuditLogs.js +7 -5
- package/dist/entries/auth/scimAuthMiddleware.js +7 -5
- package/dist/entries/contracts/authUserDirectory.js +1 -0
- package/dist/entries/database/errors.js +45 -6
- package/dist/entries/database/mysqlConnection.js +18 -2
- package/dist/entries/database/sqliteConnection.js +5 -0
- package/dist/entries/http/clientIp.js +24 -6
- package/dist/entries/http/corsMiddleware.js +14 -3
- package/dist/entries/http/loginThrottleMiddleware.js +25 -8
- package/dist/entries/http/memoryThrottleMiddleware.js +25 -8
- package/dist/entries/http/response.js +66 -7
- package/dist/entries/http/scimThrottleMiddleware.js +23 -6
- package/dist/entries/http/throttleMiddleware.js +25 -8
- package/dist/entries/http/webErrorResponse.js +66 -7
- package/dist/entries/jobs/exportAuditLogsJob.js +7 -5
- package/dist/entries/logging/requestLoggingMiddleware.js +29 -10
- package/dist/entries/openapi/generator.js +1 -1
- package/dist/entries/runtime/appEnv.js +8 -0
- package/dist/entries/tenant/databaseTenantContext.js +7 -5
- package/dist/entries/tenant/tenancyConfig.js +7 -5
- package/dist/entries/tenant/tenantDatabaseScope.js +7 -5
- package/dist/framework/public-api.d.ts +3 -3
- package/dist/index.js +161 -39
- package/package.json +16 -3
|
@@ -1,19 +1,37 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// ../../src/core/http/clientIp.ts
|
|
3
|
+
import { currentRequestMeta } from "@getstrata/core/http/requestMetaContext";
|
|
3
4
|
function trustForwardedFor(env = process.env) {
|
|
4
5
|
return (env.TRUST_FORWARDED_FOR ?? "false") === "true";
|
|
5
6
|
}
|
|
6
|
-
function
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
function isPrivateAddress(address) {
|
|
8
|
+
const ip = address.replace(/^::ffff:/i, "");
|
|
9
|
+
return ip === "::1" || ip === "localhost" || /^127\./.test(ip) || /^10\./.test(ip) || /^192\.168\./.test(ip) || /^172\.(1[6-9]|2\d|3[01])\./.test(ip) || /^169\.254\./.test(ip) || /^f[cd][0-9a-f]{2}:/i.test(ip) || /^fe80:/i.test(ip);
|
|
10
|
+
}
|
|
11
|
+
function forwardedClientIp(request) {
|
|
12
|
+
const hops = (request.headers.get("x-forwarded-for") ?? "").split(",").map((hop) => hop.trim()).filter(Boolean);
|
|
13
|
+
for (let index = hops.length - 1;index >= 0; index -= 1) {
|
|
14
|
+
const hop = hops[index];
|
|
15
|
+
if (hop && !isPrivateAddress(hop)) {
|
|
16
|
+
return hop;
|
|
17
|
+
}
|
|
9
18
|
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return forwarded;
|
|
19
|
+
if (hops.length > 0) {
|
|
20
|
+
return hops[hops.length - 1];
|
|
13
21
|
}
|
|
14
22
|
return request.headers.get("x-real-ip")?.trim() || undefined;
|
|
15
23
|
}
|
|
24
|
+
function readClientIp(request, env = process.env) {
|
|
25
|
+
if (trustForwardedFor(env)) {
|
|
26
|
+
const forwarded = forwardedClientIp(request);
|
|
27
|
+
if (forwarded) {
|
|
28
|
+
return forwarded;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return currentRequestMeta().ipAddress ?? undefined;
|
|
32
|
+
}
|
|
16
33
|
export {
|
|
34
|
+
isPrivateAddress,
|
|
17
35
|
readClientIp,
|
|
18
36
|
trustForwardedFor
|
|
19
37
|
};
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
// @bun
|
|
2
|
+
// ../../src/core/runtime/appEnv.ts
|
|
3
|
+
function isProductionEnv(env = process.env) {
|
|
4
|
+
return env.APP_ENV === "production" || env.NODE_ENV === "production";
|
|
5
|
+
}
|
|
6
|
+
|
|
2
7
|
// ../../src/core/http/corsMiddleware.ts
|
|
8
|
+
function defaultAllowedOrigins() {
|
|
9
|
+
return isProductionEnv() ? "" : "*";
|
|
10
|
+
}
|
|
3
11
|
function resolveCorsConfig() {
|
|
4
12
|
return {
|
|
5
|
-
allowedOrigins: (process.env.CORS_ALLOWED_ORIGINS ??
|
|
13
|
+
allowedOrigins: (process.env.CORS_ALLOWED_ORIGINS ?? defaultAllowedOrigins()).split(",").map((origin) => origin.trim()).filter(Boolean),
|
|
6
14
|
allowedMethods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
|
|
7
15
|
allowedHeaders: [
|
|
8
16
|
"Authorization",
|
|
@@ -42,12 +50,15 @@ function buildCorsHeaders(request) {
|
|
|
42
50
|
const origin = request.headers.get("origin");
|
|
43
51
|
const corsConfig = resolveCorsConfig();
|
|
44
52
|
const allowedOrigins = corsConfig.allowedOrigins;
|
|
45
|
-
|
|
53
|
+
headers.set("Vary", "Origin");
|
|
54
|
+
const allowOrigin = allowedOrigins.includes("*") ? "*" : origin && allowedOrigins.includes(origin) ? origin : null;
|
|
55
|
+
if (!allowOrigin) {
|
|
56
|
+
return headers;
|
|
57
|
+
}
|
|
46
58
|
headers.set("Access-Control-Allow-Origin", allowOrigin);
|
|
47
59
|
headers.set("Access-Control-Allow-Methods", corsConfig.allowedMethods.join(", "));
|
|
48
60
|
headers.set("Access-Control-Allow-Headers", corsConfig.allowedHeaders.join(", "));
|
|
49
61
|
headers.set("Access-Control-Max-Age", String(corsConfig.maxAgeSeconds));
|
|
50
|
-
headers.set("Vary", "Origin");
|
|
51
62
|
return headers;
|
|
52
63
|
}
|
|
53
64
|
export {
|
|
@@ -57,19 +57,36 @@ function sdkClientClassName() {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
// ../../src/core/http/clientIp.ts
|
|
60
|
+
import { currentRequestMeta } from "@getstrata/core/http/requestMetaContext";
|
|
60
61
|
function trustForwardedFor(env = process.env) {
|
|
61
62
|
return (env.TRUST_FORWARDED_FOR ?? "false") === "true";
|
|
62
63
|
}
|
|
63
|
-
function
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
function isPrivateAddress(address) {
|
|
65
|
+
const ip = address.replace(/^::ffff:/i, "");
|
|
66
|
+
return ip === "::1" || ip === "localhost" || /^127\./.test(ip) || /^10\./.test(ip) || /^192\.168\./.test(ip) || /^172\.(1[6-9]|2\d|3[01])\./.test(ip) || /^169\.254\./.test(ip) || /^f[cd][0-9a-f]{2}:/i.test(ip) || /^fe80:/i.test(ip);
|
|
67
|
+
}
|
|
68
|
+
function forwardedClientIp(request) {
|
|
69
|
+
const hops = (request.headers.get("x-forwarded-for") ?? "").split(",").map((hop) => hop.trim()).filter(Boolean);
|
|
70
|
+
for (let index = hops.length - 1;index >= 0; index -= 1) {
|
|
71
|
+
const hop = hops[index];
|
|
72
|
+
if (hop && !isPrivateAddress(hop)) {
|
|
73
|
+
return hop;
|
|
74
|
+
}
|
|
66
75
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return forwarded;
|
|
76
|
+
if (hops.length > 0) {
|
|
77
|
+
return hops[hops.length - 1];
|
|
70
78
|
}
|
|
71
79
|
return request.headers.get("x-real-ip")?.trim() || undefined;
|
|
72
80
|
}
|
|
81
|
+
function readClientIp(request, env = process.env) {
|
|
82
|
+
if (trustForwardedFor(env)) {
|
|
83
|
+
const forwarded = forwardedClientIp(request);
|
|
84
|
+
if (forwarded) {
|
|
85
|
+
return forwarded;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return currentRequestMeta().ipAddress ?? undefined;
|
|
89
|
+
}
|
|
73
90
|
|
|
74
91
|
// ../../src/core/runtime/frontendMode.ts
|
|
75
92
|
var FRONTEND_MODES = ["api", "server-htmx", "spa-react", "hybrid"];
|
|
@@ -111,7 +128,7 @@ function readSpaPrefix() {
|
|
|
111
128
|
}
|
|
112
129
|
|
|
113
130
|
// ../../src/core/view/webErrorView.ts
|
|
114
|
-
import { currentRequestMeta } from "@getstrata/core/http/requestMetaContext";
|
|
131
|
+
import { currentRequestMeta as currentRequestMeta2 } from "@getstrata/core/http/requestMetaContext";
|
|
115
132
|
|
|
116
133
|
// ../../src/core/view/htmlResponse.ts
|
|
117
134
|
function withCharset(contentType) {
|
|
@@ -212,7 +229,7 @@ async function renderWebErrorHtml(input) {
|
|
|
212
229
|
try {
|
|
213
230
|
return await render({
|
|
214
231
|
...input,
|
|
215
|
-
request: input.request ??
|
|
232
|
+
request: input.request ?? currentRequestMeta2().request
|
|
216
233
|
});
|
|
217
234
|
} catch {
|
|
218
235
|
return renderKernelErrorChrome(input);
|
|
@@ -54,19 +54,36 @@ function sdkClientClassName() {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
// ../../src/core/http/clientIp.ts
|
|
57
|
+
import { currentRequestMeta } from "@getstrata/core/http/requestMetaContext";
|
|
57
58
|
function trustForwardedFor(env = process.env) {
|
|
58
59
|
return (env.TRUST_FORWARDED_FOR ?? "false") === "true";
|
|
59
60
|
}
|
|
60
|
-
function
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
function isPrivateAddress(address) {
|
|
62
|
+
const ip = address.replace(/^::ffff:/i, "");
|
|
63
|
+
return ip === "::1" || ip === "localhost" || /^127\./.test(ip) || /^10\./.test(ip) || /^192\.168\./.test(ip) || /^172\.(1[6-9]|2\d|3[01])\./.test(ip) || /^169\.254\./.test(ip) || /^f[cd][0-9a-f]{2}:/i.test(ip) || /^fe80:/i.test(ip);
|
|
64
|
+
}
|
|
65
|
+
function forwardedClientIp(request) {
|
|
66
|
+
const hops = (request.headers.get("x-forwarded-for") ?? "").split(",").map((hop) => hop.trim()).filter(Boolean);
|
|
67
|
+
for (let index = hops.length - 1;index >= 0; index -= 1) {
|
|
68
|
+
const hop = hops[index];
|
|
69
|
+
if (hop && !isPrivateAddress(hop)) {
|
|
70
|
+
return hop;
|
|
71
|
+
}
|
|
63
72
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return forwarded;
|
|
73
|
+
if (hops.length > 0) {
|
|
74
|
+
return hops[hops.length - 1];
|
|
67
75
|
}
|
|
68
76
|
return request.headers.get("x-real-ip")?.trim() || undefined;
|
|
69
77
|
}
|
|
78
|
+
function readClientIp(request, env = process.env) {
|
|
79
|
+
if (trustForwardedFor(env)) {
|
|
80
|
+
const forwarded = forwardedClientIp(request);
|
|
81
|
+
if (forwarded) {
|
|
82
|
+
return forwarded;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return currentRequestMeta().ipAddress ?? undefined;
|
|
86
|
+
}
|
|
70
87
|
|
|
71
88
|
// ../../src/core/runtime/frontendMode.ts
|
|
72
89
|
var FRONTEND_MODES = ["api", "server-htmx", "spa-react", "hybrid"];
|
|
@@ -108,7 +125,7 @@ function readSpaPrefix() {
|
|
|
108
125
|
}
|
|
109
126
|
|
|
110
127
|
// ../../src/core/view/webErrorView.ts
|
|
111
|
-
import { currentRequestMeta } from "@getstrata/core/http/requestMetaContext";
|
|
128
|
+
import { currentRequestMeta as currentRequestMeta2 } from "@getstrata/core/http/requestMetaContext";
|
|
112
129
|
|
|
113
130
|
// ../../src/core/view/htmlResponse.ts
|
|
114
131
|
function withCharset(contentType) {
|
|
@@ -209,7 +226,7 @@ async function renderWebErrorHtml(input) {
|
|
|
209
226
|
try {
|
|
210
227
|
return await render({
|
|
211
228
|
...input,
|
|
212
|
-
request: input.request ??
|
|
229
|
+
request: input.request ?? currentRequestMeta2().request
|
|
213
230
|
});
|
|
214
231
|
} catch {
|
|
215
232
|
return renderKernelErrorChrome(input);
|
|
@@ -6,6 +6,7 @@ import { toHttpError as toHttpError3 } from "@getstrata/core/errors/http";
|
|
|
6
6
|
import {
|
|
7
7
|
BadRequestError,
|
|
8
8
|
ConflictError,
|
|
9
|
+
InternalServerError,
|
|
9
10
|
toHttpError,
|
|
10
11
|
UnprocessableEntityError
|
|
11
12
|
} from "@getstrata/core/errors/http";
|
|
@@ -24,14 +25,55 @@ function getPostgresSqlState(error) {
|
|
|
24
25
|
}
|
|
25
26
|
return;
|
|
26
27
|
}
|
|
28
|
+
var MYSQL_ERRNO_MESSAGES = {
|
|
29
|
+
1062: () => new ConflictError("A record with these values already exists."),
|
|
30
|
+
1451: () => new UnprocessableEntityError("Record is still referenced by other records."),
|
|
31
|
+
1452: () => new UnprocessableEntityError("Referenced record does not exist."),
|
|
32
|
+
1048: () => new BadRequestError("Required field is missing."),
|
|
33
|
+
3819: () => new BadRequestError("Value violates a database constraint.")
|
|
34
|
+
};
|
|
35
|
+
function mapSqliteError(error) {
|
|
36
|
+
const code = typeof error.code === "string" ? error.code : "";
|
|
37
|
+
if (!code.startsWith("SQLITE_")) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
if (code === "SQLITE_CONSTRAINT_UNIQUE" || code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
|
|
41
|
+
return new ConflictError("A record with these values already exists.");
|
|
42
|
+
}
|
|
43
|
+
if (code === "SQLITE_CONSTRAINT_FOREIGNKEY") {
|
|
44
|
+
return new UnprocessableEntityError("Referenced record does not exist.");
|
|
45
|
+
}
|
|
46
|
+
if (code === "SQLITE_CONSTRAINT_NOTNULL") {
|
|
47
|
+
return new BadRequestError("Required field is missing.");
|
|
48
|
+
}
|
|
49
|
+
if (code.startsWith("SQLITE_CONSTRAINT")) {
|
|
50
|
+
return new BadRequestError("Value violates a database constraint.");
|
|
51
|
+
}
|
|
52
|
+
return new InternalServerError("Database operation failed.");
|
|
53
|
+
}
|
|
54
|
+
function mapMysqlError(error) {
|
|
55
|
+
const code = typeof error.code === "string" ? error.code : "";
|
|
56
|
+
if (!code.startsWith("ER_")) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const factory = typeof error.errno === "number" ? MYSQL_ERRNO_MESSAGES[error.errno] : undefined;
|
|
60
|
+
return factory ? factory() : new InternalServerError("Database operation failed.");
|
|
61
|
+
}
|
|
27
62
|
function mapDatabaseError(error) {
|
|
28
63
|
const httpError = toHttpError(error);
|
|
29
64
|
if (httpError) {
|
|
30
65
|
return httpError;
|
|
31
66
|
}
|
|
32
67
|
if (!isPostgresError(error)) {
|
|
33
|
-
|
|
34
|
-
|
|
68
|
+
return new InternalServerError;
|
|
69
|
+
}
|
|
70
|
+
const sqlite = mapSqliteError(error);
|
|
71
|
+
if (sqlite) {
|
|
72
|
+
return sqlite;
|
|
73
|
+
}
|
|
74
|
+
const mysql = mapMysqlError(error);
|
|
75
|
+
if (mysql) {
|
|
76
|
+
return mysql;
|
|
35
77
|
}
|
|
36
78
|
const sqlState = getPostgresSqlState(error);
|
|
37
79
|
switch (sqlState) {
|
|
@@ -52,10 +94,7 @@ function mapDatabaseError(error) {
|
|
|
52
94
|
constraint: error.constraint
|
|
53
95
|
});
|
|
54
96
|
default:
|
|
55
|
-
return new
|
|
56
|
-
code: error.code,
|
|
57
|
-
sqlState
|
|
58
|
-
});
|
|
97
|
+
return new InternalServerError("Database operation failed.");
|
|
59
98
|
}
|
|
60
99
|
}
|
|
61
100
|
async function withDatabaseErrorHandling(operation) {
|
|
@@ -68,6 +107,12 @@ async function withDatabaseErrorHandling(operation) {
|
|
|
68
107
|
|
|
69
108
|
// ../../src/core/http/webErrorResponse.ts
|
|
70
109
|
import { toHttpError as toHttpError2, ValidationError } from "@getstrata/core/errors/http";
|
|
110
|
+
import { appLogger } from "@getstrata/core/logging/logger";
|
|
111
|
+
|
|
112
|
+
// ../../src/core/runtime/appEnv.ts
|
|
113
|
+
function isProductionEnv(env = process.env) {
|
|
114
|
+
return env.APP_ENV === "production" || env.NODE_ENV === "production";
|
|
115
|
+
}
|
|
71
116
|
|
|
72
117
|
// ../../src/core/runtime/frontendMode.ts
|
|
73
118
|
var FRONTEND_MODES = ["api", "server-htmx", "spa-react", "hybrid"];
|
|
@@ -319,14 +364,27 @@ function errorPageTitle(status, message) {
|
|
|
319
364
|
return message;
|
|
320
365
|
}
|
|
321
366
|
function publicErrorMessage(status, message) {
|
|
322
|
-
if (status >= 500 &&
|
|
367
|
+
if (status >= 500 && isProductionEnv()) {
|
|
368
|
+
return "Something went wrong.";
|
|
369
|
+
}
|
|
323
370
|
return message;
|
|
324
371
|
}
|
|
372
|
+
function logServerError(error, mappedError) {
|
|
373
|
+
if (mappedError.status < 500) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
appLogger.error("Unhandled request error", {
|
|
377
|
+
status: mappedError.status,
|
|
378
|
+
error: error instanceof Error ? error.message : String(error),
|
|
379
|
+
stack: error instanceof Error ? error.stack : undefined
|
|
380
|
+
});
|
|
381
|
+
}
|
|
325
382
|
async function webErrorResponse(error, request) {
|
|
326
383
|
if (!request || !isViewsEnabled() || requestPrefersJson(request)) {
|
|
327
384
|
return null;
|
|
328
385
|
}
|
|
329
386
|
const mappedError = toHttpError2(error) ?? mapDatabaseError(error);
|
|
387
|
+
logServerError(error, mappedError);
|
|
330
388
|
if (mappedError.status === 401) {
|
|
331
389
|
return Response.redirect(loginRedirectLocation(request), 302);
|
|
332
390
|
}
|
|
@@ -355,6 +413,7 @@ function noContentResponse() {
|
|
|
355
413
|
}
|
|
356
414
|
function errorResponse(error) {
|
|
357
415
|
const mappedError = toHttpError3(error) ?? mapDatabaseError(error);
|
|
416
|
+
logServerError(error, mappedError);
|
|
358
417
|
return Response.json({
|
|
359
418
|
error: mappedError.message,
|
|
360
419
|
...mappedError.details === undefined ? {} : { details: mappedError.details }
|
|
@@ -57,19 +57,36 @@ function sdkClientClassName() {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
// ../../src/core/http/clientIp.ts
|
|
60
|
+
import { currentRequestMeta } from "@getstrata/core/http/requestMetaContext";
|
|
60
61
|
function trustForwardedFor(env = process.env) {
|
|
61
62
|
return (env.TRUST_FORWARDED_FOR ?? "false") === "true";
|
|
62
63
|
}
|
|
63
|
-
function
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
function isPrivateAddress(address) {
|
|
65
|
+
const ip = address.replace(/^::ffff:/i, "");
|
|
66
|
+
return ip === "::1" || ip === "localhost" || /^127\./.test(ip) || /^10\./.test(ip) || /^192\.168\./.test(ip) || /^172\.(1[6-9]|2\d|3[01])\./.test(ip) || /^169\.254\./.test(ip) || /^f[cd][0-9a-f]{2}:/i.test(ip) || /^fe80:/i.test(ip);
|
|
67
|
+
}
|
|
68
|
+
function forwardedClientIp(request) {
|
|
69
|
+
const hops = (request.headers.get("x-forwarded-for") ?? "").split(",").map((hop) => hop.trim()).filter(Boolean);
|
|
70
|
+
for (let index = hops.length - 1;index >= 0; index -= 1) {
|
|
71
|
+
const hop = hops[index];
|
|
72
|
+
if (hop && !isPrivateAddress(hop)) {
|
|
73
|
+
return hop;
|
|
74
|
+
}
|
|
66
75
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return forwarded;
|
|
76
|
+
if (hops.length > 0) {
|
|
77
|
+
return hops[hops.length - 1];
|
|
70
78
|
}
|
|
71
79
|
return request.headers.get("x-real-ip")?.trim() || undefined;
|
|
72
80
|
}
|
|
81
|
+
function readClientIp(request, env = process.env) {
|
|
82
|
+
if (trustForwardedFor(env)) {
|
|
83
|
+
const forwarded = forwardedClientIp(request);
|
|
84
|
+
if (forwarded) {
|
|
85
|
+
return forwarded;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return currentRequestMeta().ipAddress ?? undefined;
|
|
89
|
+
}
|
|
73
90
|
|
|
74
91
|
// ../../src/core/http/scimThrottleMiddleware.ts
|
|
75
92
|
var memoryBuckets = new Map;
|
|
@@ -59,19 +59,36 @@ function sdkClientClassName() {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
// ../../src/core/http/clientIp.ts
|
|
62
|
+
import { currentRequestMeta } from "@getstrata/core/http/requestMetaContext";
|
|
62
63
|
function trustForwardedFor(env = process.env) {
|
|
63
64
|
return (env.TRUST_FORWARDED_FOR ?? "false") === "true";
|
|
64
65
|
}
|
|
65
|
-
function
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
function isPrivateAddress(address) {
|
|
67
|
+
const ip = address.replace(/^::ffff:/i, "");
|
|
68
|
+
return ip === "::1" || ip === "localhost" || /^127\./.test(ip) || /^10\./.test(ip) || /^192\.168\./.test(ip) || /^172\.(1[6-9]|2\d|3[01])\./.test(ip) || /^169\.254\./.test(ip) || /^f[cd][0-9a-f]{2}:/i.test(ip) || /^fe80:/i.test(ip);
|
|
69
|
+
}
|
|
70
|
+
function forwardedClientIp(request) {
|
|
71
|
+
const hops = (request.headers.get("x-forwarded-for") ?? "").split(",").map((hop) => hop.trim()).filter(Boolean);
|
|
72
|
+
for (let index = hops.length - 1;index >= 0; index -= 1) {
|
|
73
|
+
const hop = hops[index];
|
|
74
|
+
if (hop && !isPrivateAddress(hop)) {
|
|
75
|
+
return hop;
|
|
76
|
+
}
|
|
68
77
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return forwarded;
|
|
78
|
+
if (hops.length > 0) {
|
|
79
|
+
return hops[hops.length - 1];
|
|
72
80
|
}
|
|
73
81
|
return request.headers.get("x-real-ip")?.trim() || undefined;
|
|
74
82
|
}
|
|
83
|
+
function readClientIp(request, env = process.env) {
|
|
84
|
+
if (trustForwardedFor(env)) {
|
|
85
|
+
const forwarded = forwardedClientIp(request);
|
|
86
|
+
if (forwarded) {
|
|
87
|
+
return forwarded;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return currentRequestMeta().ipAddress ?? undefined;
|
|
91
|
+
}
|
|
75
92
|
|
|
76
93
|
// ../../src/core/runtime/frontendMode.ts
|
|
77
94
|
var FRONTEND_MODES = ["api", "server-htmx", "spa-react", "hybrid"];
|
|
@@ -113,7 +130,7 @@ function readSpaPrefix() {
|
|
|
113
130
|
}
|
|
114
131
|
|
|
115
132
|
// ../../src/core/view/webErrorView.ts
|
|
116
|
-
import { currentRequestMeta } from "@getstrata/core/http/requestMetaContext";
|
|
133
|
+
import { currentRequestMeta as currentRequestMeta2 } from "@getstrata/core/http/requestMetaContext";
|
|
117
134
|
|
|
118
135
|
// ../../src/core/view/htmlResponse.ts
|
|
119
136
|
function withCharset(contentType) {
|
|
@@ -214,7 +231,7 @@ async function renderWebErrorHtml(input) {
|
|
|
214
231
|
try {
|
|
215
232
|
return await render({
|
|
216
233
|
...input,
|
|
217
|
-
request: input.request ??
|
|
234
|
+
request: input.request ?? currentRequestMeta2().request
|
|
218
235
|
});
|
|
219
236
|
} catch {
|
|
220
237
|
return renderKernelErrorChrome(input);
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// ../../src/core/http/webErrorResponse.ts
|
|
3
3
|
import { toHttpError as toHttpError2, ValidationError } from "@getstrata/core/errors/http";
|
|
4
|
+
import { appLogger } from "@getstrata/core/logging/logger";
|
|
4
5
|
|
|
5
6
|
// ../../src/core/database/errors.ts
|
|
6
7
|
import {
|
|
7
8
|
BadRequestError,
|
|
8
9
|
ConflictError,
|
|
10
|
+
InternalServerError,
|
|
9
11
|
toHttpError,
|
|
10
12
|
UnprocessableEntityError
|
|
11
13
|
} from "@getstrata/core/errors/http";
|
|
@@ -24,14 +26,55 @@ function getPostgresSqlState(error) {
|
|
|
24
26
|
}
|
|
25
27
|
return;
|
|
26
28
|
}
|
|
29
|
+
var MYSQL_ERRNO_MESSAGES = {
|
|
30
|
+
1062: () => new ConflictError("A record with these values already exists."),
|
|
31
|
+
1451: () => new UnprocessableEntityError("Record is still referenced by other records."),
|
|
32
|
+
1452: () => new UnprocessableEntityError("Referenced record does not exist."),
|
|
33
|
+
1048: () => new BadRequestError("Required field is missing."),
|
|
34
|
+
3819: () => new BadRequestError("Value violates a database constraint.")
|
|
35
|
+
};
|
|
36
|
+
function mapSqliteError(error) {
|
|
37
|
+
const code = typeof error.code === "string" ? error.code : "";
|
|
38
|
+
if (!code.startsWith("SQLITE_")) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
if (code === "SQLITE_CONSTRAINT_UNIQUE" || code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
|
|
42
|
+
return new ConflictError("A record with these values already exists.");
|
|
43
|
+
}
|
|
44
|
+
if (code === "SQLITE_CONSTRAINT_FOREIGNKEY") {
|
|
45
|
+
return new UnprocessableEntityError("Referenced record does not exist.");
|
|
46
|
+
}
|
|
47
|
+
if (code === "SQLITE_CONSTRAINT_NOTNULL") {
|
|
48
|
+
return new BadRequestError("Required field is missing.");
|
|
49
|
+
}
|
|
50
|
+
if (code.startsWith("SQLITE_CONSTRAINT")) {
|
|
51
|
+
return new BadRequestError("Value violates a database constraint.");
|
|
52
|
+
}
|
|
53
|
+
return new InternalServerError("Database operation failed.");
|
|
54
|
+
}
|
|
55
|
+
function mapMysqlError(error) {
|
|
56
|
+
const code = typeof error.code === "string" ? error.code : "";
|
|
57
|
+
if (!code.startsWith("ER_")) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const factory = typeof error.errno === "number" ? MYSQL_ERRNO_MESSAGES[error.errno] : undefined;
|
|
61
|
+
return factory ? factory() : new InternalServerError("Database operation failed.");
|
|
62
|
+
}
|
|
27
63
|
function mapDatabaseError(error) {
|
|
28
64
|
const httpError = toHttpError(error);
|
|
29
65
|
if (httpError) {
|
|
30
66
|
return httpError;
|
|
31
67
|
}
|
|
32
68
|
if (!isPostgresError(error)) {
|
|
33
|
-
|
|
34
|
-
|
|
69
|
+
return new InternalServerError;
|
|
70
|
+
}
|
|
71
|
+
const sqlite = mapSqliteError(error);
|
|
72
|
+
if (sqlite) {
|
|
73
|
+
return sqlite;
|
|
74
|
+
}
|
|
75
|
+
const mysql = mapMysqlError(error);
|
|
76
|
+
if (mysql) {
|
|
77
|
+
return mysql;
|
|
35
78
|
}
|
|
36
79
|
const sqlState = getPostgresSqlState(error);
|
|
37
80
|
switch (sqlState) {
|
|
@@ -52,10 +95,7 @@ function mapDatabaseError(error) {
|
|
|
52
95
|
constraint: error.constraint
|
|
53
96
|
});
|
|
54
97
|
default:
|
|
55
|
-
return new
|
|
56
|
-
code: error.code,
|
|
57
|
-
sqlState
|
|
58
|
-
});
|
|
98
|
+
return new InternalServerError("Database operation failed.");
|
|
59
99
|
}
|
|
60
100
|
}
|
|
61
101
|
async function withDatabaseErrorHandling(operation) {
|
|
@@ -66,6 +106,11 @@ async function withDatabaseErrorHandling(operation) {
|
|
|
66
106
|
}
|
|
67
107
|
}
|
|
68
108
|
|
|
109
|
+
// ../../src/core/runtime/appEnv.ts
|
|
110
|
+
function isProductionEnv(env = process.env) {
|
|
111
|
+
return env.APP_ENV === "production" || env.NODE_ENV === "production";
|
|
112
|
+
}
|
|
113
|
+
|
|
69
114
|
// ../../src/core/runtime/frontendMode.ts
|
|
70
115
|
var FRONTEND_MODES = ["api", "server-htmx", "spa-react", "hybrid"];
|
|
71
116
|
var DEFAULT_SPA_PREFIX = "/app";
|
|
@@ -316,14 +361,27 @@ function errorPageTitle(status, message) {
|
|
|
316
361
|
return message;
|
|
317
362
|
}
|
|
318
363
|
function publicErrorMessage(status, message) {
|
|
319
|
-
if (status >= 500 &&
|
|
364
|
+
if (status >= 500 && isProductionEnv()) {
|
|
365
|
+
return "Something went wrong.";
|
|
366
|
+
}
|
|
320
367
|
return message;
|
|
321
368
|
}
|
|
369
|
+
function logServerError(error, mappedError) {
|
|
370
|
+
if (mappedError.status < 500) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
appLogger.error("Unhandled request error", {
|
|
374
|
+
status: mappedError.status,
|
|
375
|
+
error: error instanceof Error ? error.message : String(error),
|
|
376
|
+
stack: error instanceof Error ? error.stack : undefined
|
|
377
|
+
});
|
|
378
|
+
}
|
|
322
379
|
async function webErrorResponse(error, request) {
|
|
323
380
|
if (!request || !isViewsEnabled() || requestPrefersJson(request)) {
|
|
324
381
|
return null;
|
|
325
382
|
}
|
|
326
383
|
const mappedError = toHttpError2(error) ?? mapDatabaseError(error);
|
|
384
|
+
logServerError(error, mappedError);
|
|
327
385
|
if (mappedError.status === 401) {
|
|
328
386
|
return Response.redirect(loginRedirectLocation(request), 302);
|
|
329
387
|
}
|
|
@@ -337,6 +395,7 @@ async function webErrorResponse(error, request) {
|
|
|
337
395
|
});
|
|
338
396
|
}
|
|
339
397
|
export {
|
|
398
|
+
logServerError,
|
|
340
399
|
normalizeFieldErrors,
|
|
341
400
|
webErrorResponse
|
|
342
401
|
};
|
|
@@ -191,14 +191,16 @@ async function safeFetch(input, init = {}, options = {}) {
|
|
|
191
191
|
import { repositoryConnection as db } from "@getstrata/core/database/repositoryConnection";
|
|
192
192
|
|
|
193
193
|
// ../../src/core/tenant/tenancyConfig.ts
|
|
194
|
+
var TENANCY_DRIVERS = ["rls", "column", "none"];
|
|
194
195
|
function readTenancyDriver(env = process.env) {
|
|
195
|
-
|
|
196
|
-
|
|
196
|
+
const raw = env.TENANCY_DRIVER?.trim();
|
|
197
|
+
if (raw === undefined || raw === "") {
|
|
198
|
+
return "rls";
|
|
197
199
|
}
|
|
198
|
-
if (
|
|
199
|
-
return
|
|
200
|
+
if (TENANCY_DRIVERS.includes(raw)) {
|
|
201
|
+
return raw;
|
|
200
202
|
}
|
|
201
|
-
|
|
203
|
+
throw new Error(`TENANCY_DRIVER must be one of ${TENANCY_DRIVERS.join(", ")}; received "${raw}".`);
|
|
202
204
|
}
|
|
203
205
|
function isTenancyEnabled(env = process.env) {
|
|
204
206
|
return readTenancyDriver(env) !== "none";
|
|
@@ -1,21 +1,38 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// ../../src/core/logging/requestLoggingMiddleware.ts
|
|
3
|
-
import { currentRequestMeta, runWithRequestMeta } from "@getstrata/core/http/requestMetaContext";
|
|
3
|
+
import { currentRequestMeta as currentRequestMeta2, runWithRequestMeta } from "@getstrata/core/http/requestMetaContext";
|
|
4
4
|
|
|
5
5
|
// ../../src/core/http/clientIp.ts
|
|
6
|
+
import { currentRequestMeta } from "@getstrata/core/http/requestMetaContext";
|
|
6
7
|
function trustForwardedFor(env = process.env) {
|
|
7
8
|
return (env.TRUST_FORWARDED_FOR ?? "false") === "true";
|
|
8
9
|
}
|
|
9
|
-
function
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
function isPrivateAddress(address) {
|
|
11
|
+
const ip = address.replace(/^::ffff:/i, "");
|
|
12
|
+
return ip === "::1" || ip === "localhost" || /^127\./.test(ip) || /^10\./.test(ip) || /^192\.168\./.test(ip) || /^172\.(1[6-9]|2\d|3[01])\./.test(ip) || /^169\.254\./.test(ip) || /^f[cd][0-9a-f]{2}:/i.test(ip) || /^fe80:/i.test(ip);
|
|
13
|
+
}
|
|
14
|
+
function forwardedClientIp(request) {
|
|
15
|
+
const hops = (request.headers.get("x-forwarded-for") ?? "").split(",").map((hop) => hop.trim()).filter(Boolean);
|
|
16
|
+
for (let index = hops.length - 1;index >= 0; index -= 1) {
|
|
17
|
+
const hop = hops[index];
|
|
18
|
+
if (hop && !isPrivateAddress(hop)) {
|
|
19
|
+
return hop;
|
|
20
|
+
}
|
|
12
21
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
return forwarded;
|
|
22
|
+
if (hops.length > 0) {
|
|
23
|
+
return hops[hops.length - 1];
|
|
16
24
|
}
|
|
17
25
|
return request.headers.get("x-real-ip")?.trim() || undefined;
|
|
18
26
|
}
|
|
27
|
+
function readClientIp(request, env = process.env) {
|
|
28
|
+
if (trustForwardedFor(env)) {
|
|
29
|
+
const forwarded = forwardedClientIp(request);
|
|
30
|
+
if (forwarded) {
|
|
31
|
+
return forwarded;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return currentRequestMeta().ipAddress ?? undefined;
|
|
35
|
+
}
|
|
19
36
|
|
|
20
37
|
// ../../src/core/logging/logger.ts
|
|
21
38
|
class Logger {
|
|
@@ -56,9 +73,10 @@ var appLogger = new Logger("app");
|
|
|
56
73
|
// ../../src/core/logging/requestLoggingMiddleware.ts
|
|
57
74
|
function createRequestLoggingMiddleware() {
|
|
58
75
|
return async (request, next) => {
|
|
76
|
+
const ipAddress = readClientIp(request) ?? null;
|
|
59
77
|
return await runWithRequestMeta({
|
|
60
|
-
...
|
|
61
|
-
ipAddress
|
|
78
|
+
...currentRequestMeta2(),
|
|
79
|
+
ipAddress,
|
|
62
80
|
userAgent: request.headers.get("user-agent"),
|
|
63
81
|
request
|
|
64
82
|
}, async () => {
|
|
@@ -71,7 +89,8 @@ function createRequestLoggingMiddleware() {
|
|
|
71
89
|
method: request.method,
|
|
72
90
|
path: new URL(request.url).pathname,
|
|
73
91
|
status: response.status,
|
|
74
|
-
durationMs
|
|
92
|
+
durationMs,
|
|
93
|
+
ipAddress
|
|
75
94
|
});
|
|
76
95
|
return response;
|
|
77
96
|
});
|
|
@@ -103,7 +103,7 @@ var PUBLIC_ROUTE_DESCRIPTIONS = {
|
|
|
103
103
|
"GET /health": "Liveness probe",
|
|
104
104
|
"GET /ready": "Readiness probe",
|
|
105
105
|
"GET /metrics": "Prometheus metrics",
|
|
106
|
-
"GET /api/user": "Current authenticated
|
|
106
|
+
"GET /api/user": "Current authenticated user",
|
|
107
107
|
"POST /api/login": "Login with email and password",
|
|
108
108
|
"POST /api/auth/token": "Mint a short-lived JWT with email and password",
|
|
109
109
|
"POST /api/apply/login": "Candidate portal login (opaque token)",
|