@getstrata/core 1.0.3 → 1.0.4
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 +9 -0
- package/dist/core/runtime/appEnv.d.ts +16 -2
- package/dist/core/runtime/appKeyPrefix.d.ts +8 -1
- package/dist/entries/audit/exportAuditLogs.js +83 -18
- package/dist/entries/audit/siemFormatter.js +32 -3
- package/dist/entries/auth/intendedUrlCookie.js +33 -4
- package/dist/entries/auth/jwt.js +36 -4
- package/dist/entries/auth/jwtGuard.js +36 -4
- package/dist/entries/auth/oauth/providers.js +32 -3
- package/dist/entries/auth/oauth/samlProvider.js +32 -3
- package/dist/entries/auth/passwordConfirmCookie.js +35 -6
- package/dist/entries/auth/sessionCookie.js +35 -6
- package/dist/entries/auth/sessionGuard.js +35 -6
- package/dist/entries/auth/tokenHash.js +33 -4
- package/dist/entries/cache/createCacheStore.js +32 -3
- package/dist/entries/facades.js +32 -3
- package/dist/entries/http/corsMiddleware.js +16 -1
- package/dist/entries/http/csrfMiddleware.js +34 -5
- package/dist/entries/http/csrfToken.js +34 -5
- package/dist/entries/http/flashMiddleware.js +33 -4
- package/dist/entries/http/flashSession.js +33 -4
- package/dist/entries/http/loginThrottleMiddleware.js +1 -373
- package/dist/entries/http/memoryThrottleMiddleware.js +32 -58
- package/dist/entries/http/response.js +16 -56
- package/dist/entries/http/scimThrottleMiddleware.js +32 -3
- package/dist/entries/http/securityHeadersMiddleware.js +32 -3
- package/dist/entries/http/signedUrl.js +33 -4
- package/dist/entries/http/throttleMiddleware.js +32 -58
- package/dist/entries/http/webErrorResponse.js +1 -401
- package/dist/entries/jobs/exportAuditLogsJob.js +83 -18
- package/dist/entries/lifecycle/gracefulShutdown.js +1 -50
- package/dist/entries/mail/mailer.js +32 -3
- package/dist/entries/openapi/generator.js +32 -3
- package/dist/entries/queue/createAppQueue.js +32 -3
- package/dist/entries/queue/publicQueue.js +32 -3
- package/dist/entries/queue/queueMetrics.js +32 -3
- package/dist/entries/queue/redisQueue.js +32 -3
- package/dist/entries/runtime/appEnv.js +18 -1
- package/dist/entries/runtime/appKeyPrefix.js +1 -70
- package/dist/entries/security/oauthState.js +33 -4
- package/dist/entries/security/safeFetch.js +32 -9
- package/dist/entries/security/safeUrl.js +1 -105
- package/dist/entries/security/totp.js +32 -3
- package/dist/entries/tenant/databaseTenantContext.js +48 -6
- package/dist/entries/tracing/tracingMiddleware.js +32 -3
- package/dist/entries/view.js +1 -778
- package/dist/framework/public-api.d.ts +27 -18
- package/dist/index.js +442 -53
- package/package.json +2 -2
|
@@ -7,6 +7,26 @@ import {
|
|
|
7
7
|
} from "@getstrata/core/http/contentSecurityPolicy";
|
|
8
8
|
import { currentRequestMeta, runWithRequestMeta } from "@getstrata/core/http/requestMetaContext";
|
|
9
9
|
|
|
10
|
+
// ../../src/core/runtime/appEnv.ts
|
|
11
|
+
var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
|
|
12
|
+
function normalizeEnvValue(value) {
|
|
13
|
+
return (value ?? "").trim().toLowerCase();
|
|
14
|
+
}
|
|
15
|
+
function isProductionEnv(env = process.env) {
|
|
16
|
+
const appEnv = normalizeEnvValue(env.APP_ENV);
|
|
17
|
+
const nodeEnv = normalizeEnvValue(env.NODE_ENV);
|
|
18
|
+
if (appEnv === "production" || nodeEnv === "production") {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
if (appEnv === "") {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
return !NON_PRODUCTION_APP_ENVS.has(appEnv);
|
|
25
|
+
}
|
|
26
|
+
function envFlagEnabled(value) {
|
|
27
|
+
return value === "true";
|
|
28
|
+
}
|
|
29
|
+
|
|
10
30
|
// ../../src/core/runtime/appKeyPrefix.ts
|
|
11
31
|
function appKeyPrefix() {
|
|
12
32
|
return process.env.APP_KEY_PREFIX?.trim() || "strata";
|
|
@@ -17,6 +37,18 @@ function appCookieName(kind) {
|
|
|
17
37
|
function appDevSecret(kind) {
|
|
18
38
|
return `${appKeyPrefix()}-dev-${kind}`;
|
|
19
39
|
}
|
|
40
|
+
function requireConfiguredSecret(names, devKind, env = process.env) {
|
|
41
|
+
for (const name of names) {
|
|
42
|
+
const value = env[name]?.trim();
|
|
43
|
+
if (value) {
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (isProductionEnv(env)) {
|
|
48
|
+
throw new Error(`${names[0]} must be set outside development. Do not derive secrets from the app name.`);
|
|
49
|
+
}
|
|
50
|
+
return appDevSecret(devKind);
|
|
51
|
+
}
|
|
20
52
|
function namespacedRedisKey(kind) {
|
|
21
53
|
return `${appKeyPrefix()}:${kind}`;
|
|
22
54
|
}
|
|
@@ -34,9 +66,6 @@ function appUserAgent() {
|
|
|
34
66
|
function otelServiceName() {
|
|
35
67
|
return process.env.OTEL_SERVICE_NAME?.trim() || `${appKeyPrefix()}-api`;
|
|
36
68
|
}
|
|
37
|
-
function webhookSignatureHeader() {
|
|
38
|
-
return process.env.WEBHOOK_SIGNATURE_HEADER?.trim() || `x-${appKeyPrefix()}-signature`;
|
|
39
|
-
}
|
|
40
69
|
function appDisplayName() {
|
|
41
70
|
return process.env.APP_NAME?.trim() || "Strata";
|
|
42
71
|
}
|
|
@@ -4,6 +4,26 @@ import { createHmac } from "crypto";
|
|
|
4
4
|
import { ForbiddenError } from "@getstrata/core/errors/http";
|
|
5
5
|
import { timingSafeCompareString } from "@getstrata/core/security/timingSafeCompare";
|
|
6
6
|
|
|
7
|
+
// ../../src/core/runtime/appEnv.ts
|
|
8
|
+
var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
|
|
9
|
+
function normalizeEnvValue(value) {
|
|
10
|
+
return (value ?? "").trim().toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
function isProductionEnv(env = process.env) {
|
|
13
|
+
const appEnv = normalizeEnvValue(env.APP_ENV);
|
|
14
|
+
const nodeEnv = normalizeEnvValue(env.NODE_ENV);
|
|
15
|
+
if (appEnv === "production" || nodeEnv === "production") {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
if (appEnv === "") {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return !NON_PRODUCTION_APP_ENVS.has(appEnv);
|
|
22
|
+
}
|
|
23
|
+
function envFlagEnabled(value) {
|
|
24
|
+
return value === "true";
|
|
25
|
+
}
|
|
26
|
+
|
|
7
27
|
// ../../src/core/runtime/appKeyPrefix.ts
|
|
8
28
|
function appKeyPrefix() {
|
|
9
29
|
return process.env.APP_KEY_PREFIX?.trim() || "strata";
|
|
@@ -14,6 +34,18 @@ function appCookieName(kind) {
|
|
|
14
34
|
function appDevSecret(kind) {
|
|
15
35
|
return `${appKeyPrefix()}-dev-${kind}`;
|
|
16
36
|
}
|
|
37
|
+
function requireConfiguredSecret(names, devKind, env = process.env) {
|
|
38
|
+
for (const name of names) {
|
|
39
|
+
const value = env[name]?.trim();
|
|
40
|
+
if (value) {
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (isProductionEnv(env)) {
|
|
45
|
+
throw new Error(`${names[0]} must be set outside development. Do not derive secrets from the app name.`);
|
|
46
|
+
}
|
|
47
|
+
return appDevSecret(devKind);
|
|
48
|
+
}
|
|
17
49
|
function namespacedRedisKey(kind) {
|
|
18
50
|
return `${appKeyPrefix()}:${kind}`;
|
|
19
51
|
}
|
|
@@ -31,9 +63,6 @@ function appUserAgent() {
|
|
|
31
63
|
function otelServiceName() {
|
|
32
64
|
return process.env.OTEL_SERVICE_NAME?.trim() || `${appKeyPrefix()}-api`;
|
|
33
65
|
}
|
|
34
|
-
function webhookSignatureHeader() {
|
|
35
|
-
return process.env.WEBHOOK_SIGNATURE_HEADER?.trim() || `x-${appKeyPrefix()}-signature`;
|
|
36
|
-
}
|
|
37
66
|
function appDisplayName() {
|
|
38
67
|
return process.env.APP_NAME?.trim() || "Strata";
|
|
39
68
|
}
|
|
@@ -60,7 +89,7 @@ function sdkClientClassName() {
|
|
|
60
89
|
|
|
61
90
|
// ../../src/core/http/signedUrl.ts
|
|
62
91
|
function resolveSignedUrlSecret() {
|
|
63
|
-
return
|
|
92
|
+
return requireConfiguredSecret(["SIGNED_URL_SECRET", "SESSION_SECRET", "OAUTH_STATE_SECRET"], "signed-url-secret");
|
|
64
93
|
}
|
|
65
94
|
function resolveSignedUrlOrigin() {
|
|
66
95
|
return (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
|
@@ -4,6 +4,26 @@ import { currentAuthUser } from "@getstrata/core/auth/authContext";
|
|
|
4
4
|
import { currentTenant, rateLimitMultiplierForPlan } from "@getstrata/core/tenant/tenantContext";
|
|
5
5
|
var {RedisClient } = globalThis.Bun;
|
|
6
6
|
|
|
7
|
+
// ../../src/core/runtime/appEnv.ts
|
|
8
|
+
var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
|
|
9
|
+
function normalizeEnvValue(value) {
|
|
10
|
+
return (value ?? "").trim().toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
function isProductionEnv(env = process.env) {
|
|
13
|
+
const appEnv = normalizeEnvValue(env.APP_ENV);
|
|
14
|
+
const nodeEnv = normalizeEnvValue(env.NODE_ENV);
|
|
15
|
+
if (appEnv === "production" || nodeEnv === "production") {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
if (appEnv === "") {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return !NON_PRODUCTION_APP_ENVS.has(appEnv);
|
|
22
|
+
}
|
|
23
|
+
function envFlagEnabled(value) {
|
|
24
|
+
return value === "true";
|
|
25
|
+
}
|
|
26
|
+
|
|
7
27
|
// ../../src/core/runtime/appKeyPrefix.ts
|
|
8
28
|
function appKeyPrefix() {
|
|
9
29
|
return process.env.APP_KEY_PREFIX?.trim() || "strata";
|
|
@@ -14,6 +34,18 @@ function appCookieName(kind) {
|
|
|
14
34
|
function appDevSecret(kind) {
|
|
15
35
|
return `${appKeyPrefix()}-dev-${kind}`;
|
|
16
36
|
}
|
|
37
|
+
function requireConfiguredSecret(names, devKind, env = process.env) {
|
|
38
|
+
for (const name of names) {
|
|
39
|
+
const value = env[name]?.trim();
|
|
40
|
+
if (value) {
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (isProductionEnv(env)) {
|
|
45
|
+
throw new Error(`${names[0]} must be set outside development. Do not derive secrets from the app name.`);
|
|
46
|
+
}
|
|
47
|
+
return appDevSecret(devKind);
|
|
48
|
+
}
|
|
17
49
|
function namespacedRedisKey(kind) {
|
|
18
50
|
return `${appKeyPrefix()}:${kind}`;
|
|
19
51
|
}
|
|
@@ -31,9 +63,6 @@ function appUserAgent() {
|
|
|
31
63
|
function otelServiceName() {
|
|
32
64
|
return process.env.OTEL_SERVICE_NAME?.trim() || `${appKeyPrefix()}-api`;
|
|
33
65
|
}
|
|
34
|
-
function webhookSignatureHeader() {
|
|
35
|
-
return process.env.WEBHOOK_SIGNATURE_HEADER?.trim() || `x-${appKeyPrefix()}-signature`;
|
|
36
|
-
}
|
|
37
66
|
function appDisplayName() {
|
|
38
67
|
return process.env.APP_NAME?.trim() || "Strata";
|
|
39
68
|
}
|
|
@@ -133,9 +162,6 @@ function readSpaPrefix() {
|
|
|
133
162
|
import { currentRequestMeta as currentRequestMeta2 } from "@getstrata/core/http/requestMetaContext";
|
|
134
163
|
|
|
135
164
|
// ../../src/core/view/htmlResponse.ts
|
|
136
|
-
function withCharset(contentType) {
|
|
137
|
-
return contentType.includes("charset=") ? contentType : `${contentType}; charset=utf-8`;
|
|
138
|
-
}
|
|
139
165
|
function htmlResponse(html, init = {}) {
|
|
140
166
|
return new Response(html, {
|
|
141
167
|
status: init.status ?? 200,
|
|
@@ -145,42 +171,9 @@ function htmlResponse(html, init = {}) {
|
|
|
145
171
|
}
|
|
146
172
|
});
|
|
147
173
|
}
|
|
148
|
-
function isHtmxRequest(request) {
|
|
149
|
-
return request.headers.get("HX-Request") === "true";
|
|
150
|
-
}
|
|
151
|
-
function redirectResponse(location, status = 302) {
|
|
152
|
-
return new Response(null, {
|
|
153
|
-
status,
|
|
154
|
-
headers: {
|
|
155
|
-
Location: location
|
|
156
|
-
}
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
function textResponse(body, init = {}) {
|
|
160
|
-
return new Response(body, {
|
|
161
|
-
status: init.status ?? 200,
|
|
162
|
-
headers: {
|
|
163
|
-
"Content-Type": "text/plain; charset=utf-8"
|
|
164
|
-
}
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
function xmlResponse(body, init = {}) {
|
|
168
|
-
return new Response(body, {
|
|
169
|
-
status: init.status ?? 200,
|
|
170
|
-
headers: {
|
|
171
|
-
"Content-Type": withCharset(init.contentType ?? "application/xml")
|
|
172
|
-
}
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
function rssResponse(body, init = {}) {
|
|
176
|
-
return xmlResponse(body, { ...init, contentType: "application/rss+xml" });
|
|
177
|
-
}
|
|
178
174
|
|
|
179
175
|
// ../../src/core/view/webErrorView.ts
|
|
180
176
|
var configuredErrorView = {};
|
|
181
|
-
function configureWebErrorView(options) {
|
|
182
|
-
configuredErrorView = { ...options };
|
|
183
|
-
}
|
|
184
177
|
function escapeHtml(value) {
|
|
185
178
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
186
179
|
}
|
|
@@ -214,15 +207,6 @@ function renderKernelErrorChrome(input) {
|
|
|
214
207
|
</html>
|
|
215
208
|
`;
|
|
216
209
|
}
|
|
217
|
-
function errorTemplateName(status) {
|
|
218
|
-
if (status === 404) {
|
|
219
|
-
return "errors/not-found";
|
|
220
|
-
}
|
|
221
|
-
if (status === 403) {
|
|
222
|
-
return "errors/forbidden";
|
|
223
|
-
}
|
|
224
|
-
return "errors/error";
|
|
225
|
-
}
|
|
226
210
|
async function renderWebErrorHtml(input) {
|
|
227
211
|
const render = configuredErrorView.render;
|
|
228
212
|
if (!render) {
|
|
@@ -240,16 +224,6 @@ async function renderWebErrorHtml(input) {
|
|
|
240
224
|
async function htmlErrorResponse(input) {
|
|
241
225
|
return htmlResponse(await renderWebErrorHtml(input), { status: input.status });
|
|
242
226
|
}
|
|
243
|
-
async function notFoundHtmlResponse(body) {
|
|
244
|
-
if (body !== undefined) {
|
|
245
|
-
return htmlResponse(body, { status: 404 });
|
|
246
|
-
}
|
|
247
|
-
return htmlErrorResponse({
|
|
248
|
-
status: 404,
|
|
249
|
-
title: "Not Found",
|
|
250
|
-
message: "The page you requested was not found."
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
227
|
|
|
254
228
|
// ../../src/core/http/contentNegotiation.ts
|
|
255
229
|
function requestPrefersJson(request) {
|
|
@@ -1,401 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// ../../src/core/http/webErrorResponse.ts
|
|
3
|
-
import { toHttpError as toHttpError2, ValidationError } from "@getstrata/core/errors/http";
|
|
4
|
-
import { appLogger } from "@getstrata/core/logging/logger";
|
|
5
|
-
|
|
6
|
-
// ../../src/core/database/errors.ts
|
|
7
|
-
import {
|
|
8
|
-
BadRequestError,
|
|
9
|
-
ConflictError,
|
|
10
|
-
InternalServerError,
|
|
11
|
-
toHttpError,
|
|
12
|
-
UnprocessableEntityError
|
|
13
|
-
} from "@getstrata/core/errors/http";
|
|
14
|
-
function isPostgresError(error) {
|
|
15
|
-
return typeof error === "object" && error !== null && (("errno" in error) || ("code" in error));
|
|
16
|
-
}
|
|
17
|
-
function getPostgresSqlState(error) {
|
|
18
|
-
if (typeof error.errno === "string" && /^\d{5}$/.test(error.errno)) {
|
|
19
|
-
return error.errno;
|
|
20
|
-
}
|
|
21
|
-
if (typeof error.errno === "number") {
|
|
22
|
-
return String(error.errno).padStart(5, "0");
|
|
23
|
-
}
|
|
24
|
-
if (typeof error.code === "string" && /^\d{5}$/.test(error.code)) {
|
|
25
|
-
return error.code;
|
|
26
|
-
}
|
|
27
|
-
return;
|
|
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
|
-
}
|
|
63
|
-
function mapDatabaseError(error) {
|
|
64
|
-
const httpError = toHttpError(error);
|
|
65
|
-
if (httpError) {
|
|
66
|
-
return httpError;
|
|
67
|
-
}
|
|
68
|
-
if (!isPostgresError(error)) {
|
|
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;
|
|
78
|
-
}
|
|
79
|
-
const sqlState = getPostgresSqlState(error);
|
|
80
|
-
switch (sqlState) {
|
|
81
|
-
case "23505":
|
|
82
|
-
return new ConflictError(error.detail ?? "A record with these values already exists.", {
|
|
83
|
-
constraint: error.constraint
|
|
84
|
-
});
|
|
85
|
-
case "23503":
|
|
86
|
-
return new UnprocessableEntityError(error.detail ?? "Referenced record does not exist.", {
|
|
87
|
-
constraint: error.constraint
|
|
88
|
-
});
|
|
89
|
-
case "23502":
|
|
90
|
-
return new BadRequestError(error.detail ?? "Required field is missing.", {
|
|
91
|
-
constraint: error.constraint
|
|
92
|
-
});
|
|
93
|
-
case "23514":
|
|
94
|
-
return new BadRequestError(error.detail ?? "Value violates a database constraint.", {
|
|
95
|
-
constraint: error.constraint
|
|
96
|
-
});
|
|
97
|
-
default:
|
|
98
|
-
return new InternalServerError("Database operation failed.");
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
async function withDatabaseErrorHandling(operation) {
|
|
102
|
-
try {
|
|
103
|
-
return await operation();
|
|
104
|
-
} catch (error) {
|
|
105
|
-
throw mapDatabaseError(error);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
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
|
-
|
|
114
|
-
// ../../src/core/runtime/frontendMode.ts
|
|
115
|
-
var FRONTEND_MODES = ["api", "server-htmx", "spa-react", "hybrid"];
|
|
116
|
-
var DEFAULT_SPA_PREFIX = "/app";
|
|
117
|
-
var FRONTEND_MODE_PATTERN = new RegExp(`^(${FRONTEND_MODES.join("|")})$`);
|
|
118
|
-
function parseFrontendMode(value) {
|
|
119
|
-
const mode = (value ?? "api").trim();
|
|
120
|
-
if (mode === "server-htmx" || mode === "spa-react" || mode === "hybrid") {
|
|
121
|
-
return mode;
|
|
122
|
-
}
|
|
123
|
-
return "api";
|
|
124
|
-
}
|
|
125
|
-
function readFrontendMode() {
|
|
126
|
-
return parseFrontendMode(process.env.FRONTEND_MODE);
|
|
127
|
-
}
|
|
128
|
-
function isViewsMode(mode) {
|
|
129
|
-
return mode === "server-htmx" || mode === "hybrid";
|
|
130
|
-
}
|
|
131
|
-
function isSpaMode(mode) {
|
|
132
|
-
return mode === "spa-react" || mode === "hybrid";
|
|
133
|
-
}
|
|
134
|
-
function isViewsEnabled() {
|
|
135
|
-
return isViewsMode(readFrontendMode());
|
|
136
|
-
}
|
|
137
|
-
function isSpaEnabled() {
|
|
138
|
-
return isSpaMode(readFrontendMode());
|
|
139
|
-
}
|
|
140
|
-
function normalizeSpaPrefix(value) {
|
|
141
|
-
const raw = (value ?? DEFAULT_SPA_PREFIX).trim() || DEFAULT_SPA_PREFIX;
|
|
142
|
-
const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
|
|
143
|
-
const trimmed = withSlash.replace(/\/+$/, "");
|
|
144
|
-
if (trimmed.length === 0 || trimmed === "/") {
|
|
145
|
-
return DEFAULT_SPA_PREFIX;
|
|
146
|
-
}
|
|
147
|
-
return trimmed;
|
|
148
|
-
}
|
|
149
|
-
function readSpaPrefix() {
|
|
150
|
-
return normalizeSpaPrefix(process.env.SPA_PREFIX);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// ../../src/core/view/webErrorView.ts
|
|
154
|
-
import { currentRequestMeta } from "@getstrata/core/http/requestMetaContext";
|
|
155
|
-
|
|
156
|
-
// ../../src/core/view/htmlResponse.ts
|
|
157
|
-
function withCharset(contentType) {
|
|
158
|
-
return contentType.includes("charset=") ? contentType : `${contentType}; charset=utf-8`;
|
|
159
|
-
}
|
|
160
|
-
function htmlResponse(html, init = {}) {
|
|
161
|
-
return new Response(html, {
|
|
162
|
-
status: init.status ?? 200,
|
|
163
|
-
statusText: init.statusText,
|
|
164
|
-
headers: {
|
|
165
|
-
"Content-Type": "text/html; charset=utf-8"
|
|
166
|
-
}
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
function isHtmxRequest(request) {
|
|
170
|
-
return request.headers.get("HX-Request") === "true";
|
|
171
|
-
}
|
|
172
|
-
function redirectResponse(location, status = 302) {
|
|
173
|
-
return new Response(null, {
|
|
174
|
-
status,
|
|
175
|
-
headers: {
|
|
176
|
-
Location: location
|
|
177
|
-
}
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
function textResponse(body, init = {}) {
|
|
181
|
-
return new Response(body, {
|
|
182
|
-
status: init.status ?? 200,
|
|
183
|
-
headers: {
|
|
184
|
-
"Content-Type": "text/plain; charset=utf-8"
|
|
185
|
-
}
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
function xmlResponse(body, init = {}) {
|
|
189
|
-
return new Response(body, {
|
|
190
|
-
status: init.status ?? 200,
|
|
191
|
-
headers: {
|
|
192
|
-
"Content-Type": withCharset(init.contentType ?? "application/xml")
|
|
193
|
-
}
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
function rssResponse(body, init = {}) {
|
|
197
|
-
return xmlResponse(body, { ...init, contentType: "application/rss+xml" });
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// ../../src/core/view/webErrorView.ts
|
|
201
|
-
var configuredErrorView = {};
|
|
202
|
-
function configureWebErrorView(options) {
|
|
203
|
-
configuredErrorView = { ...options };
|
|
204
|
-
}
|
|
205
|
-
function escapeHtml(value) {
|
|
206
|
-
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
207
|
-
}
|
|
208
|
-
function renderKernelErrorChrome(input) {
|
|
209
|
-
const title = escapeHtml(input.title);
|
|
210
|
-
const message = escapeHtml(input.message);
|
|
211
|
-
const errorLines = Object.entries(input.errors ?? {}).flatMap(([field, messages]) => messages.map((item) => `${field}: ${item}`)).map((line) => `<li>${escapeHtml(line)}</li>`).join("");
|
|
212
|
-
const details = errorLines ? `<ul class="error-list">${errorLines}</ul>` : "";
|
|
213
|
-
const goBack = input.status === 422 ? `<p><a href="javascript:history.back()">Go back</a></p>` : "";
|
|
214
|
-
return `<!doctype html>
|
|
215
|
-
<html lang="en">
|
|
216
|
-
<head>
|
|
217
|
-
<meta charset="UTF-8" />
|
|
218
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
219
|
-
<title>${title}</title>
|
|
220
|
-
<link rel="stylesheet" href="/assets/app.css" />
|
|
221
|
-
</head>
|
|
222
|
-
<body>
|
|
223
|
-
<header class="site-header">
|
|
224
|
-
<a class="brand" href="/">Home</a>
|
|
225
|
-
</header>
|
|
226
|
-
<main class="site-main">
|
|
227
|
-
<section class="page-header">
|
|
228
|
-
<h1>${title}</h1>
|
|
229
|
-
<p>${message}</p>
|
|
230
|
-
${details}
|
|
231
|
-
${goBack}
|
|
232
|
-
</section>
|
|
233
|
-
</main>
|
|
234
|
-
</body>
|
|
235
|
-
</html>
|
|
236
|
-
`;
|
|
237
|
-
}
|
|
238
|
-
function errorTemplateName(status) {
|
|
239
|
-
if (status === 404) {
|
|
240
|
-
return "errors/not-found";
|
|
241
|
-
}
|
|
242
|
-
if (status === 403) {
|
|
243
|
-
return "errors/forbidden";
|
|
244
|
-
}
|
|
245
|
-
return "errors/error";
|
|
246
|
-
}
|
|
247
|
-
async function renderWebErrorHtml(input) {
|
|
248
|
-
const render = configuredErrorView.render;
|
|
249
|
-
if (!render) {
|
|
250
|
-
return renderKernelErrorChrome(input);
|
|
251
|
-
}
|
|
252
|
-
try {
|
|
253
|
-
return await render({
|
|
254
|
-
...input,
|
|
255
|
-
request: input.request ?? currentRequestMeta().request
|
|
256
|
-
});
|
|
257
|
-
} catch {
|
|
258
|
-
return renderKernelErrorChrome(input);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
async function htmlErrorResponse(input) {
|
|
262
|
-
return htmlResponse(await renderWebErrorHtml(input), { status: input.status });
|
|
263
|
-
}
|
|
264
|
-
async function notFoundHtmlResponse(body) {
|
|
265
|
-
if (body !== undefined) {
|
|
266
|
-
return htmlResponse(body, { status: 404 });
|
|
267
|
-
}
|
|
268
|
-
return htmlErrorResponse({
|
|
269
|
-
status: 404,
|
|
270
|
-
title: "Not Found",
|
|
271
|
-
message: "The page you requested was not found."
|
|
272
|
-
});
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// ../../src/core/http/contentNegotiation.ts
|
|
276
|
-
function requestPrefersJson(request) {
|
|
277
|
-
if (!request) {
|
|
278
|
-
return true;
|
|
279
|
-
}
|
|
280
|
-
if (request.headers.get("HX-Request") === "true") {
|
|
281
|
-
return false;
|
|
282
|
-
}
|
|
283
|
-
const pathname = new URL(request.url).pathname;
|
|
284
|
-
if (pathname.startsWith("/api/")) {
|
|
285
|
-
return true;
|
|
286
|
-
}
|
|
287
|
-
const accept = request.headers.get("accept")?.toLowerCase() ?? "";
|
|
288
|
-
if (accept.includes("text/html")) {
|
|
289
|
-
return false;
|
|
290
|
-
}
|
|
291
|
-
if (accept.includes("application/json")) {
|
|
292
|
-
return true;
|
|
293
|
-
}
|
|
294
|
-
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
|
|
295
|
-
if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
|
|
296
|
-
return false;
|
|
297
|
-
}
|
|
298
|
-
return false;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// ../../src/core/http/safeInternalPath.ts
|
|
302
|
-
function looksLikeExternalTarget(value) {
|
|
303
|
-
const trimmed = value.trim();
|
|
304
|
-
if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
|
|
305
|
-
return true;
|
|
306
|
-
}
|
|
307
|
-
if (trimmed.includes("://") || trimmed.includes(":/") || trimmed.includes(":\\")) {
|
|
308
|
-
return true;
|
|
309
|
-
}
|
|
310
|
-
try {
|
|
311
|
-
const decoded = decodeURIComponent(trimmed);
|
|
312
|
-
if (decoded.startsWith("//") || decoded.includes("\\") || /https?:/i.test(decoded)) {
|
|
313
|
-
return true;
|
|
314
|
-
}
|
|
315
|
-
} catch {
|
|
316
|
-
return true;
|
|
317
|
-
}
|
|
318
|
-
return false;
|
|
319
|
-
}
|
|
320
|
-
function sanitizeInternalPath(raw, fallback = "/") {
|
|
321
|
-
if (looksLikeExternalTarget(raw)) {
|
|
322
|
-
return fallback;
|
|
323
|
-
}
|
|
324
|
-
return raw;
|
|
325
|
-
}
|
|
326
|
-
function safeInternalRedirectPath(request, fallback = "/") {
|
|
327
|
-
const url = new URL(request.url);
|
|
328
|
-
return sanitizeInternalPath(`${url.pathname}${url.search}`, fallback);
|
|
329
|
-
}
|
|
330
|
-
function loginRedirectLocation(request) {
|
|
331
|
-
return `/login?redirect=${encodeURIComponent(safeInternalRedirectPath(request))}`;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// ../../src/core/http/webErrorResponse.ts
|
|
335
|
-
function normalizeFieldErrors(details) {
|
|
336
|
-
if (!details || typeof details !== "object" || Array.isArray(details)) {
|
|
337
|
-
return {};
|
|
338
|
-
}
|
|
339
|
-
const errors = {};
|
|
340
|
-
for (const [field, messages] of Object.entries(details)) {
|
|
341
|
-
if (Array.isArray(messages)) {
|
|
342
|
-
errors[field] = messages.map(String);
|
|
343
|
-
continue;
|
|
344
|
-
}
|
|
345
|
-
if (typeof messages === "string") {
|
|
346
|
-
errors[field] = [messages];
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
return errors;
|
|
350
|
-
}
|
|
351
|
-
function errorPageTitle(status, message) {
|
|
352
|
-
if (status === 404) {
|
|
353
|
-
return "Not Found";
|
|
354
|
-
}
|
|
355
|
-
if (status === 403) {
|
|
356
|
-
return "Forbidden";
|
|
357
|
-
}
|
|
358
|
-
if (status >= 500) {
|
|
359
|
-
return "Server Error";
|
|
360
|
-
}
|
|
361
|
-
return message;
|
|
362
|
-
}
|
|
363
|
-
function publicErrorMessage(status, message) {
|
|
364
|
-
if (status >= 500 && isProductionEnv()) {
|
|
365
|
-
return "Something went wrong.";
|
|
366
|
-
}
|
|
367
|
-
return message;
|
|
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
|
-
}
|
|
379
|
-
async function webErrorResponse(error, request) {
|
|
380
|
-
if (!request || !isViewsEnabled() || requestPrefersJson(request)) {
|
|
381
|
-
return null;
|
|
382
|
-
}
|
|
383
|
-
const mappedError = toHttpError2(error) ?? mapDatabaseError(error);
|
|
384
|
-
logServerError(error, mappedError);
|
|
385
|
-
if (mappedError.status === 401) {
|
|
386
|
-
return Response.redirect(loginRedirectLocation(request), 302);
|
|
387
|
-
}
|
|
388
|
-
const errors = mappedError instanceof ValidationError || mappedError.name === "ValidationError" ? normalizeFieldErrors(mappedError.details) : undefined;
|
|
389
|
-
return htmlErrorResponse({
|
|
390
|
-
status: mappedError.status,
|
|
391
|
-
title: errorPageTitle(mappedError.status, mappedError.message),
|
|
392
|
-
message: publicErrorMessage(mappedError.status, mappedError.message),
|
|
393
|
-
errors,
|
|
394
|
-
request
|
|
395
|
-
});
|
|
396
|
-
}
|
|
397
|
-
export {
|
|
398
|
-
logServerError,
|
|
399
|
-
normalizeFieldErrors,
|
|
400
|
-
webErrorResponse
|
|
401
|
-
};
|
|
1
|
+
export * from "../../index.js";
|