@getstrata/core 0.5.60 → 0.5.62

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +1 -1
  3. package/dist/core/http/contentSecurityPolicy.d.ts +27 -0
  4. package/dist/core/http/requestMetaContext.d.ts +1 -0
  5. package/dist/core/http/safeInternalPath.d.ts +4 -0
  6. package/dist/core/http/securedRouteModelBinding.d.ts +2 -0
  7. package/dist/core/http/securityHeadersMiddleware.d.ts +5 -2
  8. package/dist/core/http/signedUrl.d.ts +11 -0
  9. package/dist/core/http/webErrorResponse.d.ts +1 -1
  10. package/dist/core/jobs/exportAuditLogsJob.d.ts +12 -0
  11. package/dist/core/mail/sanitizeMailHtml.d.ts +2 -0
  12. package/dist/core/security/totp.d.ts +7 -1
  13. package/dist/core/view/etaViewEngine.d.ts +3 -2
  14. package/dist/core/view/htmlResponse.d.ts +1 -2
  15. package/dist/core/view/index.d.ts +4 -1
  16. package/dist/core/view/viewEngine.d.ts +1 -0
  17. package/dist/core/view/webErrorView.d.ts +21 -0
  18. package/dist/entries/audit/exportAuditLogs.js +1 -1
  19. package/dist/entries/http/contentSecurityPolicy.js +1 -0
  20. package/dist/entries/http/requireAbilityMiddleware.js +44 -1
  21. package/dist/entries/http/requireWebAuthMiddleware.js +36 -3
  22. package/dist/entries/http/response.js +127 -555
  23. package/dist/entries/http/safeInternalPath.js +38 -0
  24. package/dist/entries/http/securedRouteModelBinding.js +27 -5
  25. package/dist/entries/http/securityHeadersMiddleware.js +34 -61
  26. package/dist/entries/http/signedUrl.js +97 -0
  27. package/dist/entries/http/webErrorResponse.js +126 -554
  28. package/dist/entries/jobs/dispatchWebhookJob.js +14 -12
  29. package/dist/entries/jobs/exportAuditLogsJob.js +340 -0
  30. package/dist/entries/logging/requestLoggingMiddleware.js +2 -1
  31. package/dist/entries/mail/markdownMail.js +62 -12
  32. package/dist/entries/mail/markdownMailable.js +62 -12
  33. package/dist/entries/mail/sanitizeMailHtml.js +60 -0
  34. package/dist/entries/openapi/generator.js +1 -1
  35. package/dist/entries/security/safeFetch.js +1 -1
  36. package/dist/entries/security/totp.js +31 -1
  37. package/dist/entries/view.js +88 -8
  38. package/dist/framework/public-api.d.ts +6 -1
  39. package/dist/index.js +744 -349
  40. package/package.json +27 -2
  41. package/dist/config/contentSecurityPolicy.d.ts +0 -5
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;
@@ -5535,12 +5540,15 @@ function createMetricsMiddleware() {
5535
5540
  // ../../src/core/http/requireAbilityMiddleware.ts
5536
5541
  function createRequireAbilityMiddleware(abilityChecker) {
5537
5542
  return (ability) => {
5538
- return async (_request, next) => {
5543
+ return async (request, next) => {
5539
5544
  const user = currentAuthUser();
5540
5545
  try {
5541
5546
  abilityChecker.requireAbility(user, ability);
5542
5547
  } catch (error) {
5543
5548
  if (error instanceof ForbiddenError) {
5549
+ if (isViewsEnabled() && !requestPrefersJson(request)) {
5550
+ throw error;
5551
+ }
5544
5552
  return Response.json({ error: error.message }, { status: error.status });
5545
5553
  }
5546
5554
  throw error;
@@ -5584,13 +5592,12 @@ function createRequireWebAuthMiddleware(auth2) {
5584
5592
  return async (request, next) => {
5585
5593
  const user = await auth2.resolve(request);
5586
5594
  if (user) {
5587
- return await next();
5595
+ return await runWithAuthUser(user, () => next());
5588
5596
  }
5589
5597
  if (requestPrefersJson(request)) {
5590
5598
  throw new UnauthorizedError;
5591
5599
  }
5592
- const redirectTarget = encodeURIComponent(new URL(request.url).pathname);
5593
- return Response.redirect(`/login?redirect=${redirectTarget}`, 302);
5600
+ return Response.redirect(loginRedirectLocation(request), 302);
5594
5601
  };
5595
5602
  }
5596
5603
  // ../../src/core/http/scimThrottleMiddleware.ts
@@ -5636,76 +5643,127 @@ function createScimThrottleMiddleware(options) {
5636
5643
  }
5637
5644
  // ../../src/config/app.ts
5638
5645
  var appConfig = {
5639
- name: "WorkHub",
5646
+ name: process.env.APP_NAME?.trim() || "WorkHub",
5640
5647
  env: process.env.APP_ENV ?? "local",
5641
5648
  debug: (process.env.APP_DEBUG ?? "true") !== "false",
5642
5649
  url: process.env.APP_URL ?? "http://localhost:3000",
5643
5650
  apiPrefix: process.env.API_PREFIX ?? "/api/v1"
5644
5651
  };
5645
5652
 
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();
5653
+ // ../../src/core/http/securityHeadersMiddleware.ts
5654
+ function createSecurityHeadersMiddleware(options = {}) {
5655
+ return async (request, next) => {
5656
+ const nonce = generateCspNonce();
5657
+ const existing = currentRequestMeta();
5658
+ return await runWithRequestMeta({
5659
+ ...existing,
5660
+ request: existing.request ?? request,
5661
+ cspNonce: nonce
5662
+ }, async () => {
5663
+ const response = await next();
5664
+ const headers = new Headers(response.headers);
5665
+ headers.set("X-Content-Type-Options", "nosniff");
5666
+ headers.set("X-Frame-Options", "DENY");
5667
+ headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
5668
+ headers.set("X-XSS-Protection", "0");
5669
+ headers.set("Content-Security-Policy", resolveContentSecurityPolicy(response, { ...options, nonce }));
5670
+ if (appConfig.env === "production") {
5671
+ headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
5672
+ }
5673
+ return new Response(response.body, {
5674
+ status: response.status,
5675
+ statusText: response.statusText,
5676
+ headers
5677
+ });
5678
+ });
5679
+ };
5680
+ }
5681
+ // ../../src/core/http/signedUrl.ts
5682
+ import { createHmac as createHmac2 } from "crypto";
5683
+ function resolveSignedUrlSecret() {
5684
+ return process.env.SIGNED_URL_SECRET?.trim() || process.env.SESSION_SECRET?.trim() || process.env.OAUTH_STATE_SECRET?.trim() || "workhub-dev-signed-url-secret";
5685
+ }
5686
+ function resolveSignedUrlOrigin() {
5687
+ return (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
5688
+ }
5689
+ function normalizeSignedPath(path) {
5690
+ const trimmed = path.trim();
5691
+ if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
5692
+ throw new Error("Signed URLs must use a same-origin absolute path.");
5679
5693
  }
5680
- const frontendMode = (process.env.FRONTEND_MODE ?? "api").trim();
5681
- if (frontendMode === "server-htmx") {
5682
- return serverHtmxContentSecurityPolicy();
5694
+ if (trimmed.includes("://")) {
5695
+ throw new Error("Signed URLs must use a same-origin absolute path.");
5683
5696
  }
5684
- if (frontendMode === "spa-react") {
5685
- return spaContentSecurityPolicy();
5697
+ return trimmed;
5698
+ }
5699
+ function sortedQueryString(params) {
5700
+ const entries = [...params.entries()].filter(([key]) => key !== "signature").sort(([left], [right]) => left.localeCompare(right));
5701
+ return new URLSearchParams(entries).toString();
5702
+ }
5703
+ function signCanonicalPayload(path, query) {
5704
+ return createHmac2("sha256", resolveSignedUrlSecret()).update(`${path}
5705
+ ${query}`).digest("hex");
5706
+ }
5707
+ function buildSignedSearchParams(path, query = {}, expiresAt) {
5708
+ const params = new URLSearchParams;
5709
+ for (const [key, value] of Object.entries(query)) {
5710
+ if (key === "signature" || key === "expires") {
5711
+ continue;
5712
+ }
5713
+ params.set(key, String(value));
5714
+ }
5715
+ if (expiresAt !== undefined) {
5716
+ params.set("expires", String(expiresAt));
5686
5717
  }
5687
- return strictApiContentSecurityPolicy();
5718
+ params.set("signature", signCanonicalPayload(path, sortedQueryString(params)));
5719
+ return params;
5688
5720
  }
5689
-
5690
- // ../../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");
5721
+ function signedUrl(path, query = {}) {
5722
+ const normalizedPath = normalizeSignedPath(path);
5723
+ const params = buildSignedSearchParams(normalizedPath, query);
5724
+ return `${normalizedPath}?${params.toString()}`;
5725
+ }
5726
+ function temporarySignedUrl(path, expiresInSeconds, query = {}) {
5727
+ if (!Number.isInteger(expiresInSeconds) || expiresInSeconds <= 0) {
5728
+ throw new Error("Signed URL expiry must be a positive integer number of seconds.");
5729
+ }
5730
+ const normalizedPath = normalizeSignedPath(path);
5731
+ const expiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds;
5732
+ const params = buildSignedSearchParams(normalizedPath, query, expiresAt);
5733
+ return `${normalizedPath}?${params.toString()}`;
5734
+ }
5735
+ function absoluteTemporarySignedUrl(path, expiresInSeconds, query = {}, origin = resolveSignedUrlOrigin()) {
5736
+ return `${origin.replace(/\/$/, "")}${temporarySignedUrl(path, expiresInSeconds, query)}`;
5737
+ }
5738
+ function readSignedRequestUrl(input) {
5739
+ if (input instanceof URL) {
5740
+ return input;
5741
+ }
5742
+ if (typeof input === "string") {
5743
+ return new URL(input, resolveSignedUrlOrigin());
5744
+ }
5745
+ return new URL(input.url);
5746
+ }
5747
+ function hasValidSignature(input) {
5748
+ const url = readSignedRequestUrl(input);
5749
+ const signature = url.searchParams.get("signature");
5750
+ if (!signature) {
5751
+ return false;
5752
+ }
5753
+ const expires = url.searchParams.get("expires");
5754
+ if (expires) {
5755
+ const expiresAt = Number.parseInt(expires, 10);
5756
+ if (!Number.isInteger(expiresAt) || expiresAt <= Math.floor(Date.now() / 1000)) {
5757
+ return false;
5702
5758
  }
5703
- return new Response(response.body, {
5704
- status: response.status,
5705
- statusText: response.statusText,
5706
- headers
5707
- });
5708
- };
5759
+ }
5760
+ const expected = signCanonicalPayload(url.pathname, sortedQueryString(url.searchParams));
5761
+ return timingSafeCompareString(signature, expected);
5762
+ }
5763
+ function assertValidSignature(input) {
5764
+ if (!hasValidSignature(input)) {
5765
+ throw new ForbiddenError("Invalid or expired signed URL.");
5766
+ }
5709
5767
  }
5710
5768
  // ../../src/core/http/throttleMiddleware.ts
5711
5769
  var {RedisClient: RedisClient4 } = globalThis.Bun;
@@ -5825,6 +5883,7 @@ function installGracefulShutdownSignals(signals = ["SIGINT", "SIGTERM"]) {
5825
5883
  function createRequestLoggingMiddleware() {
5826
5884
  return async (request, next) => {
5827
5885
  return await runWithRequestMeta({
5886
+ ...currentRequestMeta(),
5828
5887
  ipAddress: readClientIp(request) ?? null,
5829
5888
  userAgent: request.headers.get("user-agent"),
5830
5889
  request
@@ -5844,8 +5903,65 @@ function createRequestLoggingMiddleware() {
5844
5903
  });
5845
5904
  };
5846
5905
  }
5906
+ // ../../src/core/mail/sanitizeMailHtml.ts
5907
+ var ALLOWED_TAGS = new Set([
5908
+ "a",
5909
+ "blockquote",
5910
+ "br",
5911
+ "code",
5912
+ "em",
5913
+ "h1",
5914
+ "h2",
5915
+ "h3",
5916
+ "h4",
5917
+ "h5",
5918
+ "h6",
5919
+ "hr",
5920
+ "li",
5921
+ "ol",
5922
+ "p",
5923
+ "pre",
5924
+ "strong",
5925
+ "ul"
5926
+ ]);
5927
+ function escapeHtml2(value) {
5928
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
5929
+ }
5930
+ function decodeBasicEntities(value) {
5931
+ return value.replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
5932
+ }
5933
+ function isSafeHref(value) {
5934
+ return /^(https?:|mailto:)/i.test(value.trim());
5935
+ }
5936
+ function sanitizeAttributes(tagName, rawAttributes) {
5937
+ if (tagName !== "a") {
5938
+ return "";
5939
+ }
5940
+ const hrefMatch = rawAttributes.match(/\bhref\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
5941
+ const href = decodeBasicEntities(hrefMatch?.[2] ?? hrefMatch?.[3] ?? hrefMatch?.[4] ?? "");
5942
+ if (!href || !isSafeHref(href)) {
5943
+ return "";
5944
+ }
5945
+ return ` href="${escapeHtml2(href)}"`;
5946
+ }
5947
+ function sanitizeMailHtml(html) {
5948
+ return html.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)>/g, (match, tagName, rawAttributes) => {
5949
+ const name = tagName.toLowerCase();
5950
+ if (!ALLOWED_TAGS.has(name)) {
5951
+ return "";
5952
+ }
5953
+ if (match.startsWith("</")) {
5954
+ return `</${name}>`;
5955
+ }
5956
+ if (match.endsWith("/>") || name === "br" || name === "hr") {
5957
+ return `<${name}>`;
5958
+ }
5959
+ return `<${name}${sanitizeAttributes(name, rawAttributes)}>`;
5960
+ });
5961
+ }
5962
+
5847
5963
  // ../../src/core/mail/markdownMail.ts
5848
- function escapeHtml(value) {
5964
+ function escapeHtml3(value) {
5849
5965
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
5850
5966
  }
5851
5967
  function stripMarkdown(markdown) {
@@ -5854,19 +5970,12 @@ function stripMarkdown(markdown) {
5854
5970
  `).trim();
5855
5971
  }
5856
5972
  function markdownToHtml(markdown) {
5857
- const escaped = markdown.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
5858
- return escaped.replace(/^### (.+)$/gm, "<h3>$1</h3>").replace(/^## (.+)$/gm, "<h2>$1</h2>").replace(/^# (.+)$/gm, "<h1>$1</h1>").replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>').replace(/^[-*]\s+(.+)$/gm, "<li>$1</li>").replace(/(<li>[\s\S]*?<\/li>\n?)+/g, (block) => `<ul>${block}</ul>`).replace(/```(\w+)?\n([\s\S]*?)```/g, "<pre><code>$2</code></pre>").split(/\n\n+/).map((block) => {
5859
- if (block.startsWith("<")) {
5860
- return block;
5861
- }
5862
- return `<p>${block.replace(/\n/g, " ")}</p>`;
5863
- }).join(`
5864
- `);
5973
+ return sanitizeMailHtml(Bun.markdown.html(markdown));
5865
5974
  }
5866
5975
  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");
5976
+ const title = escapeHtml3(options.title ?? "GetStrata");
5977
+ const preview = escapeHtml3(options.preview ?? "");
5978
+ const footer = escapeHtml3(options.footer ?? "Sent by GetStrata");
5870
5979
  return `<!DOCTYPE html>
5871
5980
  <html lang="en">
5872
5981
  <head>
@@ -6689,6 +6798,272 @@ function validateObject(payload, schema) {
6689
6798
  }
6690
6799
  return output;
6691
6800
  }
6801
+ // ../../src/core/view/etaViewEngine.ts
6802
+ import { join as join4, relative } from "path";
6803
+ import { Eta } from "eta";
6804
+
6805
+ // ../../src/core/view/assertEtaHtmlSource.ts
6806
+ var HTML_TAGS = new Set([
6807
+ "a",
6808
+ "abbr",
6809
+ "address",
6810
+ "article",
6811
+ "aside",
6812
+ "audio",
6813
+ "b",
6814
+ "blockquote",
6815
+ "body",
6816
+ "button",
6817
+ "canvas",
6818
+ "caption",
6819
+ "cite",
6820
+ "code",
6821
+ "col",
6822
+ "colgroup",
6823
+ "data",
6824
+ "datalist",
6825
+ "dd",
6826
+ "del",
6827
+ "details",
6828
+ "dfn",
6829
+ "dialog",
6830
+ "div",
6831
+ "dl",
6832
+ "dt",
6833
+ "em",
6834
+ "fieldset",
6835
+ "figcaption",
6836
+ "figure",
6837
+ "footer",
6838
+ "form",
6839
+ "h1",
6840
+ "h2",
6841
+ "h3",
6842
+ "h4",
6843
+ "h5",
6844
+ "h6",
6845
+ "header",
6846
+ "hgroup",
6847
+ "hr",
6848
+ "html",
6849
+ "i",
6850
+ "iframe",
6851
+ "img",
6852
+ "input",
6853
+ "ins",
6854
+ "kbd",
6855
+ "label",
6856
+ "legend",
6857
+ "li",
6858
+ "main",
6859
+ "map",
6860
+ "mark",
6861
+ "menu",
6862
+ "meter",
6863
+ "nav",
6864
+ "object",
6865
+ "ol",
6866
+ "optgroup",
6867
+ "option",
6868
+ "output",
6869
+ "p",
6870
+ "picture",
6871
+ "pre",
6872
+ "progress",
6873
+ "q",
6874
+ "rp",
6875
+ "rt",
6876
+ "ruby",
6877
+ "s",
6878
+ "samp",
6879
+ "script",
6880
+ "search",
6881
+ "section",
6882
+ "select",
6883
+ "slot",
6884
+ "small",
6885
+ "source",
6886
+ "span",
6887
+ "strong",
6888
+ "style",
6889
+ "sub",
6890
+ "summary",
6891
+ "sup",
6892
+ "table",
6893
+ "tbody",
6894
+ "td",
6895
+ "template",
6896
+ "textarea",
6897
+ "tfoot",
6898
+ "th",
6899
+ "thead",
6900
+ "time",
6901
+ "title",
6902
+ "tr",
6903
+ "track",
6904
+ "u",
6905
+ "ul",
6906
+ "var",
6907
+ "video",
6908
+ "wbr"
6909
+ ]);
6910
+ var TAG_PATTERN = "([a-z][a-z0-9]*)";
6911
+ var CLASS_OR_ID_PATTERN = "[.#][\\w-]+";
6912
+ var CLASS_ID_SHORTHAND = new RegExp(`^${TAG_PATTERN}(?:${CLASS_OR_ID_PATTERN})+`);
6913
+ var ATTRIBUTE_SHORTHAND = new RegExp(`^${TAG_PATTERN}(?:${CLASS_OR_ID_PATTERN})*\\s+[\\w:-]+=`);
6914
+ var TAG_SHORTHAND = new RegExp(`^${TAG_PATTERN}(?:>|\\s+\\S|$)`);
6915
+ function htmlTagName(match) {
6916
+ const tag = match?.[1];
6917
+ return tag && HTML_TAGS.has(tag) ? tag : undefined;
6918
+ }
6919
+ function describePugLikeLine(line) {
6920
+ if (/^\|(?:\s|$)/.test(line)) {
6921
+ return "piped text";
6922
+ }
6923
+ if (htmlTagName(line.match(CLASS_ID_SHORTHAND))) {
6924
+ return "class/id shorthand";
6925
+ }
6926
+ if (htmlTagName(line.match(ATTRIBUTE_SHORTHAND))) {
6927
+ return "attribute shorthand";
6928
+ }
6929
+ if (htmlTagName(line.match(TAG_SHORTHAND))) {
6930
+ return "tag shorthand";
6931
+ }
6932
+ return;
6933
+ }
6934
+ function updateSkipBlock(lower, skipBlock) {
6935
+ if (skipBlock) {
6936
+ if (skipBlock === "comment" && lower.includes("-->")) {
6937
+ return null;
6938
+ }
6939
+ if (skipBlock !== "comment" && lower.includes(`</${skipBlock}`)) {
6940
+ return null;
6941
+ }
6942
+ return skipBlock;
6943
+ }
6944
+ if (lower.startsWith("<!--") && !lower.includes("-->")) {
6945
+ return "comment";
6946
+ }
6947
+ if (lower.startsWith("<script") && !lower.includes("</script")) {
6948
+ return "script";
6949
+ }
6950
+ if (lower.startsWith("<style") && !lower.includes("</style")) {
6951
+ return "style";
6952
+ }
6953
+ return null;
6954
+ }
6955
+ function assertEtaHtmlSource(templateName, source) {
6956
+ let skipBlock = null;
6957
+ for (const rawLine of source.split(/\r?\n/)) {
6958
+ const trimmed = rawLine.trim();
6959
+ if (!trimmed) {
6960
+ continue;
6961
+ }
6962
+ const lower = trimmed.toLowerCase();
6963
+ const nextSkipBlock = updateSkipBlock(lower, skipBlock);
6964
+ if (skipBlock || nextSkipBlock) {
6965
+ skipBlock = nextSkipBlock;
6966
+ continue;
6967
+ }
6968
+ if (trimmed.startsWith("<") || trimmed.startsWith("<%")) {
6969
+ continue;
6970
+ }
6971
+ const kind = describePugLikeLine(trimmed);
6972
+ if (kind) {
6973
+ throw new Error(`Eta view "${templateName}" contains Pug-like markup (${kind}: ${trimmed}). Eta views must be HTML + <% %> tags, not Pug.`);
6974
+ }
6975
+ }
6976
+ }
6977
+
6978
+ // ../../src/core/view/etaViewEngine.ts
6979
+ var DEFAULT_VIEWS_DIRECTORY = join4(process.cwd(), "resources/views");
6980
+ var DEFAULT_LAYOUT = "layouts/app.eta";
6981
+
6982
+ class EtaViewEngine {
6983
+ eta;
6984
+ resolveLayoutData;
6985
+ constructor(viewsDirectory = DEFAULT_VIEWS_DIRECTORY, resolveLayoutData) {
6986
+ this.eta = new Eta({
6987
+ views: viewsDirectory,
6988
+ autoTrim: false
6989
+ });
6990
+ this.resolveLayoutData = resolveLayoutData;
6991
+ const readFile2 = this.eta.readFile?.bind(this.eta);
6992
+ this.eta.readFile = (path) => {
6993
+ const source = readFile2 ? readFile2(path) : "";
6994
+ assertEtaHtmlSource(relative(viewsDirectory, path) || path, source);
6995
+ return source;
6996
+ };
6997
+ }
6998
+ async render(name, data = {}, options = {}) {
6999
+ const template = name.endsWith(".eta") ? name : `${name}.eta`;
7000
+ const request = options.request ?? currentRequestMeta().request;
7001
+ const layoutData = this.resolveLayoutData ? await this.resolveLayoutData(request) : {};
7002
+ const mergedData = { ...layoutData, ...data };
7003
+ const body = await this.eta.renderAsync(template, mergedData);
7004
+ const layout = options.layout ?? DEFAULT_LAYOUT;
7005
+ if (layout === false) {
7006
+ return body;
7007
+ }
7008
+ const layoutTemplate = layout.endsWith(".eta") ? layout : `${layout}.eta`;
7009
+ return await this.eta.renderAsync(layoutTemplate, {
7010
+ ...mergedData,
7011
+ body
7012
+ });
7013
+ }
7014
+ }
7015
+ // ../../src/core/view/webLayoutData.ts
7016
+ var configuredLayoutOptions = {};
7017
+ function configureWebLayoutData(options) {
7018
+ configuredLayoutOptions = { ...options };
7019
+ }
7020
+ function layoutUserKey(options) {
7021
+ return options.userKey ?? "authUser";
7022
+ }
7023
+ async function defaultLoadLayoutUser(container, _request) {
7024
+ const authUser = currentAuthUser();
7025
+ if (!authUser) {
7026
+ return null;
7027
+ }
7028
+ const userId = Number(authUser.id);
7029
+ if (!Number.isInteger(userId) || userId <= 0) {
7030
+ return null;
7031
+ }
7032
+ const directory = resolveAuthUserDirectory(container);
7033
+ if (!directory) {
7034
+ return {
7035
+ id: userId,
7036
+ email: "",
7037
+ role: authUser.role ?? "member"
7038
+ };
7039
+ }
7040
+ try {
7041
+ const user = await directory.findByIdOrThrow(userId);
7042
+ return {
7043
+ id: userId,
7044
+ email: user.email ?? "",
7045
+ role: authUser.role ?? user.role ?? "member"
7046
+ };
7047
+ } catch {
7048
+ return null;
7049
+ }
7050
+ }
7051
+ async function resolveWebLayoutData(container, request, options = {}) {
7052
+ const resolved = { ...configuredLayoutOptions, ...options };
7053
+ const csrfToken = request ? resolveCsrfTokenForRequest(request) : "";
7054
+ const flash = request ? currentRequestMeta().flash ?? pullFlash(request) : null;
7055
+ const userKey = layoutUserKey(resolved);
7056
+ const loadUser = resolved.loadUser ?? defaultLoadLayoutUser;
7057
+ const user = await loadUser(container, request);
7058
+ const extra = typeof resolved.extra === "function" ? await resolved.extra(user) : resolved.extra ?? {};
7059
+ return {
7060
+ [userKey]: user,
7061
+ csrfToken,
7062
+ flash,
7063
+ cspNonce: currentRequestMeta().cspNonce ?? "",
7064
+ ...extra
7065
+ };
7066
+ }
6692
7067
  export {
6693
7068
  AdminResourceRegistry,
6694
7069
  ApiTokenGuard,
@@ -6747,6 +7122,7 @@ export {
6747
7122
  ValidationError,
6748
7123
  WebFormRequest,
6749
7124
  WhereBuilder,
7125
+ absoluteTemporarySignedUrl,
6750
7126
  appSchedule,
6751
7127
  appendOrganizationScope,
6752
7128
  appendProjectScope,
@@ -6756,6 +7132,7 @@ export {
6756
7132
  assertIfMatch,
6757
7133
  assertOrganizationReadable,
6758
7134
  assertResourceInCurrentTenant,
7135
+ assertValidSignature,
6759
7136
  auditChecksum,
6760
7137
  auth,
6761
7138
  authContext,
@@ -6773,7 +7150,9 @@ export {
6773
7150
  composeMiddleware,
6774
7151
  conditionalJsonResponse,
6775
7152
  config,
7153
+ configureContentSecurityPolicy,
6776
7154
  configureMembershipLookup,
7155
+ configureWebErrorView,
6777
7156
  configureWebLayoutData,
6778
7157
  createAuthMiddleware,
6779
7158
  createAuthorizeMiddleware,
@@ -6822,12 +7201,14 @@ export {
6822
7201
  dehydrateValue,
6823
7202
  emailRule,
6824
7203
  emptyPaginateResult,
7204
+ errorTemplateName,
6825
7205
  etagFromResource,
6826
7206
  eventBus,
6827
7207
  events,
6828
7208
  filterMassAssignable,
6829
7209
  formatAdminValue,
6830
7210
  freshDatabase,
7211
+ generateCspNonce,
6831
7212
  getActiveDatabaseConnection,
6832
7213
  getBoundDatabaseConnection,
6833
7214
  getDefaultDatabasePool,
@@ -6839,6 +7220,8 @@ export {
6839
7220
  hasMinimumOrgRole2 as hasMinimumOrgRole,
6840
7221
  hasOne,
6841
7222
  hasOrgMembership,
7223
+ hasValidSignature,
7224
+ htmlErrorResponse,
6842
7225
  htmlResponse,
6843
7226
  hydrateValue,
6844
7227
  indexBelongsToManyRelation,
@@ -6862,6 +7245,7 @@ export {
6862
7245
  loadSeedersFromDirectory,
6863
7246
  log,
6864
7247
  logSecurityEvent,
7248
+ loginRedirectLocation,
6865
7249
  mail,
6866
7250
  mailer,
6867
7251
  markdownToHtml,
@@ -6893,6 +7277,7 @@ export {
6893
7277
  registerDefaultDatabasePool,
6894
7278
  registerModelRepository,
6895
7279
  registerShutdownHandler,
7280
+ renderKernelErrorChrome,
6896
7281
  renderMarkdownMail,
6897
7282
  repositoryConnection,
6898
7283
  requestIdMiddleware,
@@ -6908,9 +7293,11 @@ export {
6908
7293
  resolveApplicationLogger,
6909
7294
  resolveApplicationPolicyGate,
6910
7295
  resolveApplicationQueue,
7296
+ resolveContentSecurityPolicy,
6911
7297
  resolveCsrfToken,
6912
7298
  resolveCsrfTokenForRequest,
6913
7299
  resolveDatabaseDriver,
7300
+ resolveHtmlContentSecurityPolicy,
6914
7301
  resolveMembershipService,
6915
7302
  resolveOrganizationScope,
6916
7303
  resolveRepositoryConnection,
@@ -6931,15 +7318,23 @@ export {
6931
7318
  runWithTenant,
6932
7319
  runWithTenantDatabase,
6933
7320
  runWithTraceContext,
7321
+ safeInternalRedirectPath,
7322
+ sanitizeInternalPath,
7323
+ sanitizeMailHtml,
6934
7324
  scopedOrganizationIds,
6935
7325
  securedBindRouteModel,
6936
7326
  securedBindRouteModelByKey,
6937
7327
  sendMarkdownMail,
6938
7328
  serializeDate,
7329
+ serverHtmxContentSecurityPolicy,
6939
7330
  setActiveApplicationContext,
7331
+ signedUrl,
7332
+ spaContentSecurityPolicy,
6940
7333
  storageFacade as storage,
7334
+ strictApiContentSecurityPolicy,
6941
7335
  stringRule,
6942
7336
  stripMarkdown,
7337
+ temporarySignedUrl,
6943
7338
  textResponse,
6944
7339
  toPaginatedResourceCollection,
6945
7340
  toResourceCollection,