@getstrata/core 0.5.60 → 0.5.61

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/index.js CHANGED
@@ -4306,6 +4306,133 @@ function conditionalJsonResponse(request, data, init = {}) {
4306
4306
  headers
4307
4307
  });
4308
4308
  }
4309
+ // ../../src/core/http/contentSecurityPolicy.ts
4310
+ var HTMX_2_0_4_INDICATOR_STYLE_HASH = "'sha256-bsV5JivYxvGywDAZ22EZJKBFip65Ng9xoJVLbBg7bdo='";
4311
+ var API_DIRECTIVES = {
4312
+ "default-src": ["'none'"],
4313
+ "frame-ancestors": ["'none'"],
4314
+ "base-uri": ["'none'"]
4315
+ };
4316
+ var HTMX_HTML_DIRECTIVES = {
4317
+ "default-src": ["'self'"],
4318
+ "script-src": ["'self'", "https://unpkg.com"],
4319
+ "style-src": ["'self'", HTMX_2_0_4_INDICATOR_STYLE_HASH],
4320
+ "connect-src": ["'self'"],
4321
+ "img-src": ["'self'", "data:", "https:"],
4322
+ "font-src": ["'self'"],
4323
+ "media-src": ["'self'", "https:"],
4324
+ "frame-src": [
4325
+ "https://www.youtube.com",
4326
+ "https://www.youtube-nocookie.com",
4327
+ "https://player.vimeo.com"
4328
+ ],
4329
+ "form-action": ["'self'"],
4330
+ "frame-ancestors": ["'none'"],
4331
+ "base-uri": ["'self'"]
4332
+ };
4333
+ var SPA_HTML_DIRECTIVES = {
4334
+ "default-src": ["'self'"],
4335
+ "script-src": ["'self'"],
4336
+ "style-src": ["'self'"],
4337
+ "connect-src": ["'self'"],
4338
+ "img-src": ["'self'", "data:", "https:"],
4339
+ "font-src": ["'self'"],
4340
+ "frame-ancestors": ["'none'"],
4341
+ "base-uri": ["'self'"]
4342
+ };
4343
+ var configuredHtmlOptions = {};
4344
+ function generateCspNonce() {
4345
+ const bytes = new Uint8Array(16);
4346
+ crypto.getRandomValues(bytes);
4347
+ return Buffer.from(bytes).toString("base64");
4348
+ }
4349
+ function configureContentSecurityPolicy(options) {
4350
+ configuredHtmlOptions = { ...options };
4351
+ }
4352
+ function cloneDirectives(source) {
4353
+ const copy = {};
4354
+ for (const [directive, values] of Object.entries(source)) {
4355
+ copy[directive] = [...values];
4356
+ }
4357
+ return copy;
4358
+ }
4359
+ function normalizeSources(value) {
4360
+ if (Array.isArray(value)) {
4361
+ return value.flatMap((item) => item.split(/\s+/).filter(Boolean));
4362
+ }
4363
+ return value.split(/\s+/).filter(Boolean);
4364
+ }
4365
+ function mergeDirectives(base, extra) {
4366
+ const merged = cloneDirectives(base);
4367
+ for (const [directive, value] of Object.entries(extra ?? {})) {
4368
+ if (value === false) {
4369
+ delete merged[directive];
4370
+ continue;
4371
+ }
4372
+ const sources = normalizeSources(value);
4373
+ const existing = merged[directive] ?? [];
4374
+ merged[directive] = [...existing, ...sources.filter((source) => !existing.includes(source))];
4375
+ }
4376
+ return merged;
4377
+ }
4378
+ function serializeDirectives(directives) {
4379
+ return Object.entries(directives).map(([directive, values]) => values.length > 0 ? `${directive} ${values.join(" ")}` : directive).join("; ");
4380
+ }
4381
+ function applyNonce(directives, nonce) {
4382
+ if (!nonce) {
4383
+ return directives;
4384
+ }
4385
+ const token = `'nonce-${nonce}'`;
4386
+ const next = cloneDirectives(directives);
4387
+ for (const directive of ["script-src", "style-src"]) {
4388
+ const values = next[directive] ?? ["'self'"];
4389
+ if (!values.includes(token)) {
4390
+ next[directive] = [...values, token];
4391
+ }
4392
+ }
4393
+ return next;
4394
+ }
4395
+ function htmlBaselineDirectives() {
4396
+ const frontendMode = (process.env.FRONTEND_MODE ?? "api").trim();
4397
+ if (frontendMode === "server-htmx") {
4398
+ return cloneDirectives(HTMX_HTML_DIRECTIVES);
4399
+ }
4400
+ if (frontendMode === "spa-react") {
4401
+ return cloneDirectives(SPA_HTML_DIRECTIVES);
4402
+ }
4403
+ return cloneDirectives(API_DIRECTIVES);
4404
+ }
4405
+ function strictApiContentSecurityPolicy() {
4406
+ return serializeDirectives(API_DIRECTIVES);
4407
+ }
4408
+ function serverHtmxContentSecurityPolicy(nonce) {
4409
+ return serializeDirectives(applyNonce(cloneDirectives(HTMX_HTML_DIRECTIVES), nonce));
4410
+ }
4411
+ function spaContentSecurityPolicy(nonce) {
4412
+ return serializeDirectives(applyNonce(cloneDirectives(SPA_HTML_DIRECTIVES), nonce));
4413
+ }
4414
+ function resolveHtmlContentSecurityPolicy(options = {}) {
4415
+ const resolved = {
4416
+ ...configuredHtmlOptions,
4417
+ ...options,
4418
+ directives: {
4419
+ ...configuredHtmlOptions.directives,
4420
+ ...options.directives
4421
+ }
4422
+ };
4423
+ const override = resolved.htmlCsp ?? resolved.csp;
4424
+ if (override) {
4425
+ return override;
4426
+ }
4427
+ return serializeDirectives(applyNonce(mergeDirectives(htmlBaselineDirectives(), resolved.directives), options.nonce));
4428
+ }
4429
+ function resolveContentSecurityPolicy(response, options = {}) {
4430
+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
4431
+ if (!contentType.includes("text/html")) {
4432
+ return strictApiContentSecurityPolicy();
4433
+ }
4434
+ return resolveHtmlContentSecurityPolicy(options);
4435
+ }
4309
4436
  // ../../src/core/http/cookies.ts
