@getstrata/core 0.5.100 → 0.7.3
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 +67 -32
- package/README.md +16 -35
- package/dist/core/auth/abilityCatalog.d.ts +2 -2
- package/dist/core/auth/basicAuthGuard.d.ts +9 -0
- package/dist/core/auth/guard.d.ts +6 -0
- package/dist/core/auth/jwt.d.ts +19 -0
- package/dist/core/auth/jwtGuard.d.ts +14 -0
- package/dist/core/auth/tokenAbilityChecker.d.ts +5 -0
- package/dist/core/cache/tags.d.ts +6 -0
- package/dist/core/contracts/authUserDirectory.d.ts +4 -0
- package/dist/core/database/baseRepository.d.ts +5 -1
- package/dist/core/database/dialect.d.ts +18 -0
- package/dist/core/database/factory.d.ts +1 -0
- package/dist/core/database/index.d.ts +11 -3
- package/dist/core/database/model.d.ts +21 -2
- package/dist/core/database/mysqlConnection.d.ts +12 -0
- package/dist/core/database/namedConnections.d.ts +15 -0
- package/dist/core/database/relationQuery.d.ts +22 -3
- package/dist/core/database/relationships.d.ts +22 -2
- package/dist/core/database/repositoryQuery.d.ts +5 -1
- package/dist/core/database/sqliteConnection.d.ts +7 -0
- package/dist/core/http/loginThrottleMiddleware.d.ts +5 -2
- package/dist/core/http/resources.d.ts +2 -2
- package/dist/core/http/response.d.ts +2 -1
- package/dist/core/http/statelessAuth.d.ts +8 -0
- package/dist/core/http/throttleResponse.d.ts +2 -0
- package/dist/core/runtime/frontendMode.d.ts +10 -2
- package/dist/entries/auth/basicAuthGuard.js +137 -0
- package/dist/entries/auth/jwt.js +135 -0
- package/dist/entries/auth/jwtGuard.js +203 -0
- package/dist/entries/auth/sessionGuard.js +3 -21
- package/dist/entries/auth/tokenAbilityChecker.js +24 -0
- package/dist/entries/cache/tags.js +7 -1
- package/dist/entries/database/connectionContext.js +1 -0
- package/dist/entries/database/dialect.js +1 -0
- package/dist/entries/database/factory.js +5 -4
- package/dist/entries/database/model.js +189 -33
- package/dist/entries/database/mysqlConnection.js +35 -0
- package/dist/entries/database/namedConnections.js +1 -0
- package/dist/entries/database/query.js +28 -15
- package/dist/entries/database/relationships.js +43 -6
- package/dist/entries/database/repositoryQuery.js +142 -73
- package/dist/entries/database/schema.js +28 -15
- package/dist/entries/database/sqliteConnection.js +34 -0
- package/dist/entries/facades.js +1 -1
- package/dist/entries/http/contentNegotiation.js +5 -2
- package/dist/entries/http/csrfMiddleware.js +45 -0
- package/dist/entries/http/loginThrottleMiddleware.js +246 -7
- package/dist/entries/http/memoryThrottleMiddleware.js +208 -6
- package/dist/entries/http/requireAbilityMiddleware.js +35 -11
- package/dist/entries/http/requirePasswordConfirmMiddleware.js +5 -2
- package/dist/entries/http/requireVerifiedMiddleware.js +5 -2
- package/dist/entries/http/requireWebAuthMiddleware.js +12 -3
- package/dist/entries/http/resources.js +4 -1
- package/dist/entries/http/response.js +46 -12
- package/dist/entries/http/statelessAuth.js +48 -0
- package/dist/entries/http/throttleMiddleware.js +208 -6
- package/dist/entries/http/webErrorResponse.js +35 -11
- package/dist/entries/http/webFormRequest.js +5 -2
- package/dist/entries/mail/mailer.js +1 -1
- package/dist/entries/openapi/generator.js +48 -5
- package/dist/entries/runtime/frontendMode.js +39 -10
- package/dist/framework/public-api.d.ts +11 -1
- package/dist/index.js +1068 -250
- package/package.json +56 -5
|
@@ -71,7 +71,215 @@ function readClientIp(request, env = process.env) {
|
|
|
71
71
|
return request.headers.get("x-real-ip")?.trim() || undefined;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// ../../src/core/runtime/frontendMode.ts
|
|
75
|
+
var FRONTEND_MODES = ["api", "server-htmx", "spa-react", "hybrid"];
|
|
76
|
+
var DEFAULT_SPA_PREFIX = "/app";
|
|
77
|
+
var FRONTEND_MODE_PATTERN = new RegExp(`^(${FRONTEND_MODES.join("|")})$`);
|
|
78
|
+
function parseFrontendMode(value) {
|
|
79
|
+
const mode = (value ?? "api").trim();
|
|
80
|
+
if (mode === "server-htmx" || mode === "spa-react" || mode === "hybrid") {
|
|
81
|
+
return mode;
|
|
82
|
+
}
|
|
83
|
+
return "api";
|
|
84
|
+
}
|
|
85
|
+
function readFrontendMode() {
|
|
86
|
+
return parseFrontendMode(process.env.FRONTEND_MODE);
|
|
87
|
+
}
|
|
88
|
+
function isViewsMode(mode) {
|
|
89
|
+
return mode === "server-htmx" || mode === "hybrid";
|
|
90
|
+
}
|
|
91
|
+
function isSpaMode(mode) {
|
|
92
|
+
return mode === "spa-react" || mode === "hybrid";
|
|
93
|
+
}
|
|
94
|
+
function isViewsEnabled() {
|
|
95
|
+
return isViewsMode(readFrontendMode());
|
|
96
|
+
}
|
|
97
|
+
function isSpaEnabled() {
|
|
98
|
+
return isSpaMode(readFrontendMode());
|
|
99
|
+
}
|
|
100
|
+
function normalizeSpaPrefix(value) {
|
|
101
|
+
const raw = (value ?? DEFAULT_SPA_PREFIX).trim() || DEFAULT_SPA_PREFIX;
|
|
102
|
+
const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
|
|
103
|
+
const trimmed = withSlash.replace(/\/+$/, "");
|
|
104
|
+
if (trimmed.length === 0 || trimmed === "/") {
|
|
105
|
+
return DEFAULT_SPA_PREFIX;
|
|
106
|
+
}
|
|
107
|
+
return trimmed;
|
|
108
|
+
}
|
|
109
|
+
function readSpaPrefix() {
|
|
110
|
+
return normalizeSpaPrefix(process.env.SPA_PREFIX);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ../../src/core/view/webErrorView.ts
|
|
114
|
+
import { currentRequestMeta } from "@getstrata/core/http/requestMetaContext";
|
|
115
|
+
|
|
116
|
+
// ../../src/core/view/htmlResponse.ts
|
|
117
|
+
function withCharset(contentType) {
|
|
118
|
+
return contentType.includes("charset=") ? contentType : `${contentType}; charset=utf-8`;
|
|
119
|
+
}
|
|
120
|
+
function htmlResponse(html, init = {}) {
|
|
121
|
+
return new Response(html, {
|
|
122
|
+
status: init.status ?? 200,
|
|
123
|
+
statusText: init.statusText,
|
|
124
|
+
headers: {
|
|
125
|
+
"Content-Type": "text/html; charset=utf-8"
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function isHtmxRequest(request) {
|
|
130
|
+
return request.headers.get("HX-Request") === "true";
|
|
131
|
+
}
|
|
132
|
+
function redirectResponse(location, status = 302) {
|
|
133
|
+
return new Response(null, {
|
|
134
|
+
status,
|
|
135
|
+
headers: {
|
|
136
|
+
Location: location
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
function textResponse(body, init = {}) {
|
|
141
|
+
return new Response(body, {
|
|
142
|
+
status: init.status ?? 200,
|
|
143
|
+
headers: {
|
|
144
|
+
"Content-Type": "text/plain; charset=utf-8"
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
function xmlResponse(body, init = {}) {
|
|
149
|
+
return new Response(body, {
|
|
150
|
+
status: init.status ?? 200,
|
|
151
|
+
headers: {
|
|
152
|
+
"Content-Type": withCharset(init.contentType ?? "application/xml")
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
function rssResponse(body, init = {}) {
|
|
157
|
+
return xmlResponse(body, { ...init, contentType: "application/rss+xml" });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ../../src/core/view/webErrorView.ts
|
|
161
|
+
var configuredErrorView = {};
|
|
162
|
+
function configureWebErrorView(options) {
|
|
163
|
+
configuredErrorView = { ...options };
|
|
164
|
+
}
|
|
165
|
+
function escapeHtml(value) {
|
|
166
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
167
|
+
}
|
|
168
|
+
function renderKernelErrorChrome(input) {
|
|
169
|
+
const title = escapeHtml(input.title);
|
|
170
|
+
const message = escapeHtml(input.message);
|
|
171
|
+
const errorLines = Object.entries(input.errors ?? {}).flatMap(([field, messages]) => messages.map((item) => `${field}: ${item}`)).map((line) => `<li>${escapeHtml(line)}</li>`).join("");
|
|
172
|
+
const details = errorLines ? `<ul class="error-list">${errorLines}</ul>` : "";
|
|
173
|
+
const goBack = input.status === 422 ? `<p><a href="javascript:history.back()">Go back</a></p>` : "";
|
|
174
|
+
return `<!doctype html>
|
|
175
|
+
<html lang="en">
|
|
176
|
+
<head>
|
|
177
|
+
<meta charset="UTF-8" />
|
|
178
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
179
|
+
<title>${title}</title>
|
|
180
|
+
<link rel="stylesheet" href="/assets/app.css" />
|
|
181
|
+
</head>
|
|
182
|
+
<body>
|
|
183
|
+
<header class="site-header">
|
|
184
|
+
<a class="brand" href="/">Home</a>
|
|
185
|
+
</header>
|
|
186
|
+
<main class="site-main">
|
|
187
|
+
<section class="page-header">
|
|
188
|
+
<h1>${title}</h1>
|
|
189
|
+
<p>${message}</p>
|
|
190
|
+
${details}
|
|
191
|
+
${goBack}
|
|
192
|
+
</section>
|
|
193
|
+
</main>
|
|
194
|
+
</body>
|
|
195
|
+
</html>
|
|
196
|
+
`;
|
|
197
|
+
}
|
|
198
|
+
function errorTemplateName(status) {
|
|
199
|
+
if (status === 404) {
|
|
200
|
+
return "errors/not-found";
|
|
201
|
+
}
|
|
202
|
+
if (status === 403) {
|
|
203
|
+
return "errors/forbidden";
|
|
204
|
+
}
|
|
205
|
+
return "errors/error";
|
|
206
|
+
}
|
|
207
|
+
async function renderWebErrorHtml(input) {
|
|
208
|
+
const render = configuredErrorView.render;
|
|
209
|
+
if (!render) {
|
|
210
|
+
return renderKernelErrorChrome(input);
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
return await render({
|
|
214
|
+
...input,
|
|
215
|
+
request: input.request ?? currentRequestMeta().request
|
|
216
|
+
});
|
|
217
|
+
} catch {
|
|
218
|
+
return renderKernelErrorChrome(input);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async function htmlErrorResponse(input) {
|
|
222
|
+
return htmlResponse(await renderWebErrorHtml(input), { status: input.status });
|
|
223
|
+
}
|
|
224
|
+
async function notFoundHtmlResponse(body) {
|
|
225
|
+
if (body !== undefined) {
|
|
226
|
+
return htmlResponse(body, { status: 404 });
|
|
227
|
+
}
|
|
228
|
+
return htmlErrorResponse({
|
|
229
|
+
status: 404,
|
|
230
|
+
title: "Not Found",
|
|
231
|
+
message: "The page you requested was not found."
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ../../src/core/http/contentNegotiation.ts
|
|
236
|
+
function requestPrefersJson(request) {
|
|
237
|
+
if (!request) {
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
if (request.headers.get("HX-Request") === "true") {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
const pathname = new URL(request.url).pathname;
|
|
244
|
+
if (pathname.startsWith("/api/")) {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
const accept = request.headers.get("accept")?.toLowerCase() ?? "";
|
|
248
|
+
if (accept.includes("text/html")) {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
if (accept.includes("application/json")) {
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
|
|
255
|
+
if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ../../src/core/http/throttleResponse.ts
|
|
262
|
+
async function tooManyRequestsResponse(request, message, decaySeconds) {
|
|
263
|
+
const retryAfter = { "retry-after": String(decaySeconds) };
|
|
264
|
+
if (requestPrefersJson(request) || !isViewsEnabled()) {
|
|
265
|
+
return Response.json({ error: message }, {
|
|
266
|
+
status: 429,
|
|
267
|
+
headers: retryAfter
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
const html = await htmlErrorResponse({
|
|
271
|
+
status: 429,
|
|
272
|
+
title: "Too Many Requests",
|
|
273
|
+
message,
|
|
274
|
+
request
|
|
275
|
+
});
|
|
276
|
+
const headers = new Headers(html.headers);
|
|
277
|
+
headers.set("retry-after", String(decaySeconds));
|
|
278
|
+
return new Response(html.body, { status: 429, headers });
|
|
279
|
+
}
|
|
280
|
+
|
|
74
281
|
// ../../src/core/http/loginThrottleMiddleware.ts
|
|
282
|
+
var memoryLoginBuckets = new Map;
|
|
75
283
|
function resolveLoginIdentity(request) {
|
|
76
284
|
return readClientIp(request) ?? "unknown";
|
|
77
285
|
}
|
|
@@ -89,7 +297,30 @@ async function resolveLoginEmail(request) {
|
|
|
89
297
|
return "unknown";
|
|
90
298
|
}
|
|
91
299
|
}
|
|
92
|
-
function
|
|
300
|
+
function consumeMemoryAttempt(key, decaySeconds) {
|
|
301
|
+
const now = Date.now();
|
|
302
|
+
const existing = memoryLoginBuckets.get(key);
|
|
303
|
+
if (!existing || existing.resetAt <= now) {
|
|
304
|
+
memoryLoginBuckets.set(key, { count: 1, resetAt: now + decaySeconds * 1000 });
|
|
305
|
+
return 1;
|
|
306
|
+
}
|
|
307
|
+
existing.count += 1;
|
|
308
|
+
return existing.count;
|
|
309
|
+
}
|
|
310
|
+
function createMemoryLoginThrottleMiddleware(options) {
|
|
311
|
+
const prefix = options.keyPrefix ?? namespacedRedisKey("login-throttle:");
|
|
312
|
+
return async (request, next) => {
|
|
313
|
+
const identity = resolveLoginIdentity(request);
|
|
314
|
+
const email = await resolveLoginEmail(request);
|
|
315
|
+
const throttleKey = `${prefix}${identity}:${email}`;
|
|
316
|
+
const attempts = consumeMemoryAttempt(throttleKey, options.decaySeconds);
|
|
317
|
+
if (attempts > options.maxAttempts) {
|
|
318
|
+
return await tooManyRequestsResponse(request, "Too many login attempts. Try again later.", options.decaySeconds);
|
|
319
|
+
}
|
|
320
|
+
return await next();
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
function createRedisLoginThrottleMiddleware(options) {
|
|
93
324
|
const client = new RedisClient(options.redisUrl);
|
|
94
325
|
const prefix = options.keyPrefix ?? namespacedRedisKey("login-throttle:");
|
|
95
326
|
return async (request, next) => {
|
|
@@ -101,17 +332,25 @@ function createLoginThrottleMiddleware(options) {
|
|
|
101
332
|
await client.expire(throttleKey, options.decaySeconds);
|
|
102
333
|
}
|
|
103
334
|
if (attempts > options.maxAttempts) {
|
|
104
|
-
return
|
|
105
|
-
status: 429,
|
|
106
|
-
headers: {
|
|
107
|
-
"retry-after": String(options.decaySeconds)
|
|
108
|
-
}
|
|
109
|
-
});
|
|
335
|
+
return await tooManyRequestsResponse(request, "Too many login attempts. Try again later.", options.decaySeconds);
|
|
110
336
|
}
|
|
111
337
|
return await next();
|
|
112
338
|
};
|
|
113
339
|
}
|
|
340
|
+
function createLoginThrottleMiddleware(options) {
|
|
341
|
+
const redisUrl = options.redisUrl?.trim() ?? "";
|
|
342
|
+
if (redisUrl) {
|
|
343
|
+
return createRedisLoginThrottleMiddleware({ ...options, redisUrl });
|
|
344
|
+
}
|
|
345
|
+
return createMemoryLoginThrottleMiddleware(options);
|
|
346
|
+
}
|
|
347
|
+
function resetMemoryLoginThrottleForTests() {
|
|
348
|
+
memoryLoginBuckets.clear();
|
|
349
|
+
}
|
|
114
350
|
export {
|
|
115
351
|
createLoginThrottleMiddleware,
|
|
352
|
+
createMemoryLoginThrottleMiddleware,
|
|
353
|
+
resetMemoryLoginThrottleForTests,
|
|
354
|
+
resolveLoginEmail,
|
|
116
355
|
resolveLoginIdentity
|
|
117
356
|
};
|
|
@@ -68,6 +68,213 @@ function readClientIp(request, env = process.env) {
|
|
|
68
68
|
return request.headers.get("x-real-ip")?.trim() || undefined;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
// ../../src/core/runtime/frontendMode.ts
|
|
72
|
+
var FRONTEND_MODES = ["api", "server-htmx", "spa-react", "hybrid"];
|
|
73
|
+
var DEFAULT_SPA_PREFIX = "/app";
|
|
74
|
+
var FRONTEND_MODE_PATTERN = new RegExp(`^(${FRONTEND_MODES.join("|")})$`);
|
|
75
|
+
function parseFrontendMode(value) {
|
|
76
|
+
const mode = (value ?? "api").trim();
|
|
77
|
+
if (mode === "server-htmx" || mode === "spa-react" || mode === "hybrid") {
|
|
78
|
+
return mode;
|
|
79
|
+
}
|
|
80
|
+
return "api";
|
|
81
|
+
}
|
|
82
|
+
function readFrontendMode() {
|
|
83
|
+
return parseFrontendMode(process.env.FRONTEND_MODE);
|
|
84
|
+
}
|
|
85
|
+
function isViewsMode(mode) {
|
|
86
|
+
return mode === "server-htmx" || mode === "hybrid";
|
|
87
|
+
}
|
|
88
|
+
function isSpaMode(mode) {
|
|
89
|
+
return mode === "spa-react" || mode === "hybrid";
|
|
90
|
+
}
|
|
91
|
+
function isViewsEnabled() {
|
|
92
|
+
return isViewsMode(readFrontendMode());
|
|
93
|
+
}
|
|
94
|
+
function isSpaEnabled() {
|
|
95
|
+
return isSpaMode(readFrontendMode());
|
|
96
|
+
}
|
|
97
|
+
function normalizeSpaPrefix(value) {
|
|
98
|
+
const raw = (value ?? DEFAULT_SPA_PREFIX).trim() || DEFAULT_SPA_PREFIX;
|
|
99
|
+
const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
|
|
100
|
+
const trimmed = withSlash.replace(/\/+$/, "");
|
|
101
|
+
if (trimmed.length === 0 || trimmed === "/") {
|
|
102
|
+
return DEFAULT_SPA_PREFIX;
|
|
103
|
+
}
|
|
104
|
+
return trimmed;
|
|
105
|
+
}
|
|
106
|
+
function readSpaPrefix() {
|
|
107
|
+
return normalizeSpaPrefix(process.env.SPA_PREFIX);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ../../src/core/view/webErrorView.ts
|
|
111
|
+
import { currentRequestMeta } from "@getstrata/core/http/requestMetaContext";
|
|
112
|
+
|
|
113
|
+
// ../../src/core/view/htmlResponse.ts
|
|
114
|
+
function withCharset(contentType) {
|
|
115
|
+
return contentType.includes("charset=") ? contentType : `${contentType}; charset=utf-8`;
|
|
116
|
+
}
|
|
117
|
+
function htmlResponse(html, init = {}) {
|
|
118
|
+
return new Response(html, {
|
|
119
|
+
status: init.status ?? 200,
|
|
120
|
+
statusText: init.statusText,
|
|
121
|
+
headers: {
|
|
122
|
+
"Content-Type": "text/html; charset=utf-8"
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
function isHtmxRequest(request) {
|
|
127
|
+
return request.headers.get("HX-Request") === "true";
|
|
128
|
+
}
|
|
129
|
+
function redirectResponse(location, status = 302) {
|
|
130
|
+
return new Response(null, {
|
|
131
|
+
status,
|
|
132
|
+
headers: {
|
|
133
|
+
Location: location
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
function textResponse(body, init = {}) {
|
|
138
|
+
return new Response(body, {
|
|
139
|
+
status: init.status ?? 200,
|
|
140
|
+
headers: {
|
|
141
|
+
"Content-Type": "text/plain; charset=utf-8"
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
function xmlResponse(body, init = {}) {
|
|
146
|
+
return new Response(body, {
|
|
147
|
+
status: init.status ?? 200,
|
|
148
|
+
headers: {
|
|
149
|
+
"Content-Type": withCharset(init.contentType ?? "application/xml")
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
function rssResponse(body, init = {}) {
|
|
154
|
+
return xmlResponse(body, { ...init, contentType: "application/rss+xml" });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ../../src/core/view/webErrorView.ts
|
|
158
|
+
var configuredErrorView = {};
|
|
159
|
+
function configureWebErrorView(options) {
|
|
160
|
+
configuredErrorView = { ...options };
|
|
161
|
+
}
|
|
162
|
+
function escapeHtml(value) {
|
|
163
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
164
|
+
}
|
|
165
|
+
function renderKernelErrorChrome(input) {
|
|
166
|
+
const title = escapeHtml(input.title);
|
|
167
|
+
const message = escapeHtml(input.message);
|
|
168
|
+
const errorLines = Object.entries(input.errors ?? {}).flatMap(([field, messages]) => messages.map((item) => `${field}: ${item}`)).map((line) => `<li>${escapeHtml(line)}</li>`).join("");
|
|
169
|
+
const details = errorLines ? `<ul class="error-list">${errorLines}</ul>` : "";
|
|
170
|
+
const goBack = input.status === 422 ? `<p><a href="javascript:history.back()">Go back</a></p>` : "";
|
|
171
|
+
return `<!doctype html>
|
|
172
|
+
<html lang="en">
|
|
173
|
+
<head>
|
|
174
|
+
<meta charset="UTF-8" />
|
|
175
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
176
|
+
<title>${title}</title>
|
|
177
|
+
<link rel="stylesheet" href="/assets/app.css" />
|
|
178
|
+
</head>
|
|
179
|
+
<body>
|
|
180
|
+
<header class="site-header">
|
|
181
|
+
<a class="brand" href="/">Home</a>
|
|
182
|
+
</header>
|
|
183
|
+
<main class="site-main">
|
|
184
|
+
<section class="page-header">
|
|
185
|
+
<h1>${title}</h1>
|
|
186
|
+
<p>${message}</p>
|
|
187
|
+
${details}
|
|
188
|
+
${goBack}
|
|
189
|
+
</section>
|
|
190
|
+
</main>
|
|
191
|
+
</body>
|
|
192
|
+
</html>
|
|
193
|
+
`;
|
|
194
|
+
}
|
|
195
|
+
function errorTemplateName(status) {
|
|
196
|
+
if (status === 404) {
|
|
197
|
+
return "errors/not-found";
|
|
198
|
+
}
|
|
199
|
+
if (status === 403) {
|
|
200
|
+
return "errors/forbidden";
|
|
201
|
+
}
|
|
202
|
+
return "errors/error";
|
|
203
|
+
}
|
|
204
|
+
async function renderWebErrorHtml(input) {
|
|
205
|
+
const render = configuredErrorView.render;
|
|
206
|
+
if (!render) {
|
|
207
|
+
return renderKernelErrorChrome(input);
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
return await render({
|
|
211
|
+
...input,
|
|
212
|
+
request: input.request ?? currentRequestMeta().request
|
|
213
|
+
});
|
|
214
|
+
} catch {
|
|
215
|
+
return renderKernelErrorChrome(input);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
async function htmlErrorResponse(input) {
|
|
219
|
+
return htmlResponse(await renderWebErrorHtml(input), { status: input.status });
|
|
220
|
+
}
|
|
221
|
+
async function notFoundHtmlResponse(body) {
|
|
222
|
+
if (body !== undefined) {
|
|
223
|
+
return htmlResponse(body, { status: 404 });
|
|
224
|
+
}
|
|
225
|
+
return htmlErrorResponse({
|
|
226
|
+
status: 404,
|
|
227
|
+
title: "Not Found",
|
|
228
|
+
message: "The page you requested was not found."
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ../../src/core/http/contentNegotiation.ts
|
|
233
|
+
function requestPrefersJson(request) {
|
|
234
|
+
if (!request) {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
if (request.headers.get("HX-Request") === "true") {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
const pathname = new URL(request.url).pathname;
|
|
241
|
+
if (pathname.startsWith("/api/")) {
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
const accept = request.headers.get("accept")?.toLowerCase() ?? "";
|
|
245
|
+
if (accept.includes("text/html")) {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
if (accept.includes("application/json")) {
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
|
|
252
|
+
if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ../../src/core/http/throttleResponse.ts
|
|
259
|
+
async function tooManyRequestsResponse(request, message, decaySeconds) {
|
|
260
|
+
const retryAfter = { "retry-after": String(decaySeconds) };
|
|
261
|
+
if (requestPrefersJson(request) || !isViewsEnabled()) {
|
|
262
|
+
return Response.json({ error: message }, {
|
|
263
|
+
status: 429,
|
|
264
|
+
headers: retryAfter
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
const html = await htmlErrorResponse({
|
|
268
|
+
status: 429,
|
|
269
|
+
title: "Too Many Requests",
|
|
270
|
+
message,
|
|
271
|
+
request
|
|
272
|
+
});
|
|
273
|
+
const headers = new Headers(html.headers);
|
|
274
|
+
headers.set("retry-after", String(decaySeconds));
|
|
275
|
+
return new Response(html.body, { status: 429, headers });
|
|
276
|
+
}
|
|
277
|
+
|
|
71
278
|
// ../../src/core/http/memoryThrottleMiddleware.ts
|
|
72
279
|
var throttleBucketRegistries = new Set;
|
|
73
280
|
function createMemoryThrottleMiddleware(options) {
|
|
@@ -86,12 +293,7 @@ function createMemoryThrottleMiddleware(options) {
|
|
|
86
293
|
}
|
|
87
294
|
existing.count += 1;
|
|
88
295
|
if (existing.count > options.maxAttempts) {
|
|
89
|
-
return
|
|
90
|
-
status: 429,
|
|
91
|
-
headers: {
|
|
92
|
-
"retry-after": String(options.decaySeconds)
|
|
93
|
-
}
|
|
94
|
-
});
|
|
296
|
+
return await tooManyRequestsResponse(request, "Too many requests.", options.decaySeconds);
|
|
95
297
|
}
|
|
96
298
|
return await next();
|
|
97
299
|
};
|
|
@@ -4,21 +4,42 @@ import { currentAuthUser } from "@getstrata/core/auth/authContext";
|
|
|
4
4
|
import { ForbiddenError } from "@getstrata/core/errors/http";
|
|
5
5
|
|
|
6
6
|
// ../../src/core/runtime/frontendMode.ts
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
if (mode === "spa-react") {
|
|
13
|
-
return
|
|
7
|
+
var FRONTEND_MODES = ["api", "server-htmx", "spa-react", "hybrid"];
|
|
8
|
+
var DEFAULT_SPA_PREFIX = "/app";
|
|
9
|
+
var FRONTEND_MODE_PATTERN = new RegExp(`^(${FRONTEND_MODES.join("|")})$`);
|
|
10
|
+
function parseFrontendMode(value) {
|
|
11
|
+
const mode = (value ?? "api").trim();
|
|
12
|
+
if (mode === "server-htmx" || mode === "spa-react" || mode === "hybrid") {
|
|
13
|
+
return mode;
|
|
14
14
|
}
|
|
15
15
|
return "api";
|
|
16
16
|
}
|
|
17
|
+
function readFrontendMode() {
|
|
18
|
+
return parseFrontendMode(process.env.FRONTEND_MODE);
|
|
19
|
+
}
|
|
20
|
+
function isViewsMode(mode) {
|
|
21
|
+
return mode === "server-htmx" || mode === "hybrid";
|
|
22
|
+
}
|
|
23
|
+
function isSpaMode(mode) {
|
|
24
|
+
return mode === "spa-react" || mode === "hybrid";
|
|
25
|
+
}
|
|
17
26
|
function isViewsEnabled() {
|
|
18
|
-
return readFrontendMode()
|
|
27
|
+
return isViewsMode(readFrontendMode());
|
|
19
28
|
}
|
|
20
29
|
function isSpaEnabled() {
|
|
21
|
-
return readFrontendMode()
|
|
30
|
+
return isSpaMode(readFrontendMode());
|
|
31
|
+
}
|
|
32
|
+
function normalizeSpaPrefix(value) {
|
|
33
|
+
const raw = (value ?? DEFAULT_SPA_PREFIX).trim() || DEFAULT_SPA_PREFIX;
|
|
34
|
+
const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
|
|
35
|
+
const trimmed = withSlash.replace(/\/+$/, "");
|
|
36
|
+
if (trimmed.length === 0 || trimmed === "/") {
|
|
37
|
+
return DEFAULT_SPA_PREFIX;
|
|
38
|
+
}
|
|
39
|
+
return trimmed;
|
|
40
|
+
}
|
|
41
|
+
function readSpaPrefix() {
|
|
42
|
+
return normalizeSpaPrefix(process.env.SPA_PREFIX);
|
|
22
43
|
}
|
|
23
44
|
|
|
24
45
|
// ../../src/core/http/contentNegotiation.ts
|
|
@@ -29,6 +50,10 @@ function requestPrefersJson(request) {
|
|
|
29
50
|
if (request.headers.get("HX-Request") === "true") {
|
|
30
51
|
return false;
|
|
31
52
|
}
|
|
53
|
+
const pathname = new URL(request.url).pathname;
|
|
54
|
+
if (pathname.startsWith("/api/")) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
32
57
|
const accept = request.headers.get("accept")?.toLowerCase() ?? "";
|
|
33
58
|
if (accept.includes("text/html")) {
|
|
34
59
|
return false;
|
|
@@ -40,8 +65,7 @@ function requestPrefersJson(request) {
|
|
|
40
65
|
if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
|
|
41
66
|
return false;
|
|
42
67
|
}
|
|
43
|
-
|
|
44
|
-
return pathname.startsWith("/api/");
|
|
68
|
+
return false;
|
|
45
69
|
}
|
|
46
70
|
|
|
47
71
|
// ../../src/core/http/requireAbilityMiddleware.ts
|
|
@@ -12,6 +12,10 @@ function requestPrefersJson(request) {
|
|
|
12
12
|
if (request.headers.get("HX-Request") === "true") {
|
|
13
13
|
return false;
|
|
14
14
|
}
|
|
15
|
+
const pathname = new URL(request.url).pathname;
|
|
16
|
+
if (pathname.startsWith("/api/")) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
15
19
|
const accept = request.headers.get("accept")?.toLowerCase() ?? "";
|
|
16
20
|
if (accept.includes("text/html")) {
|
|
17
21
|
return false;
|
|
@@ -23,8 +27,7 @@ function requestPrefersJson(request) {
|
|
|
23
27
|
if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
|
|
24
28
|
return false;
|
|
25
29
|
}
|
|
26
|
-
|
|
27
|
-
return pathname.startsWith("/api/");
|
|
30
|
+
return false;
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
// ../../src/core/http/safeInternalPath.ts
|
|
@@ -15,6 +15,10 @@ function requestPrefersJson(request) {
|
|
|
15
15
|
if (request.headers.get("HX-Request") === "true") {
|
|
16
16
|
return false;
|
|
17
17
|
}
|
|
18
|
+
const pathname = new URL(request.url).pathname;
|
|
19
|
+
if (pathname.startsWith("/api/")) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
18
22
|
const accept = request.headers.get("accept")?.toLowerCase() ?? "";
|
|
19
23
|
if (accept.includes("text/html")) {
|
|
20
24
|
return false;
|
|
@@ -26,8 +30,7 @@ function requestPrefersJson(request) {
|
|
|
26
30
|
if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
|
|
27
31
|
return false;
|
|
28
32
|
}
|
|
29
|
-
|
|
30
|
-
return pathname.startsWith("/api/");
|
|
33
|
+
return false;
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
// ../../src/core/http/requireVerifiedMiddleware.ts
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// ../../src/core/http/requireWebAuthMiddleware.ts
|
|
3
3
|
import { runWithAuthUser } from "@getstrata/core/auth/authContext";
|
|
4
|
+
import { createIntendedUrlCookieFromRequest } from "@getstrata/core/auth/intendedUrlCookie";
|
|
4
5
|
import { UnauthorizedError } from "@getstrata/core/errors/http";
|
|
5
6
|
|
|
6
7
|
// ../../src/core/http/contentNegotiation.ts
|
|
@@ -11,6 +12,10 @@ function requestPrefersJson(request) {
|
|
|
11
12
|
if (request.headers.get("HX-Request") === "true") {
|
|
12
13
|
return false;
|
|
13
14
|
}
|
|
15
|
+
const pathname = new URL(request.url).pathname;
|
|
16
|
+
if (pathname.startsWith("/api/")) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
14
19
|
const accept = request.headers.get("accept")?.toLowerCase() ?? "";
|
|
15
20
|
if (accept.includes("text/html")) {
|
|
16
21
|
return false;
|
|
@@ -22,8 +27,7 @@ function requestPrefersJson(request) {
|
|
|
22
27
|
if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
|
|
23
28
|
return false;
|
|
24
29
|
}
|
|
25
|
-
|
|
26
|
-
return pathname.startsWith("/api/");
|
|
30
|
+
return false;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
// ../../src/core/http/safeInternalPath.ts
|
|
@@ -69,7 +73,12 @@ function createRequireWebAuthMiddleware(auth) {
|
|
|
69
73
|
if (requestPrefersJson(request)) {
|
|
70
74
|
throw new UnauthorizedError;
|
|
71
75
|
}
|
|
72
|
-
|
|
76
|
+
const intended = createIntendedUrlCookieFromRequest(request);
|
|
77
|
+
const headers = new Headers({ Location: loginRedirectLocation(request) });
|
|
78
|
+
if (intended) {
|
|
79
|
+
headers.append("Set-Cookie", intended);
|
|
80
|
+
}
|
|
81
|
+
return new Response(null, { status: 302, headers });
|
|
73
82
|
};
|
|
74
83
|
}
|
|
75
84
|
export {
|
|
@@ -8,6 +8,9 @@ function whenLoaded(model, relation, transform) {
|
|
|
8
8
|
if (value === undefined) {
|
|
9
9
|
return;
|
|
10
10
|
}
|
|
11
|
+
if (value === null) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
11
14
|
return transform ? transform(value) : value;
|
|
12
15
|
}
|
|
13
16
|
|
|
@@ -33,7 +36,7 @@ class JsonResource {
|
|
|
33
36
|
}
|
|
34
37
|
whenLoaded(relation, transform) {
|
|
35
38
|
const model = this.resource;
|
|
36
|
-
if (typeof model.loaded !== "function") {
|
|
39
|
+
if (this.resource == null || typeof model.loaded !== "function") {
|
|
37
40
|
return;
|
|
38
41
|
}
|
|
39
42
|
return whenLoaded(model, relation, transform);
|