4310
4437
  function readRequestCookie(request, name) {
4311
4438
  const cookies = request.cookies;
@@ -4906,219 +5033,6 @@ function isViewsEnabled() {
4906
5033
  return readFrontendMode() === "server-htmx";
4907
5034
  }
4908
5035
 
4909
- // ../../src/core/view/etaViewEngine.ts
4910
- import { join as join4, relative } from "path";
4911
- import { Eta } from "eta";
4912
-
4913
- // ../../src/core/view/assertEtaHtmlSource.ts
4914
- var HTML_TAGS = new Set([
4915
- "a",
4916
- "abbr",
4917
- "address",
4918
- "article",
4919
- "aside",
4920
- "audio",
4921
- "b",
4922
- "blockquote",
4923
- "body",
4924
- "button",
4925
- "canvas",
4926
- "caption",
4927
- "cite",
4928
- "code",
4929
- "col",
4930
- "colgroup",
4931
- "data",
4932
- "datalist",
4933
- "dd",
4934
- "del",
4935
- "details",
4936
- "dfn",
4937
- "dialog",
4938
- "div",
4939
- "dl",
4940
- "dt",
4941
- "em",
4942
- "fieldset",
4943
- "figcaption",
4944
- "figure",
4945
- "footer",
4946
- "form",
4947
- "h1",
4948
- "h2",
4949
- "h3",
4950
- "h4",
4951
- "h5",
4952
- "h6",
4953
- "header",
4954
- "hgroup",
4955
- "hr",
4956
- "html",
4957
- "i",
4958
- "iframe",
4959
- "img",
4960
- "input",
4961
- "ins",
4962
- "kbd",
4963
- "label",
4964
- "legend",
4965
- "li",
4966
- "main",
4967
- "map",
4968
- "mark",
4969
- "menu",
4970
- "meter",
4971
- "nav",
4972
- "object",
4973
- "ol",
4974
- "optgroup",
4975
- "option",
4976
- "output",
4977
- "p",
4978
- "picture",
4979
- "pre",
4980
- "progress",
4981
- "q",
4982
- "rp",
4983
- "rt",
4984
- "ruby",
4985
- "s",
4986
- "samp",
4987
- "script",
4988
- "search",
4989
- "section",
4990
- "select",
4991
- "slot",
4992
- "small",
4993
- "source",
4994
- "span",
4995
- "strong",
4996
- "style",
4997
- "sub",
4998
- "summary",
4999
- "sup",
5000
- "table",
5001
- "tbody",
5002
- "td",
5003
- "template",
5004
- "textarea",
5005
- "tfoot",
5006
- "th",
5007
- "thead",
5008
- "time",
5009
- "title",
5010
- "tr",
5011
- "track",
5012
- "u",
5013
- "ul",
5014
- "var",
5015
- "video",
5016
- "wbr"
5017
- ]);
5018
- var TAG_PATTERN = "([a-z][a-z0-9]*)";
5019
- var CLASS_OR_ID_PATTERN = "[.#][\\w-]+";
5020
- var CLASS_ID_SHORTHAND = new RegExp(`^${TAG_PATTERN}(?:${CLASS_OR_ID_PATTERN})+`);
5021
- var ATTRIBUTE_SHORTHAND = new RegExp(`^${TAG_PATTERN}(?:${CLASS_OR_ID_PATTERN})*\\s+[\\w:-]+=`);
5022
- var TAG_SHORTHAND = new RegExp(`^${TAG_PATTERN}(?:>|\\s+\\S|$)`);
5023
- function htmlTagName(match) {
5024
- const tag = match?.[1];
5025
- return tag && HTML_TAGS.has(tag) ? tag : undefined;
5026
- }
5027
- function describePugLikeLine(line) {
5028
- if (/^\|(?:\s|$)/.test(line)) {
5029
- return "piped text";
5030
- }
5031
- if (htmlTagName(line.match(CLASS_ID_SHORTHAND))) {
5032
- return "class/id shorthand";
5033
- }
5034
- if (htmlTagName(line.match(ATTRIBUTE_SHORTHAND))) {
5035
- return "attribute shorthand";
5036
- }
5037
- if (htmlTagName(line.match(TAG_SHORTHAND))) {
5038
- return "tag shorthand";
5039
- }
5040
- return;
5041
- }
5042
- function updateSkipBlock(lower, skipBlock) {
5043
- if (skipBlock) {
5044
- if (skipBlock === "comment" && lower.includes("-->")) {
5045
- return null;
5046
- }
5047
- if (skipBlock !== "comment" && lower.includes(`</${skipBlock}`)) {
5048
- return null;
5049
- }
5050
- return skipBlock;
5051
- }
5052
- if (lower.startsWith("<!--") && !lower.includes("-->")) {
5053
- return "comment";
5054
- }
5055
- if (lower.startsWith("<script") && !lower.includes("</script")) {
5056
- return "script";
5057
- }
5058
- if (lower.startsWith("<style") && !lower.includes("</style")) {
5059
- return "style";
5060
- }
5061
- return null;
5062
- }
5063
- function assertEtaHtmlSource(templateName, source) {
5064
- let skipBlock = null;
5065
- for (const rawLine of source.split(/\r?\n/)) {
5066
- const trimmed = rawLine.trim();
5067
- if (!trimmed) {
5068
- continue;
5069
- }
5070
- const lower = trimmed.toLowerCase();
5071
- const nextSkipBlock = updateSkipBlock(lower, skipBlock);
5072
- if (skipBlock || nextSkipBlock) {
5073
- skipBlock = nextSkipBlock;
5074
- continue;
5075
- }
5076
- if (trimmed.startsWith("<") || trimmed.startsWith("<%")) {
5077
- continue;
5078
- }
5079
- const kind = describePugLikeLine(trimmed);
5080
- if (kind) {
5081
- throw new Error(`Eta view "${templateName}" contains Pug-like markup (${kind}: ${trimmed}). Eta views must be HTML + <% %> tags, not Pug.`);
5082
- }
5083
- }
5084
- }
5085
-
5086
- // ../../src/core/view/etaViewEngine.ts
5087
- var DEFAULT_VIEWS_DIRECTORY = join4(process.cwd(), "resources/views");
5088
- var DEFAULT_LAYOUT = "layouts/app.eta";
5089
-
5090
- class EtaViewEngine {
5091
- eta;
5092
- resolveLayoutData;
5093
- constructor(viewsDirectory = DEFAULT_VIEWS_DIRECTORY, resolveLayoutData) {
5094
- this.eta = new Eta({
5095
- views: viewsDirectory,
5096
- autoTrim: false
5097
- });
5098
- this.resolveLayoutData = resolveLayoutData;
5099
- const readFile2 = this.eta.readFile?.bind(this.eta);
5100
- this.eta.readFile = (path) => {
5101
- const source = readFile2 ? readFile2(path) : "";
5102
- assertEtaHtmlSource(relative(viewsDirectory, path) || path, source);
5103
- return source;
5104
- };
5105
- }
5106
- async render(name, data = {}, options = {}) {
5107
- const template = name.endsWith(".eta") ? name : `${name}.eta`;
5108
- const layoutData = this.resolveLayoutData ? await this.resolveLayoutData() : {};
5109
- const mergedData = { ...layoutData, ...data };
5110
- const body = await this.eta.renderAsync(template, mergedData);
5111
- const layout = options.layout ?? DEFAULT_LAYOUT;
5112
- if (layout === false) {
5113
- return body;
5114
- }
5115
- const layoutTemplate = layout.endsWith(".eta") ? layout : `${layout}.eta`;
5116
- return await this.eta.renderAsync(layoutTemplate, {
5117
- ...mergedData,
5118
- body
5119
- });
5120
- }
5121
- }
5122
5036
  // ../../src/core/view/htmlResponse.ts
5123
5037
  function withCharset(contentType) {
5124
5038
  return contentType.includes("charset=") ? contentType : `${contentType}; charset=utf-8`;
@@ -5143,9 +5057,6 @@ function redirectResponse(location, status = 302) {
5143
5057
  }
5144
5058
  });
5145
5059
  }
5146
- function notFoundHtmlResponse(body = "Not Found") {
5147
- return htmlResponse(body, { status: 404 });
5148
- }
5149
5060
  function textResponse(body, init = {}) {
5150
5061
  return new Response(body, {
5151
5062
  status: init.status ?? 200,
@@ -5165,57 +5076,82 @@ function xmlResponse(body, init = {}) {
5165
5076
  function rssResponse(body, init = {}) {
5166
5077
  return xmlResponse(body, { ...init, contentType: "application/rss+xml" });
5167
5078
  }
5168
- // ../../src/core/view/webLayoutData.ts
5169
- var configuredLayoutOptions = {};
5170
- function configureWebLayoutData(options) {
5171
- configuredLayoutOptions = { ...options };
5079
+
5080
+ // ../../src/core/view/webErrorView.ts
5081
+ var configuredErrorView = {};
5082
+ function configureWebErrorView(options) {
5083
+ configuredErrorView = { ...options };
5172
5084
  }
5173
- function layoutUserKey(options) {
5174
- return options.userKey ?? "authUser";
5085
+ function escapeHtml(value) {
5086
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
5087
+ }
5088
+ function renderKernelErrorChrome(input) {
5089
+ const title = escapeHtml(input.title);
5090
+ const message = escapeHtml(input.message);
5091
+ const errorLines = Object.entries(input.errors ?? {}).flatMap(([field, messages]) => messages.map((item) => `${field}: ${item}`)).map((line) => `<li>${escapeHtml(line)}</li>`).join("");
5092
+ const details = errorLines ? `<ul class="error-list">${errorLines}</ul>` : "";
5093
+ const goBack = input.status === 422 ? `<p><a href="javascript:history.back()">Go back</a></p>` : "";
5094
+ return `<!doctype html>
5095
+ <html lang="en">
5096
+ <head>
5097
+ <meta charset="UTF-8" />
5098
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
5099
+ <title>${title}</title>
5100
+ <link rel="stylesheet" href="/assets/app.css" />
5101
+ </head>
5102
+ <body>
5103
+ <header class="site-header">
5104
+ <a class="brand" href="/">Home</a>
5105
+ </header>
5106
+ <main class="site-main">
5107
+ <section class="page-header">
5108
+ <h1>${title}</h1>
5109
+ <p>${message}</p>
5110
+ ${details}
5111
+ ${goBack}
5112
+ </section>
5113
+ </main>
5114
+ </body>
5115
+ </html>
5116
+ `;
5175
5117
  }
5176
- async function defaultLoadLayoutUser(container, _request) {
5177
- const authUser = currentAuthUser();
5178
- if (!authUser) {
5179
- return null;
5118
+ function errorTemplateName(status) {
5119
+ if (status === 404) {
5120
+ return "errors/not-found";
5180
5121
  }
5181
- const userId = Number(authUser.id);
5182
- if (!Number.isInteger(userId) || userId <= 0) {
5183
- return null;
5122
+ if (status === 403) {
5123
+ return "errors/forbidden";
5184
5124
  }
5185
- const directory = resolveAuthUserDirectory(container);
5186
- if (!directory) {
5187
- return {
5188
- id: userId,
5189
- email: "",
5190
- role: authUser.role ?? "member"
5191
- };
5125
+ return "errors/error";
5126
+ }
5127
+ async function renderWebErrorHtml(input) {
5128
+ const render = configuredErrorView.render;
5129
+ if (!render) {
5130
+ return renderKernelErrorChrome(input);
5192
5131
  }
5193
5132
  try {
5194
- const user = await directory.findByIdOrThrow(userId);
5195
- return {
5196
- id: userId,
5197
- email: user.email ?? "",
5198
- role: authUser.role ?? user.role ?? "member"
5199
- };
5133
+ return await render({
5134
+ ...input,
5135
+ request: input.request ?? currentRequestMeta().request
5136
+ });
5200
5137
  } catch {
5201
- return null;
5138
+ return renderKernelErrorChrome(input);
5202
5139
  }
5203
5140
  }
5204
- async function resolveWebLayoutData(container, request, options = {}) {
5205
- const resolved = { ...configuredLayoutOptions, ...options };
5206
- const csrfToken = request ? resolveCsrfTokenForRequest(request) : "";
5207
- const flash = request ? currentRequestMeta().flash ?? pullFlash(request) : null;
5208
- const userKey = layoutUserKey(resolved);
5209
- const loadUser = resolved.loadUser ?? defaultLoadLayoutUser;
5210
- const user = await loadUser(container, request);
5211
- const extra = typeof resolved.extra === "function" ? await resolved.extra(user) : resolved.extra ?? {};
5212
- return {
5213
- [userKey]: user,
5214
- csrfToken,
5215
- flash,
5216
- ...extra
5217
- };
5141
+ async function htmlErrorResponse(input) {
5142
+ return htmlResponse(await renderWebErrorHtml(input), { status: input.status });
5143
+ }
5144
+ async function notFoundHtmlResponse(body) {
5145
+ if (body !== undefined) {
5146
+ return htmlResponse(body, { status: 404 });
5147
+ }
5148
+ return htmlErrorResponse({
5149
+ status: 404,
5150
+ title: "Not Found",
5151
+ message: "The page you requested was not found."
5152
+ });
5218
5153
  }
5154
+
5219
5155
  // ../../src/core/http/contentNegotiation.ts
5220
5156
  function requestPrefersJson(request) {
5221
5157
  if (!request) {
@@ -5239,6 +5175,39 @@ function requestPrefersJson(request) {
5239
5175
  return pathname.startsWith("/api/");
5240
5176
  }
5241
5177
 
5178
+ // ../../src/core/http/safeInternalPath.ts
5179
+ function looksLikeExternalTarget(value) {
5180
+ const trimmed = value.trim();
5181
+ if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
5182
+ return true;
5183
+ }
5184
+ if (trimmed.includes("://") || trimmed.includes(":/") || trimmed.includes(":\\")) {
5185
+ return true;
5186
+ }
5187
+ try {
5188
+ const decoded = decodeURIComponent(trimmed);
5189
+ if (decoded.startsWith("//") || decoded.includes("\\") || /https?:/i.test(decoded)) {
5190
+ return true;
5191
+ }
5192
+ } catch {
5193
+ return true;
5194
+ }
5195
+ return false;
5196
+ }
5197
+ function sanitizeInternalPath(raw, fallback = "/") {
5198
+ if (looksLikeExternalTarget(raw)) {
5199
+ return fallback;
5200
+ }
5201
+ return raw;
5202
+ }
5203
+ function safeInternalRedirectPath(request, fallback = "/") {
5204
+ const url = new URL(request.url);
5205
+ return sanitizeInternalPath(`${url.pathname}${url.search}`, fallback);
5206
+ }
5207
+ function loginRedirectLocation(request) {
5208
+ return `/login?redirect=${encodeURIComponent(safeInternalRedirectPath(request))}`;
5209
+ }
5210
+
5242
5211
  // ../../src/core/http/webErrorResponse.ts
5243
5212
  function normalizeFieldErrors(details) {
5244
5213
  if (!details || typeof details !== "object" || Array.isArray(details)) {
@@ -5256,23 +5225,37 @@ function normalizeFieldErrors(details) {
5256
5225
  }
5257
5226
  return errors;
5258
5227
  }
5259
- function webErrorResponse(error, request) {
5228
+ function errorPageTitle(status, message) {
5229
+ if (status === 404) {
5230
+ return "Not Found";
5231
+ }
5232
+ if (status === 403) {
5233
+ return "Forbidden";
5234
+ }
5235
+ if (status >= 500) {
5236
+ return "Server Error";
5237
+ }
5238
+ return message;
5239
+ }
5240
+ function publicErrorMessage(status, message) {
5241
+ if (status >= 500 && false) {}
5242
+ return message;
5243
+ }
5244
+ async function webErrorResponse(error, request) {
5260
5245
  if (!request || !isViewsEnabled() || requestPrefersJson(request)) {
5261
5246
  return null;
5262
5247
  }
5263
5248
  const mappedError = error instanceof HttpError ? error : mapDatabaseError(error);
5264
5249
  if (mappedError instanceof UnauthorizedError) {
5265
- const redirectTarget = encodeURIComponent(new URL(request.url).pathname);
5266
- return Response.redirect(`/login?redirect=${redirectTarget}`, 302);
5267
- }
5268
- if (mappedError instanceof ValidationError) {
5269
- const errors = normalizeFieldErrors(mappedError.details);
5270
- const fieldSummary = Object.entries(errors).flatMap(([field, messages]) => messages.map((message) => `${field}: ${message}`)).join(`
5271
- `);
5272
- return htmlResponse(`<section class="page-header"><h1>Validation failed</h1><pre>${fieldSummary || mappedError.message}</pre><p><a href="javascript:history.back()">Go back</a></p></section>`, { status: mappedError.status });
5273
- }
5274
- return htmlResponse(`<section class="page-header"><h1>${mappedError.message}</h1></section>`, {
5275
- status: mappedError.status
5250
+ return Response.redirect(loginRedirectLocation(request), 302);
5251
+ }
5252
+ const errors = mappedError instanceof ValidationError ? normalizeFieldErrors(mappedError.details) : undefined;
5253
+ return htmlErrorResponse({
5254
+ status: mappedError.status,
5255
+ title: errorPageTitle(mappedError.status, mappedError.message),
5256
+ message: publicErrorMessage(mappedError.status, mappedError.message),
5257
+ errors,
5258
+ request
5276
5259
  });
5277
5260
  }
5278
5261
 
@@ -5302,7 +5285,7 @@ function withErrorHandling(handler) {
5302
5285
  return await handler(...args);
5303
5286
  } catch (error) {
5304
5287
  const request = args.find((arg) => arg instanceof Request);
5305
- const webResponse = webErrorResponse(error, request);
5288
+ const webResponse = await webErrorResponse(error, request);
5306
5289
  if (webResponse) {
5307
5290
  return webResponse;
5308
5291
  }
@@ -5329,10 +5312,32 @@ function bindRouteModel(param, resolver, handler) {
5329
5312
  function isMutatingPolicyAction(action) {
5330
5313
  return action === "update" || action === "delete";
5331
5314
  }
5315
+ function modelHasIdentity(model) {
5316
+ return !!model && typeof model === "object" && "id" in model && model.id !== undefined && model.id !== null;
5317
+ }
5318
+ function shouldApplyViewEtag(response, model, authorization) {
5319
+ if (authorization.etag === false) {
5320
+ return false;
5321
+ }
5322
+ if (authorization.etag === true) {
5323
+ return true;
5324
+ }
5325
+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
5326
+ if (contentType.includes("text/html")) {
5327
+ return false;
5328
+ }
5329
+ return modelHasIdentity(model);
5330
+ }
5331
+ function requireResolvedModel(model) {
5332
+ if (model == null) {
5333
+ throw new NotFoundError;
5334
+ }
5335
+ return model;
5336
+ }
5332
5337
  function securedBindRouteModel(param, resolver, authorization, handler) {
5333
5338
  return async (request) => {
5334
5339
  const id = parsePositiveIntParam(String(request.params[param]), String(param));
5335
- const model = await resolver(id, request);
5340
+ const model = requireResolvedModel(await resolver(id, request));
5336
5341
  const gate = resolveApplicationPolicyGate();
5337
5342
  const auth2 = resolveApplicationAuth();
5338
5343
  const user = currentAuthUser() ?? await auth2.resolve(request);
@@ -5343,7 +5348,7 @@ function securedBindRouteModel(param, resolver, authorization, handler) {
5343
5348
  });
5344
5349
  }
5345
5350
  const response = await handler(request, model);
5346
- if (isEtagEnabled() && authorization.action === "view") {
5351
+ if (isEtagEnabled() && authorization.action === "view" && shouldApplyViewEtag(response, model, authorization)) {
5347
5352
  return applyConditionalGet(request, response, etagFromResource(model));
5348
5353
  }
5349
5354
  return response;
@@ -5355,7 +5360,7 @@ function securedBindRouteModelByKey(param, resolver, authorization, handler) {
5355
5360
  if (!key) {
5356
5361
  throw new BadRequestError(`Missing route parameter "${String(param)}".`);
5357
5362
  }
5358
- const model = await resolver(key, request);
5363
+ const model = requireResolvedModel(await resolver(key, request));
5359
5364
  const gate = resolveApplicationPolicyGate();
5360
5365
  const auth2 = resolveApplicationAuth();
5361
5366
  const user = currentAuthUser() ?? await auth2.resolve(request);
@@ -5366,7 +5371,7 @@ function securedBindRouteModelByKey(param, resolver, authorization, handler) {
5366
5371
  });
5367
5372
  }
5368
5373
  const response = await handler(request, model);
5369
- if (isEtagEnabled() && authorization.action === "view") {
5374
+ if (isEtagEnabled() && authorization.action === "view" && shouldApplyViewEtag(response, model, authorization)) {
5370
5375
  return applyConditionalGet(request, response, etagFromResource(model));
5371
5376
  }
5372
5377
  return response;
@@ -5589,8 +5594,7 @@ function createRequireWebAuthMiddleware(auth2) {
5589
5594
  if (requestPrefersJson(request)) {
5590
5595
  throw new UnauthorizedError;
5591
5596
  }
5592
- const redirectTarget = encodeURIComponent(new URL(request.url).pathname);
5593
- return Response.redirect(`/login?redirect=${redirectTarget}`, 302);
5597
+ return Response.redirect(loginRedirectLocation(request), 302);
5594
5598
  };
5595
5599
  }
5596
5600
  // ../../src/core/http/scimThrottleMiddleware.ts
@@ -5643,67 +5647,31 @@ var appConfig = {
5643
5647
  apiPrefix: process.env.API_PREFIX ?? "/api/v1"
5644
5648
  };
5645
5649
 
5646
- // ../../src/config/contentSecurityPolicy.ts
5647
- function strictApiContentSecurityPolicy() {
5648
- return "default-src 'none'; frame-ancestors 'none'; base-uri 'none'";
5649
- }
5650
- function serverHtmxContentSecurityPolicy() {
5651
- return [
5652
- "default-src 'self'",
5653
- "script-src 'self' https://unpkg.com",
5654
- "style-src 'self'",
5655
- "connect-src 'self'",
5656
- "img-src 'self'",
5657
- "font-src 'self'",
5658
- "form-action 'self'",
5659
- "frame-ancestors 'none'",
5660
- "base-uri 'self'"
5661
- ].join("; ");
5662
- }
5663
- function spaContentSecurityPolicy() {
5664
- return [
5665
- "default-src 'self'",
5666
- "script-src 'self'",
5667
- "style-src 'self'",
5668
- "connect-src 'self'",
5669
- "img-src 'self'",
5670
- "font-src 'self'",
5671
- "frame-ancestors 'none'",
5672
- "base-uri 'self'"
5673
- ].join("; ");
5674
- }
5675
- function resolveContentSecurityPolicy(response) {
5676
- const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
5677
- if (!contentType.includes("text/html")) {
5678
- return strictApiContentSecurityPolicy();
5679
- }
5680
- const frontendMode = (process.env.FRONTEND_MODE ?? "api").trim();
5681
- if (frontendMode === "server-htmx") {
5682
- return serverHtmxContentSecurityPolicy();
5683
- }
5684
- if (frontendMode === "spa-react") {
5685
- return spaContentSecurityPolicy();
5686
- }
5687
- return strictApiContentSecurityPolicy();
5688
- }
5689
-
5690
5650
  // ../../src/core/http/securityHeadersMiddleware.ts
5691
- function createSecurityHeadersMiddleware() {
5692
- return async (_request, next) => {
5693
- const response = await next();
5694
- const headers = new Headers(response.headers);
5695
- headers.set("X-Content-Type-Options", "nosniff");
5696
- headers.set("X-Frame-Options", "DENY");
5697
- headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
5698
- headers.set("X-XSS-Protection", "0");
5699
- headers.set("Content-Security-Policy", resolveContentSecurityPolicy(response));
5700
- if (appConfig.env === "production") {
5701
- headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
5702
- }
5703
- return new Response(response.body, {
5704
- status: response.status,
5705
- statusText: response.statusText,
5706
- headers
5651
+ function createSecurityHeadersMiddleware(options = {}) {
5652
+ return async (request, next) => {
5653
+ const nonce = generateCspNonce();
5654
+ const existing = currentRequestMeta();
5655
+ return await runWithRequestMeta({
5656
+ ...existing,
5657
+ request: existing.request ?? request,
5658
+ cspNonce: nonce
5659
+ }, async () => {
5660
+ const response = await next();
5661
+ const headers = new Headers(response.headers);
5662
+ headers.set("X-Content-Type-Options", "nosniff");
5663
+ headers.set("X-Frame-Options", "DENY");
5664
+ headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
5665
+ headers.set("X-XSS-Protection", "0");
5666
+ headers.set("Content-Security-Policy", resolveContentSecurityPolicy(response, { ...options, nonce }));
5667
+ if (appConfig.env === "production") {
5668
+ headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
5669
+ }
5670
+ return new Response(response.body, {
5671
+ status: response.status,
5672
+ statusText: response.statusText,
5673
+ headers
5674
+ });
5707
5675
  });
5708
5676
  };
5709
5677
  }
@@ -5825,6 +5793,7 @@ function installGracefulShutdownSignals(signals = ["SIGINT", "SIGTERM"]) {
5825
5793
  function createRequestLoggingMiddleware() {
5826
5794
  return async (request, next) => {
5827
5795
  return await runWithRequestMeta({
5796
+ ...currentRequestMeta(),
5828
5797
  ipAddress: readClientIp(request) ?? null,
5829
5798
  userAgent: request.headers.get("user-agent"),
5830
5799
  request
@@ -5845,7 +5814,7 @@ function createRequestLoggingMiddleware() {
5845
5814
  };
5846
5815
  }
5847
5816
  // ../../src/core/mail/markdownMail.ts
5848
- function escapeHtml(value) {
5817
+ function escapeHtml2(value) {
5849
5818
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
5850
5819
  }
5851
5820
  function stripMarkdown(markdown) {
@@ -5864,9 +5833,9 @@ function markdownToHtml(markdown) {
5864
5833
  `);
5865
5834
  }
5866
5835
  function wrapMarkdownMailLayout(bodyHtml, options = {}) {
5867
- const title = escapeHtml(options.title ?? "GetStrata");
5868
- const preview = escapeHtml(options.preview ?? "");
5869
- const footer = escapeHtml(options.footer ?? "Sent by GetStrata");
5836
+ const title = escapeHtml2(options.title ?? "GetStrata");
5837
+ const preview = escapeHtml2(options.preview ?? "");
5838
+ const footer = escapeHtml2(options.footer ?? "Sent by GetStrata");
5870
5839
  return `<!DOCTYPE html>
5871
5840
  <html lang="en">
5872
5841
  <head>
@@ -6689,6 +6658,272 @@ function validateObject(payload, schema) {
6689
6658
  }
6690
6659
  return output;
6691
6660
  }
6661
+ // ../../src/core/view/etaViewEngine.ts
6662
+ import { join as join4, relative } from "path";
6663
+ import { Eta } from "eta";
6664
+
6665
+ // ../../src/core/view/assertEtaHtmlSource.ts
6666
+ var HTML_TAGS = new Set([
6667
+ "a",
6668
+ "abbr",
6669
+ "address",
6670
+ "article",
6671
+ "aside",
6672
+ "audio",
6673
+ "b",
6674
+ "blockquote",
6675
+ "body",
6676
+ "button",
6677
+ "canvas",
6678
+ "caption",
6679
+ "cite",
6680
+ "code",
6681
+ "col",
6682
+ "colgroup",
6683
+ "data",
6684
+ "datalist",
6685
+ "dd",
6686
+ "del",
6687
+ "details",
6688
+ "dfn",
6689
+ "dialog",
6690
+ "div",
6691
+ "dl",
6692
+ "dt",
6693
+ "em",
6694
+ "fieldset",
6695
+ "figcaption",
6696
+ "figure",
6697
+ "footer",
6698
+ "form",
6699
+ "h1",
6700
+ "h2",
6701
+ "h3",
6702
+ "h4",
6703
+ "h5",
6704
+ "h6",
6705
+ "header",
6706
+ "hgroup",
6707
+ "hr",
6708
+ "html",
6709
+ "i",
6710
+ "iframe",
6711
+ "img",
6712
+ "input",
6713
+ "ins",
6714
+ "kbd",
6715
+ "label",
6716
+ "legend",
6717
+ "li",
6718
+ "main",
6719
+ "map",
6720
+ "mark",
6721
+ "menu",
6722
+ "meter",
6723
+ "nav",
6724
+ "object",
6725
+ "ol",
6726
+ "optgroup",
6727
+ "option",
6728
+ "output",
6729
+ "p",
6730
+ "picture",
6731
+ "pre",
6732
+ "progress",
6733
+ "q",
6734
+ "rp",
6735
+ "rt",
6736
+ "ruby",
6737
+ "s",
6738
+ "samp",
6739
+ "script",
6740
+ "search",
6741
+ "section",
6742
+ "select",
6743
+ "slot",
6744
+ "small",
6745
+ "source",
6746
+ "span",
6747
+ "strong",
6748
+ "style",
6749
+ "sub",
6750
+ "summary",
6751
+ "sup",
6752
+ "table",
6753
+ "tbody",
6754
+ "td",
6755
+ "template",
6756
+ "textarea",
6757
+ "tfoot",
6758
+ "th",
6759
+ "thead",
6760
+ "time",
6761
+ "title",
6762
+ "tr",
6763
+ "track",
6764
+ "u",
6765
+ "ul",
6766
+ "var",
6767
+ "video",
6768
+ "wbr"
6769
+ ]);
6770
+ var TAG_PATTERN = "([a-z][a-z0-9]*)";
6771
+ var CLASS_OR_ID_PATTERN = "[.#][\\w-]+";
6772
+ var CLASS_ID_SHORTHAND = new RegExp(`^${TAG_PATTERN}(?:${CLASS_OR_ID_PATTERN})+`);
6773
+ var ATTRIBUTE_SHORTHAND = new RegExp(`^${TAG_PATTERN}(?:${CLASS_OR_ID_PATTERN})*\\s+[\\w:-]+=`);
6774
+ var TAG_SHORTHAND = new RegExp(`^${TAG_PATTERN}(?:>|\\s+\\S|$)`);
6775
+ function htmlTagName(match) {
6776
+ const tag = match?.[1];
6777
+ return tag && HTML_TAGS.has(tag) ? tag : undefined;
6778
+ }
6779
+ function describePugLikeLine(line) {
6780
+ if (/^\|(?:\s|$)/.test(line)) {
6781
+ return "piped text";
6782
+ }
6783
+ if (htmlTagName(line.match(CLASS_ID_SHORTHAND))) {
6784
+ return "class/id shorthand";
6785
+ }
6786
+ if (htmlTagName(line.match(ATTRIBUTE_SHORTHAND))) {
6787
+ return "attribute shorthand";
6788
+ }
6789
+ if (htmlTagName(line.match(TAG_SHORTHAND))) {
6790
+ return "tag shorthand";
6791
+ }
6792
+ return;
6793
+ }
6794
+ function updateSkipBlock(lower, skipBlock) {
6795
+ if (skipBlock) {
6796
+ if (skipBlock === "comment" && lower.includes("-->")) {
6797
+ return null;
6798
+ }
6799
+ if (skipBlock !== "comment" && lower.includes(`</${skipBlock}`)) {
6800
+ return null;
6801
+ }
6802
+ return skipBlock;
6803
+ }
6804
+ if (lower.startsWith("<!--") && !lower.includes("-->")) {
6805
+ return "comment";
6806
+ }
6807
+ if (lower.startsWith("<script") && !lower.includes("</script")) {
6808
+ return "script";
6809
+ }
6810
+ if (lower.startsWith("<style") && !lower.includes("</style")) {
6811
+ return "style";
6812
+ }
6813
+ return null;
6814
+ }
6815
+ function assertEtaHtmlSource(templateName, source) {
6816
+ let skipBlock = null;
6817
+ for (const rawLine of source.split(/\r?\n/)) {
6818
+ const trimmed = rawLine.trim();
6819
+ if (!trimmed) {
6820
+ continue;
6821
+ }
6822
+ const lower = trimmed.toLowerCase();
6823
+ const nextSkipBlock = updateSkipBlock(lower, skipBlock);
6824
+ if (skipBlock || nextSkipBlock) {
6825
+ skipBlock = nextSkipBlock;
6826
+ continue;
6827
+ }
6828
+ if (trimmed.startsWith("<") || trimmed.startsWith("<%")) {
6829
+ continue;
6830
+ }
6831
+ const kind = describePugLikeLine(trimmed);
6832
+ if (kind) {
6833
+ throw new Error(`Eta view "${templateName}" contains Pug-like markup (${kind}: ${trimmed}). Eta views must be HTML + <% %> tags, not Pug.`);
6834
+ }
6835
+ }
6836
+ }
6837
+
6838
+ // ../../src/core/view/etaViewEngine.ts
6839
+ var DEFAULT_VIEWS_DIRECTORY = join4(process.cwd(), "resources/views");
6840
+ var DEFAULT_LAYOUT = "layouts/app.eta";
6841
+
6842
+ class EtaViewEngine {
6843
+ eta;
6844
+ resolveLayoutData;
6845
+ constructor(viewsDirectory = DEFAULT_VIEWS_DIRECTORY, resolveLayoutData) {
6846
+ this.eta = new Eta({
6847
+ views: viewsDirectory,
6848
+ autoTrim: false
6849
+ });
6850
+ this.resolveLayoutData = resolveLayoutData;
6851
+ const readFile2 = this.eta.readFile?.bind(this.eta);
6852
+ this.eta.readFile = (path) => {
6853
+ const source = readFile2 ? readFile2(path) : "";
6854
+ assertEtaHtmlSource(relative(viewsDirectory, path) || path, source);
6855
+ return source;
6856
+ };
6857
+ }
6858
+ async render(name, data = {}, options = {}) {
6859
+ const template = name.endsWith(".eta") ? name : `${name}.eta`;
6860
+ const request = options.request ?? currentRequestMeta().request;
6861
+ const layoutData = this.resolveLayoutData ? await this.resolveLayoutData(request) : {};
6862
+ const mergedData = { ...layoutData, ...data };
6863
+ const body = await this.eta.renderAsync(template, mergedData);
6864
+ const layout = options.layout ?? DEFAULT_LAYOUT;
6865
+ if (layout === false) {
6866
+ return body;
6867
+ }
6868
+ const layoutTemplate = layout.endsWith(".eta") ? layout : `${layout}.eta`;
6869
+ return await this.eta.renderAsync(layoutTemplate, {
6870
+ ...mergedData,
6871
+ body
6872
+ });
6873
+ }
6874
+ }
6875
+ // ../../src/core/view/webLayoutData.ts
6876
+ var configuredLayoutOptions = {};
6877
+ function configureWebLayoutData(options) {
6878
+ configuredLayoutOptions = { ...options };
6879
+ }
6880
+ function layoutUserKey(options) {
6881
+ return options.userKey ?? "authUser";
6882
+ }
6883
+ async function defaultLoadLayoutUser(container, _request) {
6884
+ const authUser = currentAuthUser();
6885
+ if (!authUser) {
6886
+ return null;
6887
+ }
6888
+ const userId = Number(authUser.id);
6889
+ if (!Number.isInteger(userId) || userId <= 0) {
6890
+ return null;
6891
+ }
6892
+ const directory = resolveAuthUserDirectory(container);
6893
+ if (!directory) {
6894
+ return {
6895
+ id: userId,
6896
+ email: "",
6897
+ role: authUser.role ?? "member"
6898
+ };
6899
+ }
6900
+ try {
6901
+ const user = await directory.findByIdOrThrow(userId);
6902
+ return {
6903
+ id: userId,
6904
+ email: user.email ?? "",
6905
+ role: authUser.role ?? user.role ?? "member"
6906
+ };
6907
+ } catch {
6908
+ return null;
6909
+ }
6910
+ }
6911
+ async function resolveWebLayoutData(container, request, options = {}) {
6912
+ const resolved = { ...configuredLayoutOptions, ...options };
6913
+ const csrfToken = request ? resolveCsrfTokenForRequest(request) : "";
6914
+ const flash = request ? currentRequestMeta().flash ?? pullFlash(request) : null;
6915
+ const userKey = layoutUserKey(resolved);
6916
+ const loadUser = resolved.loadUser ?? defaultLoadLayoutUser;
6917
+ const user = await loadUser(container, request);
6918
+ const extra = typeof resolved.extra === "function" ? await resolved.extra(user) : resolved.extra ?? {};
6919
+ return {
6920
+ [userKey]: user,
6921
+ csrfToken,
6922
+ flash,
6923
+ cspNonce: currentRequestMeta().cspNonce ?? "",
6924
+ ...extra
6925
+ };
6926
+ }
6692
6927
  export {
6693
6928
  AdminResourceRegistry,
6694
6929
  ApiTokenGuard,
@@ -6773,7 +7008,9 @@ export {
6773
7008
  composeMiddleware,
6774
7009
  conditionalJsonResponse,
6775
7010
  config,
7011
+ configureContentSecurityPolicy,
6776
7012
  configureMembershipLookup,
7013
+ configureWebErrorView,
6777
7014
  configureWebLayoutData,
6778
7015
  createAuthMiddleware,
6779
7016
  createAuthorizeMiddleware,
@@ -6822,12 +7059,14 @@ export {
6822
7059
  dehydrateValue,
6823
7060
  emailRule,
6824
7061
  emptyPaginateResult,
7062
+ errorTemplateName,
6825
7063
  etagFromResource,
6826
7064
  eventBus,
6827
7065
  events,
6828
7066
  filterMassAssignable,
6829
7067
  formatAdminValue,
6830
7068
  freshDatabase,
7069
+ generateCspNonce,
6831
7070
  getActiveDatabaseConnection,
6832
7071
  getBoundDatabaseConnection,
6833
7072
  getDefaultDatabasePool,
@@ -6839,6 +7078,7 @@ export {
6839
7078
  hasMinimumOrgRole2 as hasMinimumOrgRole,
6840
7079
  hasOne,
6841
7080
  hasOrgMembership,
7081
+ htmlErrorResponse,
6842
7082
  htmlResponse,
6843
7083
  hydrateValue,
6844
7084
  indexBelongsToManyRelation,
@@ -6862,6 +7102,7 @@ export {
6862
7102
  loadSeedersFromDirectory,
6863
7103
  log,
6864
7104
  logSecurityEvent,
7105
+ loginRedirectLocation,
6865
7106
  mail,
6866
7107
  mailer,
6867
7108
  markdownToHtml,
@@ -6893,6 +7134,7 @@ export {
6893
7134
  registerDefaultDatabasePool,
6894
7135
  registerModelRepository,
6895
7136
  registerShutdownHandler,
7137
+ renderKernelErrorChrome,
6896
7138
  renderMarkdownMail,
6897
7139
  repositoryConnection,
6898
7140
  requestIdMiddleware,
@@ -6908,9 +7150,11 @@ export {
6908
7150
  resolveApplicationLogger,
6909
7151
  resolveApplicationPolicyGate,
6910
7152
  resolveApplicationQueue,
7153
+ resolveContentSecurityPolicy,
6911
7154
  resolveCsrfToken,
6912
7155
  resolveCsrfTokenForRequest,
6913
7156
  resolveDatabaseDriver,
7157
+ resolveHtmlContentSecurityPolicy,
6914
7158
  resolveMembershipService,
6915
7159
  resolveOrganizationScope,
6916
7160
  resolveRepositoryConnection,
@@ -6931,13 +7175,18 @@ export {
6931
7175
  runWithTenant,
6932
7176
  runWithTenantDatabase,
6933
7177
  runWithTraceContext,
7178
+ safeInternalRedirectPath,
7179
+ sanitizeInternalPath,
6934
7180
  scopedOrganizationIds,
6935
7181
  securedBindRouteModel,
6936
7182
  securedBindRouteModelByKey,
6937
7183
  sendMarkdownMail,
6938
7184
  serializeDate,
7185
+ serverHtmxContentSecurityPolicy,
6939
7186
  setActiveApplicationContext,
7187
+ spaContentSecurityPolicy,
6940
7188
  storageFacade as storage,
7189
+ strictApiContentSecurityPolicy,
6941
7190
  stringRule,
6942
7191
  stripMarkdown,
6943
7192
  textResponse,