@getstrata/core 2.2.41 → 2.2.42

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
@@ -11545,98 +11545,32 @@ function mail() {
11545
11545
  function storageFacade() {
11546
11546
  return storage();
11547
11547
  }
11548
- // ../../src/core/http/bodySizeLimitMiddleware.ts
11549
- var guards = new WeakMap;
11550
- function guardBody(request, maxBytes) {
11551
- const existing = guards.get(request);
11552
- if (existing && existing.maxBytes <= maxBytes)
11553
- return existing;
11554
- const state = { maxBytes, exceeded: false };
11555
- const body = request.body;
11556
- if (!body)
11557
- return state;
11558
- let consumed = 0;
11559
- const stream = body.pipeThrough(new TransformStream({
11560
- transform(chunk, controller) {
11561
- const bytes = typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk;
11562
- consumed += bytes.byteLength;
11563
- if (consumed > maxBytes) {
11564
- state.exceeded = true;
11565
- throw new PayloadTooLargeError(`Request body exceeds the ${maxBytes} byte limit.`);
11566
- }
11567
- controller.enqueue(bytes);
11568
- }
11569
- }));
11570
- const bounded = new Request(request, { body: stream });
11571
- const decorateClone = (clone) => {
11572
- for (const field of ["params", "cookies"]) {
11573
- if (field in request)
11574
- Object.defineProperty(clone, field, { value: Reflect.get(request, field) });
11575
- }
11576
- const nativeClone = () => Reflect.apply(Request.prototype.clone, clone, []);
11577
- Object.defineProperty(clone, "clone", {
11578
- value: () => decorateClone(nativeClone())
11579
- });
11580
- guards.set(clone, state);
11581
- return clone;
11582
- };
11583
- const descriptors = {
11584
- body: { configurable: true, get: () => bounded.body },
11585
- bodyUsed: { configurable: true, get: () => bounded.bodyUsed },
11586
- clone: {
11587
- configurable: true,
11588
- value: () => decorateClone(Reflect.apply(Request.prototype.clone, bounded, []))
11589
- }
11590
- };
11591
- for (const method of [
11592
- "text",
11593
- "json",
11594
- "formData",
11595
- "arrayBuffer",
11596
- "blob",
11597
- "bytes",
11598
- "textStream"
11599
- ]) {
11600
- descriptors[method] = { configurable: true, value: bounded[method].bind(bounded) };
11548
+ // ../../src/core/runtime/frontendMode.ts
11549
+ var FRONTEND_MODES = ["api", "server-htmx", "spa-react", "hybrid"];
11550
+ var FRONTEND_MODE_PATTERN = new RegExp(`^(${FRONTEND_MODES.join("|")})$`);
11551
+ function parseFrontendMode(value) {
11552
+ const mode = (value ?? "api").trim();
11553
+ if (mode === "server-htmx" || mode === "spa-react" || mode === "hybrid") {
11554
+ return mode;
11601
11555
  }
11602
- Object.defineProperties(request, descriptors);
11603
- guards.set(request, state);
11604
- return state;
11556
+ return "api";
11605
11557
  }
11606
- var DEFAULT_MAX_BODY_BYTES = 1048576;
11607
- function resolveMaxBodyBytes() {
11608
- const raw = process.env.MAX_REQUEST_BODY_BYTES?.trim();
11609
- if (!raw) {
11610
- return DEFAULT_MAX_BODY_BYTES;
11611
- }
11612
- const parsed = Number(raw);
11613
- if (!Number.isSafeInteger(parsed) || parsed <= 0) {
11614
- return DEFAULT_MAX_BODY_BYTES;
11615
- }
11616
- return parsed;
11558
+ function readFrontendMode() {
11559
+ return parseFrontendMode(process.env.FRONTEND_MODE);
11617
11560
  }
11618
- function createBodySizeLimitMiddleware(maxBytes = resolveMaxBodyBytes()) {
11619
- if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0)
11620
- throw new RangeError("Body limit must be a positive safe integer.");
11621
- return async (request, next) => {
11622
- const tooLarge = () => {
11623
- const error = new PayloadTooLargeError(`Request body exceeds the ${maxBytes} byte limit.`);
11624
- return Response.json({ error: error.message }, { status: error.status });
11625
- };
11626
- const length = Number(request.headers.get("content-length"));
11627
- if (Number.isFinite(length) && length > maxBytes)
11628
- return tooLarge();
11629
- const state = guardBody(request, maxBytes);
11630
- try {
11631
- const response = await next();
11632
- return state.exceeded ? tooLarge() : response;
11633
- } catch (error) {
11634
- if (state.exceeded || error instanceof PayloadTooLargeError)
11635
- return tooLarge();
11636
- throw error;
11637
- }
11638
- };
11561
+ function isViewsMode(mode) {
11562
+ return mode === "server-htmx" || mode === "hybrid";
11563
+ }
11564
+ function isSpaMode(mode) {
11565
+ return mode === "spa-react" || mode === "hybrid";
11566
+ }
11567
+ function isViewsEnabled() {
11568
+ return isViewsMode(readFrontendMode());
11569
+ }
11570
+ function isSpaEnabled() {
11571
+ return isSpaMode(readFrontendMode());
11639
11572
  }
11573
+
11640
11574
  // ../../src/core/http/requestMetaContext.ts
11641
11575
  init_asyncContextStore();
11642
11576
  var requestMetaContext = createAsyncContextStore("@getstrata/requestMetaContext");
@@ -11650,1428 +11584,1500 @@ function currentRequestMeta() {
11650
11584
  };
11651
11585
  }
11652
11586
 
11653
- // ../../src/core/http/clientIp.ts
11654
- function trustForwardedFor(env = process.env) {
11655
- return (env.TRUST_FORWARDED_FOR ?? "false") === "true";
11656
- }
11657
- function isPrivateAddress(address) {
11658
- const ip = address.replace(/^::ffff:/i, "");
11659
- return ip === "::1" || ip === "localhost" || /^127\./.test(ip) || /^10\./.test(ip) || /^192\.168\./.test(ip) || /^172\.(1[6-9]|2\d|3[01])\./.test(ip) || /^169\.254\./.test(ip) || /^f[cd][0-9a-f]{2}:/i.test(ip) || /^fe80:/i.test(ip);
11660
- }
11661
- function forwardedClientIp(request) {
11662
- const hops = (request.headers.get("x-forwarded-for") ?? "").split(",").map((hop) => hop.trim()).filter(Boolean);
11663
- for (let index = hops.length - 1;index >= 0; index -= 1) {
11664
- const hop = hops[index];
11665
- if (hop && !isPrivateAddress(hop)) {
11666
- return hop;
11667
- }
11587
+ // ../../src/core/http/safeInternalPath.ts
11588
+ var NESTED_REDIRECT_KEYS = new Set(["redirect", "next", "return", "returnTo", "return_to"]);
11589
+ function looksLikeExternalTarget(value) {
11590
+ const trimmed = value.trim();
11591
+ if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
11592
+ return true;
11668
11593
  }
11669
- if (hops.length > 0) {
11670
- return hops[hops.length - 1];
11594
+ if (trimmed.includes("://") || trimmed.includes(":/") || trimmed.includes(":\\")) {
11595
+ return true;
11671
11596
  }
11672
- return request.headers.get("x-real-ip")?.trim() || undefined;
11673
- }
11674
- function readClientIp(request, env = process.env) {
11675
- if (trustForwardedFor(env)) {
11676
- const forwarded = forwardedClientIp(request);
11677
- if (forwarded) {
11678
- return forwarded;
11597
+ try {
11598
+ const decoded = decodeURIComponent(trimmed);
11599
+ if (decoded.startsWith("//") || decoded.includes("\\") || /https?:/i.test(decoded)) {
11600
+ return true;
11679
11601
  }
11602
+ } catch {
11603
+ return true;
11680
11604
  }
11681
- return currentRequestMeta().ipAddress ?? undefined;
11605
+ return false;
11682
11606
  }
11683
- // ../../src/core/crypto/nonCryptographicHash.ts
11684
- function nonCryptographicDigest(input) {
11685
- return Bun.hash(input).toString(16);
11607
+ function sanitizeNestedQuery(search) {
11608
+ if (!search) {
11609
+ return "";
11610
+ }
11611
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
11612
+ const next = new URLSearchParams;
11613
+ for (const [key, value] of params.entries()) {
11614
+ if (NESTED_REDIRECT_KEYS.has(key)) {
11615
+ const sanitized = sanitizeInternalPath(value, "/");
11616
+ next.set(key, sanitized);
11617
+ continue;
11618
+ }
11619
+ if (looksLikeExternalTarget(value) && value.includes("://")) {
11620
+ continue;
11621
+ }
11622
+ next.set(key, value);
11623
+ }
11624
+ const serialized = next.toString();
11625
+ return serialized ? `?${serialized}` : "";
11686
11626
  }
11687
-
11688
- // ../../src/core/http/etag.ts
11689
- function isEtagEnabled() {
11690
- return (process.env.FEATURE_ETAG ?? "true") !== "false";
11627
+ function sanitizeInternalPath(raw, fallback = "/") {
11628
+ const trimmed = raw.trim();
11629
+ if (looksLikeExternalTarget(trimmed)) {
11630
+ return fallback;
11631
+ }
11632
+ const queryIndex = trimmed.indexOf("?");
11633
+ const hashIndex = trimmed.indexOf("#");
11634
+ const end = [queryIndex, hashIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0];
11635
+ const pathname = end === undefined ? trimmed : trimmed.slice(0, end);
11636
+ if (looksLikeExternalTarget(pathname)) {
11637
+ return fallback;
11638
+ }
11639
+ const search = queryIndex >= 0 ? trimmed.slice(queryIndex, hashIndex >= 0 ? hashIndex : undefined) : "";
11640
+ return `${pathname}${sanitizeNestedQuery(search)}`;
11691
11641
  }
11692
- function formatWeakEtag(digest) {
11693
- return `W/"${digest}"`;
11642
+ function safeInternalRedirectPath(request, fallback = "/") {
11643
+ const url = new URL(request.url);
11644
+ return sanitizeInternalPath(`${url.pathname}${url.search}`, fallback);
11694
11645
  }
11695
- function computeEtagFromJson(data) {
11696
- const digest = nonCryptographicDigest(JSON.stringify(data)).slice(0, 32);
11697
- return formatWeakEtag(digest);
11646
+ function loginRedirectLocation(request) {
11647
+ return `/login?redirect=${encodeURIComponent(safeInternalRedirectPath(request))}`;
11698
11648
  }
11699
- function etagFromResource(resource) {
11700
- const version = resource.updated_at ?? resource.created_at ?? "";
11701
- const versionText = version instanceof Date ? version.toISOString() : version ? String(version) : "0";
11702
- const digest = nonCryptographicDigest(`${String(resource.id ?? "0")}:${versionText}`).slice(0, 32);
11703
- return formatWeakEtag(digest);
11649
+
11650
+ // ../../src/core/view/htmlResponse.ts
11651
+ function withCharset(contentType) {
11652
+ return contentType.includes("charset=") ? contentType : `${contentType}; charset=utf-8`;
11704
11653
  }
11705
- function normalizeEtag(value) {
11706
- return value.trim();
11654
+ function htmlResponse(html, init = {}) {
11655
+ return new Response(html, {
11656
+ status: init.status ?? 200,
11657
+ statusText: init.statusText,
11658
+ headers: {
11659
+ "Content-Type": "text/html; charset=utf-8"
11660
+ }
11661
+ });
11707
11662
  }
11708
- function etagValuesMatch(left, right) {
11709
- return normalizeEtag(left) === normalizeEtag(right);
11663
+ function isHtmxRequest(request) {
11664
+ return request.headers.get("HX-Request") === "true";
11710
11665
  }
11711
- function parseEtagList(header) {
11712
- if (!header) {
11713
- return [];
11666
+ function redirectResponse(location, status = 302) {
11667
+ if (!location.startsWith("/") || location.startsWith("//")) {
11668
+ throw new Error("Redirect location must be an internal path.");
11714
11669
  }
11715
- return header.split(",").map((value) => normalizeEtag(value)).filter(Boolean);
11670
+ return new Response(null, {
11671
+ status,
11672
+ headers: {
11673
+ Location: sanitizeInternalPath(location, "/")
11674
+ }
11675
+ });
11716
11676
  }
11717
- function ifNoneMatchSatisfied(request, etag) {
11718
- const header = request.headers.get("if-none-match");
11719
- if (!header) {
11720
- return false;
11721
- }
11722
- if (header.trim() === "*") {
11723
- return true;
11724
- }
11725
- return parseEtagList(header).some((candidate) => etagValuesMatch(candidate, etag));
11677
+ function textResponse(body, init = {}) {
11678
+ return new Response(body, {
11679
+ status: init.status ?? 200,
11680
+ headers: {
11681
+ "Content-Type": "text/plain; charset=utf-8"
11682
+ }
11683
+ });
11726
11684
  }
11727
- function ifMatchSatisfied(request, etag) {
11728
- const header = request.headers.get("if-match");
11729
- if (!header) {
11730
- return false;
11731
- }
11732
- if (header.trim() === "*") {
11685
+ function xmlResponse(body, init = {}) {
11686
+ return new Response(body, {
11687
+ status: init.status ?? 200,
11688
+ headers: {
11689
+ "Content-Type": withCharset(init.contentType ?? "application/xml")
11690
+ }
11691
+ });
11692
+ }
11693
+ function rssResponse(body, init = {}) {
11694
+ return xmlResponse(body, { ...init, contentType: "application/rss+xml" });
11695
+ }
11696
+
11697
+ // ../../src/core/view/webErrorView.ts
11698
+ var configuredErrorView = {};
11699
+ function configureWebErrorView(options) {
11700
+ configuredErrorView = { ...options };
11701
+ }
11702
+ function escapeHtml(value) {
11703
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
11704
+ }
11705
+ function errorReturnPath(request) {
11706
+ if (!request)
11707
+ return "/";
11708
+ try {
11709
+ const current = new URL(request.url);
11710
+ const previous = new URL(request.headers.get("referer") ?? "");
11711
+ if (previous.origin !== current.origin || (request.method === "GET" || request.method === "HEAD") && previous.pathname === current.pathname)
11712
+ return "/";
11713
+ return sanitizeInternalPath(previous.pathname, "/");
11714
+ } catch {
11715
+ return "/";
11716
+ }
11717
+ }
11718
+ function renderKernelErrorChrome(input) {
11719
+ const title = escapeHtml(input.title);
11720
+ const message = escapeHtml(input.message);
11721
+ const errorLines = Object.entries(input.errors ?? {}).flatMap(([field, messages]) => messages.map((item) => `${field}: ${item}`)).map((line) => `<li>${escapeHtml(line)}</li>`).join("");
11722
+ const details = errorLines ? `<ul class="error-list">${errorLines}</ul>` : "";
11723
+ const goBack = input.status === 422 ? `<p><a href="${escapeHtml(errorReturnPath(input.request ?? currentRequestMeta().request))}">Go back</a></p>` : "";
11724
+ return `<!doctype html>
11725
+ <html lang="en">
11726
+ <head>
11727
+ <meta charset="UTF-8" />
11728
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
11729
+ <title>${title}</title>
11730
+ <link rel="stylesheet" href="/assets/app.css" />
11731
+ </head>
11732
+ <body>
11733
+ <header class="site-header">
11734
+ <a class="brand" href="/">Home</a>
11735
+ </header>
11736
+ <main class="site-main">
11737
+ <section class="page-header">
11738
+ <h1>${title}</h1>
11739
+ <p>${message}</p>
11740
+ ${details}
11741
+ ${goBack}
11742
+ </section>
11743
+ </main>
11744
+ </body>
11745
+ </html>
11746
+ `;
11747
+ }
11748
+ function errorTemplateName(status) {
11749
+ if (status === 404) {
11750
+ return "errors/not-found";
11751
+ }
11752
+ if (status === 403) {
11753
+ return "errors/forbidden";
11754
+ }
11755
+ return "errors/error";
11756
+ }
11757
+ async function renderWebErrorHtml(input) {
11758
+ const render = configuredErrorView.render;
11759
+ if (!render) {
11760
+ return renderKernelErrorChrome(input);
11761
+ }
11762
+ try {
11763
+ return await render({
11764
+ ...input,
11765
+ request: input.request ?? currentRequestMeta().request
11766
+ });
11767
+ } catch {
11768
+ return renderKernelErrorChrome(input);
11769
+ }
11770
+ }
11771
+ async function htmlErrorResponse(input) {
11772
+ const response = htmlResponse(await renderWebErrorHtml(input), { status: input.status });
11773
+ response.headers.set("Cache-Control", "no-store");
11774
+ return response;
11775
+ }
11776
+ async function notFoundHtmlResponse(body) {
11777
+ if (body !== undefined) {
11778
+ const response = htmlResponse(body, { status: 404 });
11779
+ response.headers.set("Cache-Control", "no-store");
11780
+ return response;
11781
+ }
11782
+ return htmlErrorResponse({
11783
+ status: 404,
11784
+ title: "Not Found",
11785
+ message: "The page you requested was not found."
11786
+ });
11787
+ }
11788
+
11789
+ // ../../src/core/http/contentNegotiation.ts
11790
+ function requestPrefersJson(request) {
11791
+ if (!request) {
11733
11792
  return true;
11734
11793
  }
11735
- return parseEtagList(header).some((candidate) => etagValuesMatch(candidate, etag));
11794
+ if (request.headers.get("HX-Request") === "true") {
11795
+ return false;
11796
+ }
11797
+ const pathname = new URL(request.url).pathname;
11798
+ if (pathname.startsWith("/api/")) {
11799
+ return true;
11800
+ }
11801
+ const accept = request.headers.get("accept")?.toLowerCase() ?? "";
11802
+ if (accept.includes("text/html")) {
11803
+ return false;
11804
+ }
11805
+ if (accept.includes("application/json")) {
11806
+ return true;
11807
+ }
11808
+ const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
11809
+ if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
11810
+ return false;
11811
+ }
11812
+ return false;
11736
11813
  }
11737
- function assertIfMatch(request, etag, options = {}) {
11738
- const header = request.headers.get("if-match");
11739
- if (!header) {
11740
- if (options.required) {
11741
- throw new PreconditionFailedError("If-Match header is required.");
11814
+
11815
+ // ../../src/core/http/webErrorResponse.ts
11816
+ function normalizeFieldErrors(details) {
11817
+ if (!details || typeof details !== "object" || Array.isArray(details)) {
11818
+ return {};
11819
+ }
11820
+ const errors = {};
11821
+ for (const [field, messages] of Object.entries(details)) {
11822
+ if (Array.isArray(messages)) {
11823
+ errors[field] = messages.map(String);
11824
+ continue;
11825
+ }
11826
+ if (typeof messages === "string") {
11827
+ errors[field] = [messages];
11742
11828
  }
11743
- return;
11744
11829
  }
11745
- if (!ifMatchSatisfied(request, etag)) {
11746
- throw new PreconditionFailedError("Resource ETag does not match If-Match.");
11830
+ return errors;
11831
+ }
11832
+ function errorPageTitle(status, message) {
11833
+ if (status === 404) {
11834
+ return "Not Found";
11835
+ }
11836
+ if (status === 403) {
11837
+ return "Forbidden";
11838
+ }
11839
+ if (status >= 500) {
11840
+ return "Server Error";
11747
11841
  }
11842
+ return message;
11748
11843
  }
11749
- function applyEtagHeaders(headers, etag) {
11750
- const next = new Headers(headers);
11751
- next.set("ETag", etag);
11752
- next.set("Cache-Control", "private, must-revalidate");
11753
- next.append("Vary", "Authorization");
11754
- next.append("Vary", "X-Tenant-Id");
11755
- return next;
11844
+ function publicErrorMessage(status, message) {
11845
+ if (status >= 500 && isProductionEnv()) {
11846
+ return "Something went wrong.";
11847
+ }
11848
+ return message;
11756
11849
  }
11757
- function notModifiedResponse(etag) {
11758
- return new Response(null, {
11759
- status: 304,
11760
- headers: applyEtagHeaders(new Headers, etag)
11850
+ function logServerError(error, mappedError) {
11851
+ if (mappedError.status < 500) {
11852
+ return;
11853
+ }
11854
+ appLogger.error("Unhandled request error", {
11855
+ status: mappedError.status,
11856
+ error: error instanceof Error ? error.message : String(error),
11857
+ stack: error instanceof Error ? error.stack : undefined
11761
11858
  });
11762
11859
  }
11763
- function applyConditionalGet(request, response, etag) {
11764
- if (!isEtagEnabled()) {
11765
- return response;
11860
+ async function webErrorResponse(error, request) {
11861
+ if (currentRequestMeta().errorFormat === "json" || !request || !isViewsEnabled() || requestPrefersJson(request)) {
11862
+ return null;
11766
11863
  }
11767
- if (ifNoneMatchSatisfied(request, etag)) {
11768
- return notModifiedResponse(etag);
11864
+ const mappedError = toHttpError(error) ?? mapDatabaseError(error);
11865
+ logServerError(error, mappedError);
11866
+ if (mappedError.status === 401) {
11867
+ return Response.redirect(loginRedirectLocation(request), 302);
11769
11868
  }
11770
- const headers = applyEtagHeaders(new Headers(response.headers), etag);
11771
- return new Response(response.body, {
11772
- status: response.status,
11773
- statusText: response.statusText,
11774
- headers
11869
+ const errors = mappedError instanceof ValidationError || mappedError.name === "ValidationError" ? normalizeFieldErrors(mappedError.details) : undefined;
11870
+ return htmlErrorResponse({
11871
+ status: mappedError.status,
11872
+ title: errorPageTitle(mappedError.status, mappedError.message),
11873
+ message: publicErrorMessage(mappedError.status, mappedError.message),
11874
+ errors,
11875
+ request
11775
11876
  });
11776
11877
  }
11777
11878
 
11778
- // ../../src/core/http/conditionalResponse.ts
11879
+ // ../../src/core/http/response.ts
11880
+ init_transactionControl();
11779
11881
  function jsonResponse(data, init = {}) {
11780
11882
  return Response.json(data, {
11781
11883
  status: init.status ?? 200,
11782
11884
  headers: init.headers
11783
11885
  });
11784
11886
  }
11785
- function conditionalJsonResponse(request, data, init = {}) {
11786
- if (!request || !isEtagEnabled()) {
11787
- return jsonResponse(data, init);
11788
- }
11789
- const etag = computeEtagFromJson(data);
11790
- if (ifNoneMatchSatisfied(request, etag)) {
11791
- return notModifiedResponse(etag);
11792
- }
11887
+ function privateJsonResponse(data, init = {}) {
11793
11888
  const response = jsonResponse(data, init);
11794
- const headers = new Headers(response.headers);
11795
- headers.set("ETag", etag);
11796
- headers.set("Cache-Control", "private, must-revalidate");
11797
- headers.append("Vary", "Authorization");
11798
- headers.append("Vary", "X-Tenant-Id");
11799
- return new Response(response.body, {
11800
- status: response.status,
11801
- statusText: response.statusText,
11802
- headers
11803
- });
11889
+ response.headers.set("Cache-Control", "private, no-store");
11890
+ return response;
11804
11891
  }
11805
- // ../../src/core/runtime/frontendMode.ts
11806
- var FRONTEND_MODES = ["api", "server-htmx", "spa-react", "hybrid"];
11807
- var FRONTEND_MODE_PATTERN = new RegExp(`^(${FRONTEND_MODES.join("|")})$`);
11808
- function parseFrontendMode(value) {
11809
- const mode = (value ?? "api").trim();
11810
- if (mode === "server-htmx" || mode === "spa-react" || mode === "hybrid") {
11811
- return mode;
11812
- }
11813
- return "api";
11892
+ function createdResponse(data, init = {}) {
11893
+ return jsonResponse(data, { ...init, status: init.status ?? 201 });
11814
11894
  }
11815
- function readFrontendMode() {
11816
- return parseFrontendMode(process.env.FRONTEND_MODE);
11895
+ function noContentResponse() {
11896
+ return new Response(null, { status: 204 });
11817
11897
  }
11818
- function isViewsMode(mode) {
11819
- return mode === "server-htmx" || mode === "hybrid";
11898
+ function errorResponse(error) {
11899
+ const mappedError = toHttpError(error) ?? mapDatabaseError(error);
11900
+ logServerError(error, mappedError);
11901
+ return Response.json({
11902
+ error: mappedError.message,
11903
+ ...mappedError.details === undefined ? {} : { details: mappedError.details }
11904
+ }, { status: mappedError.status, headers: { "Cache-Control": "no-store" } });
11820
11905
  }
11821
- function isSpaMode(mode) {
11822
- return mode === "spa-react" || mode === "hybrid";
11823
- }
11824
- function isViewsEnabled() {
11825
- return isViewsMode(readFrontendMode());
11826
- }
11827
- function isSpaEnabled() {
11828
- return isSpaMode(readFrontendMode());
11906
+ function withErrorHandling(handler) {
11907
+ return async (...args) => {
11908
+ try {
11909
+ return await handler(...args);
11910
+ } catch (error) {
11911
+ requestTransactionRollback();
11912
+ const request = args.find((arg) => arg instanceof Request);
11913
+ const webResponse = await webErrorResponse(error, request);
11914
+ if (webResponse) {
11915
+ return webResponse;
11916
+ }
11917
+ return errorResponse(error);
11918
+ }
11919
+ };
11829
11920
  }
11830
11921
 
11831
- // ../../src/core/http/contentSecurityPolicy.ts
11832
- var HTMX_2_0_4_INDICATOR_STYLE_HASH = "'sha256-bsV5JivYxvGywDAZ22EZJKBFip65Ng9xoJVLbBg7bdo='";
11833
- var API_DIRECTIVES = {
11834
- "default-src": ["'none'"],
11835
- "frame-ancestors": ["'none'"],
11836
- "base-uri": ["'none'"]
11837
- };
11838
- var HTMX_HTML_DIRECTIVES = {
11839
- "default-src": ["'self'"],
11840
- "script-src": ["'self'"],
11841
- "style-src": ["'self'", HTMX_2_0_4_INDICATOR_STYLE_HASH],
11842
- "connect-src": ["'self'"],
11843
- "img-src": ["'self'", "data:", "https:"],
11844
- "font-src": ["'self'"],
11845
- "media-src": ["'self'", "https:"],
11846
- "frame-src": [
11847
- "https://www.youtube.com",
11848
- "https://www.youtube-nocookie.com",
11849
- "https://player.vimeo.com"
11850
- ],
11851
- "form-action": ["'self'"],
11852
- "frame-ancestors": ["'none'"],
11853
- "base-uri": ["'self'"]
11854
- };
11855
- var SPA_HTML_DIRECTIVES = {
11856
- "default-src": ["'self'"],
11857
- "script-src": ["'self'"],
11858
- "style-src": ["'self'"],
11859
- "connect-src": ["'self'"],
11860
- "img-src": ["'self'", "data:", "https:"],
11861
- "font-src": ["'self'"],
11862
- "frame-ancestors": ["'none'"],
11863
- "base-uri": ["'self'"]
11864
- };
11865
- var configuredHtmlOptions = {};
11866
- function generateCspNonce() {
11867
- const bytes = new Uint8Array(16);
11868
- crypto.getRandomValues(bytes);
11869
- return Buffer.from(bytes).toString("base64");
11870
- }
11871
- function configureContentSecurityPolicy(options) {
11872
- configuredHtmlOptions = { ...options };
11873
- }
11874
- function resetContentSecurityPolicyForTests() {
11875
- configuredHtmlOptions = {};
11876
- }
11877
- function cloneDirectives(source) {
11878
- const copy = {};
11879
- for (const [directive, values] of Object.entries(source)) {
11880
- copy[directive] = [...values];
11881
- }
11882
- return copy;
11883
- }
11884
- function normalizeSources(value) {
11885
- if (Array.isArray(value)) {
11886
- return value.flatMap((item) => item.split(/\s+/).filter(Boolean));
11887
- }
11888
- return value.split(/\s+/).filter(Boolean);
11889
- }
11890
- function mergeDirectives(base, extra) {
11891
- const merged = cloneDirectives(base);
11892
- for (const [directive, value] of Object.entries(extra ?? {})) {
11893
- if (value === false) {
11894
- delete merged[directive];
11895
- continue;
11922
+ // ../../src/core/http/bodySizeLimitMiddleware.ts
11923
+ var guards = new WeakMap;
11924
+ function guardBody(request, maxBytes) {
11925
+ const existing = guards.get(request);
11926
+ if (existing && existing.maxBytes <= maxBytes)
11927
+ return existing;
11928
+ const state = { maxBytes, exceeded: false };
11929
+ const body = request.body;
11930
+ if (!body)
11931
+ return state;
11932
+ let consumed = 0;
11933
+ const stream = body.pipeThrough(new TransformStream({
11934
+ transform(chunk, controller) {
11935
+ const bytes = typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk;
11936
+ consumed += bytes.byteLength;
11937
+ if (consumed > maxBytes) {
11938
+ state.exceeded = true;
11939
+ throw new PayloadTooLargeError(`Request body exceeds the ${maxBytes} byte limit.`);
11940
+ }
11941
+ controller.enqueue(bytes);
11896
11942
  }
11897
- const sources = normalizeSources(value);
11898
- const existing = merged[directive] ?? [];
11899
- merged[directive] = [...existing, ...sources.filter((source) => !existing.includes(source))];
11900
- }
11901
- return merged;
11902
- }
11903
- function serializeDirectives(directives) {
11904
- return Object.entries(directives).map(([directive, values]) => values.length > 0 ? `${directive} ${values.join(" ")}` : directive).join("; ");
11905
- }
11906
- function applyNonce(directives, nonce) {
11907
- if (!nonce) {
11908
- return directives;
11909
- }
11910
- const token = `'nonce-${nonce}'`;
11911
- const next = cloneDirectives(directives);
11912
- for (const directive of ["script-src", "style-src"]) {
11913
- const values = next[directive] ?? ["'self'"];
11914
- if (!values.includes(token)) {
11915
- next[directive] = [...values, token];
11943
+ }));
11944
+ const bounded = new Request(request, { body: stream });
11945
+ const decorateClone = (clone) => {
11946
+ for (const field of ["params", "cookies"]) {
11947
+ if (field in request)
11948
+ Object.defineProperty(clone, field, { value: Reflect.get(request, field) });
11916
11949
  }
11950
+ const nativeClone = () => Reflect.apply(Request.prototype.clone, clone, []);
11951
+ Object.defineProperty(clone, "clone", {
11952
+ value: () => decorateClone(nativeClone())
11953
+ });
11954
+ guards.set(clone, state);
11955
+ return clone;
11956
+ };
11957
+ const descriptors = {
11958
+ body: { configurable: true, get: () => bounded.body },
11959
+ bodyUsed: { configurable: true, get: () => bounded.bodyUsed },
11960
+ clone: {
11961
+ configurable: true,
11962
+ value: () => decorateClone(Reflect.apply(Request.prototype.clone, bounded, []))
11963
+ }
11964
+ };
11965
+ for (const method of [
11966
+ "text",
11967
+ "json",
11968
+ "formData",
11969
+ "arrayBuffer",
11970
+ "blob",
11971
+ "bytes",
11972
+ "textStream"
11973
+ ]) {
11974
+ descriptors[method] = { configurable: true, value: bounded[method].bind(bounded) };
11917
11975
  }
11918
- return next;
11976
+ Object.defineProperties(request, descriptors);
11977
+ guards.set(request, state);
11978
+ return state;
11919
11979
  }
11920
- function htmlBaselineDirectives() {
11921
- if (isViewsEnabled()) {
11922
- return cloneDirectives(HTMX_HTML_DIRECTIVES);
11980
+ var DEFAULT_MAX_BODY_BYTES = 1048576;
11981
+ function resolveMaxBodyBytes() {
11982
+ const raw = process.env.MAX_REQUEST_BODY_BYTES?.trim();
11983
+ if (!raw) {
11984
+ return DEFAULT_MAX_BODY_BYTES;
11923
11985
  }
11924
- if (isSpaEnabled()) {
11925
- return cloneDirectives(SPA_HTML_DIRECTIVES);
11986
+ const parsed = Number(raw);
11987
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
11988
+ return DEFAULT_MAX_BODY_BYTES;
11926
11989
  }
11927
- return cloneDirectives(API_DIRECTIVES);
11990
+ return parsed;
11928
11991
  }
11929
- function strictApiContentSecurityPolicy() {
11930
- return serializeDirectives(API_DIRECTIVES);
11992
+ function createBodySizeLimitMiddleware(maxBytes = resolveMaxBodyBytes()) {
11993
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0)
11994
+ throw new RangeError("Body limit must be a positive safe integer.");
11995
+ return async (request, next) => {
11996
+ const tooLarge = () => {
11997
+ const error = new PayloadTooLargeError(`Request body exceeds the ${maxBytes} byte limit.`);
11998
+ return errorResponse(error);
11999
+ };
12000
+ const length = Number(request.headers.get("content-length"));
12001
+ if (Number.isFinite(length) && length > maxBytes)
12002
+ return tooLarge();
12003
+ const state = guardBody(request, maxBytes);
12004
+ try {
12005
+ const response = await next();
12006
+ return state.exceeded ? tooLarge() : response;
12007
+ } catch (error) {
12008
+ if (state.exceeded || error instanceof PayloadTooLargeError)
12009
+ return tooLarge();
12010
+ throw error;
12011
+ }
12012
+ };
11931
12013
  }
11932
- function serverHtmxContentSecurityPolicy(nonce) {
11933
- return serializeDirectives(applyNonce(cloneDirectives(HTMX_HTML_DIRECTIVES), nonce));
12014
+ // ../../src/core/http/clientIp.ts
12015
+ function trustForwardedFor(env = process.env) {
12016
+ return (env.TRUST_FORWARDED_FOR ?? "false") === "true";
11934
12017
  }
11935
- function spaContentSecurityPolicy(nonce) {
11936
- return serializeDirectives(applyNonce(cloneDirectives(SPA_HTML_DIRECTIVES), nonce));
12018
+ function isPrivateAddress(address) {
12019
+ const ip = address.replace(/^::ffff:/i, "");
12020
+ return ip === "::1" || ip === "localhost" || /^127\./.test(ip) || /^10\./.test(ip) || /^192\.168\./.test(ip) || /^172\.(1[6-9]|2\d|3[01])\./.test(ip) || /^169\.254\./.test(ip) || /^f[cd][0-9a-f]{2}:/i.test(ip) || /^fe80:/i.test(ip);
11937
12021
  }
11938
- function resolveHtmlContentSecurityPolicy(options = {}) {
11939
- const resolved = {
11940
- ...configuredHtmlOptions,
11941
- ...options,
11942
- directives: {
11943
- ...configuredHtmlOptions.directives,
11944
- ...options.directives
12022
+ function forwardedClientIp(request) {
12023
+ const hops = (request.headers.get("x-forwarded-for") ?? "").split(",").map((hop) => hop.trim()).filter(Boolean);
12024
+ for (let index = hops.length - 1;index >= 0; index -= 1) {
12025
+ const hop = hops[index];
12026
+ if (hop && !isPrivateAddress(hop)) {
12027
+ return hop;
11945
12028
  }
11946
- };
11947
- const override = resolved.htmlCsp ?? resolved.csp;
11948
- if (override) {
11949
- return override;
11950
12029
  }
11951
- return serializeDirectives(applyNonce(mergeDirectives(htmlBaselineDirectives(), resolved.directives), options.nonce));
11952
- }
11953
- function resolveContentSecurityPolicy(response, options = {}) {
11954
- const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
11955
- if (!contentType.includes("text/html")) {
11956
- return strictApiContentSecurityPolicy();
12030
+ if (hops.length > 0) {
12031
+ return hops[hops.length - 1];
11957
12032
  }
11958
- return resolveHtmlContentSecurityPolicy(options);
12033
+ return request.headers.get("x-real-ip")?.trim() || undefined;
11959
12034
  }
11960
- // ../../src/core/http/corsMiddleware.ts
11961
- function additionalHeaders(options) {
11962
- const configured = process.env.CORS_ADDITIONAL_ALLOWED_HEADERS?.trim();
11963
- const supplied = options.additionalAllowedHeaders === undefined ? [] : options.additionalAllowedHeaders;
11964
- if (!Array.isArray(supplied)) {
11965
- throw new TypeError("CORS additionalAllowedHeaders must be an array of header names.");
11966
- }
11967
- const names = [
11968
- ...configured ? configured.split(",").map((name) => name.trim()) : [],
11969
- ...supplied
11970
- ];
11971
- return names.map((name) => {
11972
- if (typeof name !== "string" || name === "*" || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)) {
11973
- throw new TypeError("CORS additional headers must be explicit valid HTTP header names; wildcards are not allowed.");
12035
+ function readClientIp(request, env = process.env) {
12036
+ if (trustForwardedFor(env)) {
12037
+ const forwarded = forwardedClientIp(request);
12038
+ if (forwarded) {
12039
+ return forwarded;
11974
12040
  }
11975
- return name;
11976
- });
12041
+ }
12042
+ return currentRequestMeta().ipAddress ?? undefined;
11977
12043
  }
11978
- function defaultAllowedOrigins() {
11979
- return appUrl();
12044
+ // ../../src/core/crypto/nonCryptographicHash.ts
12045
+ function nonCryptographicDigest(input) {
12046
+ return Bun.hash(input).toString(16);
11980
12047
  }
11981
- function resolveCorsConfig(options = {}) {
11982
- const extraHeaders = additionalHeaders(options);
11983
- return {
11984
- allowedOrigins: (process.env.CORS_ALLOWED_ORIGINS ?? defaultAllowedOrigins()).split(",").map((origin) => origin.trim()).filter(Boolean),
11985
- allowedMethods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
11986
- allowedHeaders: [
11987
- "Authorization",
11988
- "Content-Type",
11989
- "X-Request-Id",
11990
- "X-Tenant-Id",
11991
- "X-Authenticated-User-Id",
11992
- "X-Authenticated-User-Role",
11993
- "If-Match",
11994
- "If-None-Match",
11995
- "X-CSRF-Token",
11996
- ...extraHeaders
11997
- ].filter((name, index, names) => names.findIndex((other) => other.toLowerCase() === name.toLowerCase()) === index),
11998
- maxAgeSeconds: 86400
11999
- };
12048
+
12049
+ // ../../src/core/http/etag.ts
12050
+ function isEtagEnabled() {
12051
+ return (process.env.FEATURE_ETAG ?? "true") !== "false";
12000
12052
  }
12001
- function createCorsMiddleware(options = {}) {
12002
- resolveCorsConfig(options);
12003
- const captured = {
12004
- additionalAllowedHeaders: [...options.additionalAllowedHeaders ?? []]
12005
- };
12006
- return async (request, next) => {
12007
- if (request.method === "OPTIONS") {
12008
- return new Response(null, {
12009
- status: 204,
12010
- headers: buildCorsHeaders(request, captured)
12011
- });
12012
- }
12013
- const response = await next();
12014
- const headers = new Headers(response.headers);
12015
- for (const [key, value] of buildCorsHeaders(request, captured)) {
12016
- if (key === "vary") {
12017
- const fields = (headers.get("Vary") ?? "").split(",").map((field) => field.trim()).filter(Boolean);
12018
- if (fields.includes("*"))
12019
- continue;
12020
- if (!fields.some((field) => field.toLowerCase() === "origin"))
12021
- fields.push(value);
12022
- headers.set("Vary", fields.join(", "));
12023
- } else {
12024
- headers.set(key, value);
12025
- }
12026
- }
12027
- return new Response(response.body, {
12028
- status: response.status,
12029
- statusText: response.statusText,
12030
- headers
12031
- });
12032
- };
12053
+ function formatWeakEtag(digest) {
12054
+ return `W/"${digest}"`;
12033
12055
  }
12034
- function buildCorsHeaders(request, options) {
12035
- const headers = new Headers;
12036
- const origin = request.headers.get("origin");
12037
- const corsConfig = resolveCorsConfig(options);
12038
- const allowedOrigins = corsConfig.allowedOrigins;
12039
- headers.set("Vary", "Origin");
12040
- const allowOrigin = allowedOrigins.includes("*") ? "*" : origin && allowedOrigins.includes(origin) ? origin : null;
12041
- if (!allowOrigin) {
12042
- return headers;
12043
- }
12044
- headers.set("Access-Control-Allow-Origin", allowOrigin);
12045
- headers.set("Access-Control-Allow-Methods", corsConfig.allowedMethods.join(", "));
12046
- headers.set("Access-Control-Allow-Headers", corsConfig.allowedHeaders.join(", "));
12047
- headers.set("Access-Control-Max-Age", String(corsConfig.maxAgeSeconds));
12048
- if (allowOrigin !== "*") {
12049
- headers.set("Access-Control-Allow-Credentials", "true");
12050
- }
12051
- return headers;
12056
+ function computeEtagFromJson(data) {
12057
+ const digest = nonCryptographicDigest(JSON.stringify(data)).slice(0, 32);
12058
+ return formatWeakEtag(digest);
12052
12059
  }
12053
- // ../../src/core/http/csrfToken.ts
12054
- import { timingSafeEqual as timingSafeEqual3 } from "crypto";
12055
- var CSRF_COOKIE = appCookieName("csrf");
12056
- var CSRF_TTL_MS = 60 * 60 * 1000;
12057
- function csrfCookieName() {
12058
- return process.env.CSRF_COOKIE_NAME?.trim() || appCookieName("csrf");
12060
+ function etagFromResource(resource) {
12061
+ const version = resource.updated_at ?? resource.created_at ?? "";
12062
+ const versionText = version instanceof Date ? version.toISOString() : version ? String(version) : "0";
12063
+ const digest = nonCryptographicDigest(`${String(resource.id ?? "0")}:${versionText}`).slice(0, 32);
12064
+ return formatWeakEtag(digest);
12059
12065
  }
12060
- function resolveCsrfSecret() {
12061
- return requireConfiguredSecret(["SESSION_SECRET", "CSRF_SECRET"], "csrf-secret");
12066
+ function normalizeEtag(value) {
12067
+ return value.trim();
12062
12068
  }
12063
- function csrfVerifyOptions() {
12064
- return { secret: resolveCsrfSecret(), maxAge: CSRF_TTL_MS };
12069
+ function etagValuesMatch(left, right) {
12070
+ return normalizeEtag(left) === normalizeEtag(right);
12065
12071
  }
12066
- function tokensMatch(left, right) {
12067
- const leftBuffer = Buffer.from(left);
12068
- const rightBuffer = Buffer.from(right);
12069
- if (leftBuffer.length !== rightBuffer.length) {
12070
- return false;
12072
+ function parseEtagList(header) {
12073
+ if (!header) {
12074
+ return [];
12071
12075
  }
12072
- return timingSafeEqual3(leftBuffer, rightBuffer);
12073
- }
12074
- function createCsrfTokenCookie() {
12075
- const token = Bun.CSRF.generate(resolveCsrfSecret(), { expiresIn: CSRF_TTL_MS });
12076
- const secure = isProductionEnv() ? "; Secure" : "";
12077
- return {
12078
- token,
12079
- cookie: `${csrfCookieName()}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(CSRF_TTL_MS / 1000)}${secure}`
12080
- };
12076
+ return header.split(",").map((value) => normalizeEtag(value)).filter(Boolean);
12081
12077
  }
12082
- function resolveCsrfToken(request) {
12083
- const cookieValue = readRequestCookie(request, csrfCookieName());
12084
- if (cookieValue && Bun.CSRF.verify(cookieValue, csrfVerifyOptions())) {
12085
- return { token: cookieValue };
12078
+ function ifNoneMatchSatisfied(request, etag) {
12079
+ const header = request.headers.get("if-none-match");
12080
+ if (!header) {
12081
+ return false;
12086
12082
  }
12087
- return createCsrfTokenCookie();
12088
- }
12089
- function readSubmittedCsrfToken(request) {
12090
- const headerToken = request.headers.get("x-csrf-token")?.trim();
12091
- if (headerToken) {
12092
- return headerToken;
12083
+ if (header.trim() === "*") {
12084
+ return true;
12093
12085
  }
12094
- return null;
12086
+ return parseEtagList(header).some((candidate) => etagValuesMatch(candidate, etag));
12095
12087
  }
12096
- async function readSubmittedCsrfTokenFromBody(request) {
12097
- const headerToken = readSubmittedCsrfToken(request);
12098
- if (headerToken) {
12099
- return headerToken;
12088
+ function ifMatchSatisfied(request, etag) {
12089
+ const header = request.headers.get("if-match");
12090
+ if (!header) {
12091
+ return false;
12100
12092
  }
12101
- const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
12102
- if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
12103
- const formData = await request.clone().formData();
12104
- const field = formData.get("_token");
12105
- if (typeof field === "string" && field.trim().length > 0) {
12106
- return field.trim();
12107
- }
12108
- const legacyField = formData.get("_csrf");
12109
- if (typeof legacyField === "string" && legacyField.trim().length > 0) {
12110
- return legacyField.trim();
12111
- }
12093
+ if (header.trim() === "*") {
12094
+ return true;
12112
12095
  }
12113
- return null;
12096
+ return parseEtagList(header).some((candidate) => etagValuesMatch(candidate, etag));
12114
12097
  }
12115
- function verifyCsrfToken(request, submittedToken) {
12116
- if (!submittedToken) {
12117
- return false;
12118
- }
12119
- const cookieValue = readRequestCookie(request, csrfCookieName());
12120
- if (!cookieValue) {
12121
- return false;
12098
+ function assertIfMatch(request, etag, options = {}) {
12099
+ const header = request.headers.get("if-match");
12100
+ if (!header) {
12101
+ if (options.required) {
12102
+ throw new PreconditionFailedError("If-Match header is required.");
12103
+ }
12104
+ return;
12122
12105
  }
12123
- if (!tokensMatch(submittedToken, cookieValue)) {
12124
- return false;
12106
+ if (!ifMatchSatisfied(request, etag)) {
12107
+ throw new PreconditionFailedError("Resource ETag does not match If-Match.");
12125
12108
  }
12126
- return Bun.CSRF.verify(submittedToken, csrfVerifyOptions());
12127
12109
  }
12128
- function resolveCsrfTokenForRequest(request) {
12129
- const metaToken = currentRequestMeta().csrfToken;
12130
- if (metaToken) {
12131
- return metaToken;
12132
- }
12133
- return resolveCsrfToken(request).token;
12110
+ function applyEtagHeaders(headers, etag) {
12111
+ const next = new Headers(headers);
12112
+ next.set("ETag", etag);
12113
+ next.set("Cache-Control", "private, must-revalidate");
12114
+ next.append("Vary", "Authorization");
12115
+ next.append("Vary", "X-Tenant-Id");
12116
+ return next;
12134
12117
  }
12135
-
12136
- // ../../src/core/http/csrfMiddleware.ts
12137
- var MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
12138
- function samlAcsPathname() {
12139
- const configured = process.env.SAML_ACS_URL?.trim();
12140
- if (!configured) {
12141
- return "/auth/saml/acs";
12118
+ function notModifiedResponse(etag) {
12119
+ return new Response(null, {
12120
+ status: 304,
12121
+ headers: applyEtagHeaders(new Headers, etag)
12122
+ });
12123
+ }
12124
+ function applyConditionalGet(request, response, etag) {
12125
+ if (!isEtagEnabled()) {
12126
+ return response;
12142
12127
  }
12143
- try {
12144
- return new URL(configured, "http://strata.invalid").pathname;
12145
- } catch {
12146
- return "/auth/saml/acs";
12128
+ if (ifNoneMatchSatisfied(request, etag)) {
12129
+ return notModifiedResponse(etag);
12147
12130
  }
12131
+ const headers = applyEtagHeaders(new Headers(response.headers), etag);
12132
+ return new Response(response.body, {
12133
+ status: response.status,
12134
+ statusText: response.statusText,
12135
+ headers
12136
+ });
12148
12137
  }
12149
- function skipsCsrfPath(pathname) {
12150
- return pathname === samlAcsPathname() || pathname.startsWith("/scim/") || pathname.startsWith("/billing/webhooks/");
12138
+
12139
+ // ../../src/core/http/conditionalResponse.ts
12140
+ function jsonResponse2(data, init = {}) {
12141
+ return Response.json(data, {
12142
+ status: init.status ?? 200,
12143
+ headers: init.headers
12144
+ });
12151
12145
  }
12152
- function appendSetCookie(response, cookie) {
12146
+ function conditionalJsonResponse(request, data, init = {}) {
12147
+ if (!request || !isEtagEnabled()) {
12148
+ return jsonResponse2(data, init);
12149
+ }
12150
+ const etag = computeEtagFromJson(data);
12151
+ if (ifNoneMatchSatisfied(request, etag)) {
12152
+ return notModifiedResponse(etag);
12153
+ }
12154
+ const response = jsonResponse2(data, init);
12153
12155
  const headers = new Headers(response.headers);
12154
- headers.append("set-cookie", cookie);
12156
+ headers.set("ETag", etag);
12157
+ headers.set("Cache-Control", "private, must-revalidate");
12158
+ headers.append("Vary", "Authorization");
12159
+ headers.append("Vary", "X-Tenant-Id");
12155
12160
  return new Response(response.body, {
12156
12161
  status: response.status,
12157
12162
  statusText: response.statusText,
12158
12163
  headers
12159
12164
  });
12160
12165
  }
12161
- function createCsrfMiddleware() {
12162
- return async (request, next) => {
12163
- const credentialSource = currentCredentialSource();
12164
- if (credentialSource === "bearer" || credentialSource === "basic") {
12165
- return await next();
12166
- }
12167
- const pathname = new URL(request.url).pathname;
12168
- if (skipsCsrfPath(pathname)) {
12169
- return await next();
12170
- }
12171
- const method = request.method.toUpperCase();
12172
- if (!MUTATING_METHODS.has(method)) {
12173
- const meta = currentRequestMeta();
12174
- const csrf = meta.csrfToken ? { token: meta.csrfToken } : resolveCsrfToken(request);
12175
- meta.csrfToken = csrf.token;
12176
- const response = await next();
12177
- if (!csrf.cookie) {
12178
- return response;
12179
- }
12180
- return appendSetCookie(response, csrf.cookie);
12181
- }
12182
- const submitted = await readSubmittedCsrfTokenFromBody(request);
12183
- if (!verifyCsrfToken(request, submitted)) {
12184
- throw new ForbiddenError("Invalid or missing CSRF token.");
12185
- }
12186
- const originHeader = request.headers.get("origin");
12187
- let headerOrigin = null;
12188
- if (originHeader) {
12189
- try {
12190
- headerOrigin = new URL(originHeader).origin;
12191
- } catch {
12192
- headerOrigin = null;
12193
- }
12194
- }
12195
- if (!headerOrigin || headerOrigin !== new URL(request.url).origin) {
12196
- throw new ForbiddenError("Cross-site cookie requests are not allowed.");
12197
- }
12198
- return await next();
12199
- };
12200
- }
12201
- // ../../src/core/http/csrfProtection.ts
12202
- var DEFAULT_CSRF_TTL_MS = 60 * 60 * 1000;
12203
- function createCsrfProtection(secret, options = {}) {
12204
- const expiresIn = options.expiresIn ?? DEFAULT_CSRF_TTL_MS;
12205
- const maxAge = options.maxAge ?? expiresIn;
12206
- return {
12207
- generate(_sessionKey) {
12208
- return Bun.CSRF.generate(secret, { expiresIn });
12209
- },
12210
- verify(token, _sessionKey) {
12211
- if (!token) {
12212
- return false;
12213
- }
12214
- return Bun.CSRF.verify(token, { secret, maxAge });
12215
- },
12216
- secret
12217
- };
12166
+ // ../../src/core/http/contentSecurityPolicy.ts
12167
+ var HTMX_2_0_4_INDICATOR_STYLE_HASH = "'sha256-bsV5JivYxvGywDAZ22EZJKBFip65Ng9xoJVLbBg7bdo='";
12168
+ var API_DIRECTIVES = {
12169
+ "default-src": ["'none'"],
12170
+ "frame-ancestors": ["'none'"],
12171
+ "base-uri": ["'none'"]
12172
+ };
12173
+ var HTMX_HTML_DIRECTIVES = {
12174
+ "default-src": ["'self'"],
12175
+ "script-src": ["'self'"],
12176
+ "style-src": ["'self'", HTMX_2_0_4_INDICATOR_STYLE_HASH],
12177
+ "connect-src": ["'self'"],
12178
+ "img-src": ["'self'", "data:", "https:"],
12179
+ "font-src": ["'self'"],
12180
+ "media-src": ["'self'", "https:"],
12181
+ "frame-src": [
12182
+ "https://www.youtube.com",
12183
+ "https://www.youtube-nocookie.com",
12184
+ "https://player.vimeo.com"
12185
+ ],
12186
+ "form-action": ["'self'"],
12187
+ "frame-ancestors": ["'none'"],
12188
+ "base-uri": ["'self'"]
12189
+ };
12190
+ var SPA_HTML_DIRECTIVES = {
12191
+ "default-src": ["'self'"],
12192
+ "script-src": ["'self'"],
12193
+ "style-src": ["'self'"],
12194
+ "connect-src": ["'self'"],
12195
+ "img-src": ["'self'", "data:", "https:"],
12196
+ "font-src": ["'self'"],
12197
+ "frame-ancestors": ["'none'"],
12198
+ "base-uri": ["'self'"]
12199
+ };
12200
+ var configuredHtmlOptions = {};
12201
+ function generateCspNonce() {
12202
+ const bytes = new Uint8Array(16);
12203
+ crypto.getRandomValues(bytes);
12204
+ return Buffer.from(bytes).toString("base64");
12218
12205
  }
12219
- // ../../src/core/http/flashSession.ts
12220
- import { createHmac as createHmac5, timingSafeEqual as timingSafeEqual4 } from "crypto";
12221
- var FLASH_COOKIE = appCookieName("flash");
12222
- var FLASH_TTL_MS = 60 * 1000;
12223
- function flashCookieName() {
12224
- return process.env.FLASH_COOKIE_NAME?.trim() || appCookieName("flash");
12206
+ function configureContentSecurityPolicy(options) {
12207
+ configuredHtmlOptions = { ...options };
12225
12208
  }
12226
- function resolveFlashSecret() {
12227
- return requireConfiguredSecret(["SESSION_SECRET", "OAUTH_STATE_SECRET"], "flash-secret");
12209
+ function resetContentSecurityPolicyForTests() {
12210
+ configuredHtmlOptions = {};
12228
12211
  }
12229
- function signFlashPayload(payload, issuedAt) {
12230
- const signature = createHmac5("sha256", resolveFlashSecret()).update(`${payload}.${issuedAt}`).digest("hex");
12231
- return `${payload}.${issuedAt}.${signature}`;
12212
+ function cloneDirectives(source) {
12213
+ const copy = {};
12214
+ for (const [directive, values] of Object.entries(source)) {
12215
+ copy[directive] = [...values];
12216
+ }
12217
+ return copy;
12232
12218
  }
12233
- function readFlashCookie(request) {
12234
- const cookieHeader = request.headers.get("cookie");
12235
- if (!cookieHeader) {
12236
- return null;
12219
+ function normalizeSources(value) {
12220
+ if (Array.isArray(value)) {
12221
+ return value.flatMap((item) => item.split(/\s+/).filter(Boolean));
12237
12222
  }
12238
- for (const part of cookieHeader.split(";")) {
12239
- const [name, ...rest] = part.trim().split("=");
12240
- if (name === flashCookieName()) {
12241
- return decodeURIComponent(rest.join("="));
12223
+ return value.split(/\s+/).filter(Boolean);
12224
+ }
12225
+ function mergeDirectives(base, extra) {
12226
+ const merged = cloneDirectives(base);
12227
+ for (const [directive, value] of Object.entries(extra ?? {})) {
12228
+ if (value === false) {
12229
+ delete merged[directive];
12230
+ continue;
12242
12231
  }
12232
+ const sources = normalizeSources(value);
12233
+ const existing = merged[directive] ?? [];
12234
+ merged[directive] = [...existing, ...sources.filter((source) => !existing.includes(source))];
12243
12235
  }
12244
- return null;
12236
+ return merged;
12245
12237
  }
12246
- function parseFlashCookie(cookieValue) {
12247
- const parts = cookieValue.split(".");
12248
- if (parts.length < 3) {
12249
- return null;
12250
- }
12251
- const signature = parts.pop();
12252
- const issuedAtRaw = parts.pop();
12253
- const payload = parts.join(".");
12254
- if (!signature || !issuedAtRaw || !payload) {
12255
- return null;
12256
- }
12257
- const issuedAt = Number.parseInt(issuedAtRaw, 10);
12258
- if (!Number.isFinite(issuedAt) || Date.now() - issuedAt > FLASH_TTL_MS) {
12259
- return null;
12238
+ function serializeDirectives(directives) {
12239
+ return Object.entries(directives).map(([directive, values]) => values.length > 0 ? `${directive} ${values.join(" ")}` : directive).join("; ");
12240
+ }
12241
+ function applyNonce(directives, nonce) {
12242
+ if (!nonce) {
12243
+ return directives;
12260
12244
  }
12261
- const expectedSignature = signFlashPayload(payload, issuedAt).split(".").pop();
12262
- if (!expectedSignature) {
12263
- return null;
12245
+ const token = `'nonce-${nonce}'`;
12246
+ const next = cloneDirectives(directives);
12247
+ for (const directive of ["script-src", "style-src"]) {
12248
+ const values = next[directive] ?? ["'self'"];
12249
+ if (!values.includes(token)) {
12250
+ next[directive] = [...values, token];
12251
+ }
12264
12252
  }
12265
- const expectedBuffer = Buffer.from(expectedSignature);
12266
- const actualBuffer = Buffer.from(signature);
12267
- if (expectedBuffer.length !== actualBuffer.length) {
12268
- return null;
12253
+ return next;
12254
+ }
12255
+ function htmlBaselineDirectives() {
12256
+ if (isViewsEnabled()) {
12257
+ return cloneDirectives(HTMX_HTML_DIRECTIVES);
12269
12258
  }
12270
- if (!timingSafeEqual4(expectedBuffer, actualBuffer)) {
12271
- return null;
12259
+ if (isSpaEnabled()) {
12260
+ return cloneDirectives(SPA_HTML_DIRECTIVES);
12272
12261
  }
12273
- try {
12274
- const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
12275
- if (!parsed?.message || typeof parsed.message !== "string") {
12276
- return null;
12277
- }
12278
- if (parsed.level !== "success" && parsed.level !== "error" && parsed.level !== "info") {
12279
- return null;
12262
+ return cloneDirectives(API_DIRECTIVES);
12263
+ }
12264
+ function strictApiContentSecurityPolicy() {
12265
+ return serializeDirectives(API_DIRECTIVES);
12266
+ }
12267
+ function serverHtmxContentSecurityPolicy(nonce) {
12268
+ return serializeDirectives(applyNonce(cloneDirectives(HTMX_HTML_DIRECTIVES), nonce));
12269
+ }
12270
+ function spaContentSecurityPolicy(nonce) {
12271
+ return serializeDirectives(applyNonce(cloneDirectives(SPA_HTML_DIRECTIVES), nonce));
12272
+ }
12273
+ function resolveHtmlContentSecurityPolicy(options = {}) {
12274
+ const resolved = {
12275
+ ...configuredHtmlOptions,
12276
+ ...options,
12277
+ directives: {
12278
+ ...configuredHtmlOptions.directives,
12279
+ ...options.directives
12280
12280
  }
12281
- return parsed;
12282
- } catch {
12283
- return null;
12281
+ };
12282
+ const override = resolved.htmlCsp ?? resolved.csp;
12283
+ if (override) {
12284
+ return override;
12284
12285
  }
12286
+ return serializeDirectives(applyNonce(mergeDirectives(htmlBaselineDirectives(), resolved.directives), options.nonce));
12285
12287
  }
12286
- function clearFlashCookie() {
12287
- const secure = isProductionEnv() ? "; Secure" : "";
12288
- return `${flashCookieName()}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`;
12289
- }
12290
- function pullFlash(request) {
12291
- const cookieValue = readFlashCookie(request);
12292
- if (!cookieValue) {
12293
- return null;
12288
+ function resolveContentSecurityPolicy(response, options = {}) {
12289
+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
12290
+ if (!contentType.includes("text/html")) {
12291
+ return strictApiContentSecurityPolicy();
12294
12292
  }
12295
- return parseFlashCookie(cookieValue);
12293
+ return resolveHtmlContentSecurityPolicy(options);
12296
12294
  }
12297
- function withFlashClear(response) {
12298
- const headers = new Headers(response.headers);
12299
- headers.append("set-cookie", clearFlashCookie());
12300
- return new Response(response.body, {
12301
- status: response.status,
12302
- statusText: response.statusText,
12303
- headers
12295
+ // ../../src/core/http/corsMiddleware.ts
12296
+ function additionalHeaders(options) {
12297
+ const configured = process.env.CORS_ADDITIONAL_ALLOWED_HEADERS?.trim();
12298
+ const supplied = options.additionalAllowedHeaders === undefined ? [] : options.additionalAllowedHeaders;
12299
+ if (!Array.isArray(supplied)) {
12300
+ throw new TypeError("CORS additionalAllowedHeaders must be an array of header names.");
12301
+ }
12302
+ const names = [
12303
+ ...configured ? configured.split(",").map((name) => name.trim()) : [],
12304
+ ...supplied
12305
+ ];
12306
+ return names.map((name) => {
12307
+ if (typeof name !== "string" || name === "*" || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)) {
12308
+ throw new TypeError("CORS additional headers must be explicit valid HTTP header names; wildcards are not allowed.");
12309
+ }
12310
+ return name;
12304
12311
  });
12305
12312
  }
12306
-
12307
- // ../../src/core/http/flashMiddleware.ts
12308
- function createFlashMiddleware() {
12313
+ function defaultAllowedOrigins() {
12314
+ return appUrl();
12315
+ }
12316
+ function resolveCorsConfig(options = {}) {
12317
+ const extraHeaders = additionalHeaders(options);
12318
+ return {
12319
+ allowedOrigins: (process.env.CORS_ALLOWED_ORIGINS ?? defaultAllowedOrigins()).split(",").map((origin) => origin.trim()).filter(Boolean),
12320
+ allowedMethods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
12321
+ allowedHeaders: [
12322
+ "Authorization",
12323
+ "Content-Type",
12324
+ "X-Request-Id",
12325
+ "X-Tenant-Id",
12326
+ "X-Authenticated-User-Id",
12327
+ "X-Authenticated-User-Role",
12328
+ "If-Match",
12329
+ "If-None-Match",
12330
+ "X-CSRF-Token",
12331
+ ...extraHeaders
12332
+ ].filter((name, index, names) => names.findIndex((other) => other.toLowerCase() === name.toLowerCase()) === index),
12333
+ maxAgeSeconds: 86400
12334
+ };
12335
+ }
12336
+ function createCorsMiddleware(options = {}) {
12337
+ resolveCorsConfig(options);
12338
+ const captured = {
12339
+ additionalAllowedHeaders: [...options.additionalAllowedHeaders ?? []]
12340
+ };
12309
12341
  return async (request, next) => {
12310
- const flash = pullFlash(request);
12311
- const meta = currentRequestMeta();
12312
- return await runWithRequestMeta({ ...meta, request, flash }, async () => {
12313
- const response = await next();
12314
- if (flash) {
12315
- return withFlashClear(response);
12342
+ if (request.method === "OPTIONS") {
12343
+ return new Response(null, {
12344
+ status: 204,
12345
+ headers: buildCorsHeaders(request, captured)
12346
+ });
12347
+ }
12348
+ const response = await next();
12349
+ const headers = new Headers(response.headers);
12350
+ for (const [key, value] of buildCorsHeaders(request, captured)) {
12351
+ if (key === "vary") {
12352
+ const fields = (headers.get("Vary") ?? "").split(",").map((field) => field.trim()).filter(Boolean);
12353
+ if (fields.includes("*"))
12354
+ continue;
12355
+ if (!fields.some((field) => field.toLowerCase() === "origin"))
12356
+ fields.push(value);
12357
+ headers.set("Vary", fields.join(", "));
12358
+ } else {
12359
+ headers.set(key, value);
12316
12360
  }
12317
- return response;
12361
+ }
12362
+ return new Response(response.body, {
12363
+ status: response.status,
12364
+ statusText: response.statusText,
12365
+ headers
12318
12366
  });
12319
12367
  };
12320
12368
  }
12321
- // ../../src/core/http/validation.ts
12322
- init_tenantContext();
12323
- function buildRequestCacheKey(fallbackPath, request) {
12324
- if (!request) {
12325
- return fallbackPath;
12369
+ function buildCorsHeaders(request, options) {
12370
+ const headers = new Headers;
12371
+ const origin = request.headers.get("origin");
12372
+ const corsConfig = resolveCorsConfig(options);
12373
+ const allowedOrigins = corsConfig.allowedOrigins;
12374
+ headers.set("Vary", "Origin");
12375
+ const allowOrigin = allowedOrigins.includes("*") ? "*" : origin && allowedOrigins.includes(origin) ? origin : null;
12376
+ if (!allowOrigin) {
12377
+ return headers;
12326
12378
  }
12327
- const url = new URL(request.url);
12328
- const user = currentAuthUser();
12329
- const authScope = user ? `u:${user.id}` : "guest";
12330
- const tenantScope = `t:${currentTenant()?.id ?? "none"}`;
12331
- return `${authScope}|${tenantScope}|${url.pathname}${url.search}`;
12332
- }
12333
- function getQueryParams(request) {
12334
- if (!request) {
12335
- return new URLSearchParams;
12379
+ headers.set("Access-Control-Allow-Origin", allowOrigin);
12380
+ headers.set("Access-Control-Allow-Methods", corsConfig.allowedMethods.join(", "));
12381
+ headers.set("Access-Control-Allow-Headers", corsConfig.allowedHeaders.join(", "));
12382
+ headers.set("Access-Control-Max-Age", String(corsConfig.maxAgeSeconds));
12383
+ if (allowOrigin !== "*") {
12384
+ headers.set("Access-Control-Allow-Credentials", "true");
12336
12385
  }
12337
- return new URL(request.url).searchParams;
12386
+ return headers;
12338
12387
  }
12339
- function parseOptionalPositiveIntQueryParam(params, name) {
12340
- const value = params.get(name);
12341
- if (value === null || value.trim() === "") {
12342
- return;
12343
- }
12344
- const parsed = Number.parseInt(value, 10);
12345
- if (!Number.isInteger(parsed) || parsed <= 0) {
12346
- throw new BadRequestError(`Invalid query parameter "${name}". Expected a positive integer.`);
12347
- }
12348
- return parsed;
12388
+ // ../../src/core/http/csrfToken.ts
12389
+ import { timingSafeEqual as timingSafeEqual3 } from "crypto";
12390
+ var CSRF_COOKIE = appCookieName("csrf");
12391
+ var CSRF_TTL_MS = 60 * 60 * 1000;
12392
+ function csrfCookieName() {
12393
+ return process.env.CSRF_COOKIE_NAME?.trim() || appCookieName("csrf");
12349
12394
  }
12350
- function parseOptionalBooleanQueryParam(params, name) {
12351
- const value = params.get(name);
12352
- if (value === null || value.trim() === "") {
12353
- return;
12354
- }
12355
- switch (value.toLowerCase()) {
12356
- case "true":
12357
- case "1":
12358
- return true;
12359
- case "false":
12360
- case "0":
12361
- return false;
12362
- default:
12363
- throw new BadRequestError(`Invalid query parameter "${name}". Expected a boolean.`);
12364
- }
12395
+ function resolveCsrfSecret() {
12396
+ return requireConfiguredSecret(["SESSION_SECRET", "CSRF_SECRET"], "csrf-secret");
12365
12397
  }
12366
- function parseOptionalEnumQueryParam(params, name, allowedValues) {
12367
- const value = params.get(name);
12368
- if (value === null || value.trim() === "") {
12369
- return;
12370
- }
12371
- if (!allowedValues.includes(value)) {
12372
- throw new BadRequestError(`Invalid query parameter "${name}". Expected one of: ${allowedValues.join(", ")}.`);
12373
- }
12374
- return value;
12398
+ function csrfVerifyOptions() {
12399
+ return { secret: resolveCsrfSecret(), maxAge: CSRF_TTL_MS };
12375
12400
  }
12376
- function expectObject(value, label = "request body") {
12377
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
12378
- throw new BadRequestError(`${label} must be a JSON object.`);
12401
+ function tokensMatch(left, right) {
12402
+ const leftBuffer = Buffer.from(left);
12403
+ const rightBuffer = Buffer.from(right);
12404
+ if (leftBuffer.length !== rightBuffer.length) {
12405
+ return false;
12379
12406
  }
12380
- return value;
12407
+ return timingSafeEqual3(leftBuffer, rightBuffer);
12381
12408
  }
12382
- async function parseJsonBody(request, validator) {
12383
- let payload;
12384
- try {
12385
- payload = await request.json();
12386
- } catch {
12387
- throw new BadRequestError("Request body must be valid JSON.");
12388
- }
12389
- return validator(payload);
12409
+ function createCsrfTokenCookie() {
12410
+ const token = Bun.CSRF.generate(resolveCsrfSecret(), { expiresIn: CSRF_TTL_MS });
12411
+ const secure = isProductionEnv() ? "; Secure" : "";
12412
+ return {
12413
+ token,
12414
+ cookie: `${csrfCookieName()}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(CSRF_TTL_MS / 1000)}${secure}`
12415
+ };
12390
12416
  }
12391
- function readRequiredString(payload, field, options = {}) {
12392
- const value = payload[field];
12393
- if (typeof value !== "string" || value.trim() === "") {
12394
- throw new BadRequestError(`"${field}" is required and must be a string.`);
12395
- }
12396
- const trimmed = value.trim();
12397
- if (options.minLength !== undefined && trimmed.length < options.minLength) {
12398
- throw new BadRequestError(`"${field}" must be at least ${options.minLength} characters.`);
12399
- }
12400
- if (options.maxLength !== undefined && trimmed.length > options.maxLength) {
12401
- throw new BadRequestError(`"${field}" must be at most ${options.maxLength} characters.`);
12402
- }
12403
- if (options.pattern && !options.pattern.test(trimmed)) {
12404
- throw new BadRequestError(`"${field}" has an invalid format.`);
12417
+ function resolveCsrfToken(request) {
12418
+ const cookieValue = readRequestCookie(request, csrfCookieName());
12419
+ if (cookieValue && Bun.CSRF.verify(cookieValue, csrfVerifyOptions())) {
12420
+ return { token: cookieValue };
12405
12421
  }
12406
- return trimmed;
12422
+ return createCsrfTokenCookie();
12407
12423
  }
12408
- function readOptionalString(payload, field, options = {}) {
12409
- if (!(field in payload) || payload[field] === undefined) {
12410
- return;
12424
+ function readSubmittedCsrfToken(request) {
12425
+ const headerToken = request.headers.get("x-csrf-token")?.trim();
12426
+ if (headerToken) {
12427
+ return headerToken;
12411
12428
  }
12412
- return readRequiredString(payload, field, options);
12429
+ return null;
12413
12430
  }
12414
- function readRequiredEnum(payload, field, allowedValues) {
12415
- const value = readRequiredString(payload, field);
12416
- if (!allowedValues.includes(value)) {
12417
- throw new BadRequestError(`"${field}" must be one of: ${allowedValues.join(", ")}.`);
12431
+ async function readSubmittedCsrfTokenFromBody(request) {
12432
+ const headerToken = readSubmittedCsrfToken(request);
12433
+ if (headerToken) {
12434
+ return headerToken;
12418
12435
  }
12419
- return value;
12420
- }
12421
- function readOptionalEnum(payload, field, allowedValues) {
12422
- if (!(field in payload) || payload[field] === undefined) {
12423
- return;
12436
+ const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
12437
+ if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
12438
+ const formData = await request.clone().formData();
12439
+ const field = formData.get("_token");
12440
+ if (typeof field === "string" && field.trim().length > 0) {
12441
+ return field.trim();
12442
+ }
12443
+ const legacyField = formData.get("_csrf");
12444
+ if (typeof legacyField === "string" && legacyField.trim().length > 0) {
12445
+ return legacyField.trim();
12446
+ }
12424
12447
  }
12425
- return readRequiredEnum(payload, field, allowedValues);
12448
+ return null;
12426
12449
  }
12427
- function readRequiredPositiveInt(payload, field) {
12428
- const value = payload[field];
12429
- if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
12430
- throw new BadRequestError(`"${field}" is required and must be a positive integer.`);
12450
+ function verifyCsrfToken(request, submittedToken) {
12451
+ if (!submittedToken) {
12452
+ return false;
12431
12453
  }
12432
- return value;
12433
- }
12434
- function readOptionalPositiveInt(payload, field) {
12435
- if (!(field in payload) || payload[field] === undefined) {
12436
- return;
12454
+ const cookieValue = readRequestCookie(request, csrfCookieName());
12455
+ if (!cookieValue) {
12456
+ return false;
12437
12457
  }
12438
- return readRequiredPositiveInt(payload, field);
12439
- }
12440
- function parsePositiveIntParam(value, name = "id") {
12441
- const parsed = Number.parseInt(value, 10);
12442
- if (!Number.isInteger(parsed) || parsed <= 0) {
12443
- throw new BadRequestError(`Invalid ${name}. Expected a positive integer.`);
12458
+ if (!tokensMatch(submittedToken, cookieValue)) {
12459
+ return false;
12444
12460
  }
12445
- return parsed;
12461
+ return Bun.CSRF.verify(submittedToken, csrfVerifyOptions());
12446
12462
  }
12447
-
12448
- // ../../src/core/http/formRequest.ts
12449
- class FormRequest {
12450
- authorize(_request) {
12451
- return true;
12452
- }
12453
- async validate(request) {
12454
- if (!await this.authorize(request)) {
12455
- throw new ForbiddenError;
12456
- }
12457
- return await parseJsonBody(request, (payload) => this.parse(payload));
12463
+ function resolveCsrfTokenForRequest(request) {
12464
+ const metaToken = currentRequestMeta().csrfToken;
12465
+ if (metaToken) {
12466
+ return metaToken;
12458
12467
  }
12468
+ return resolveCsrfToken(request).token;
12459
12469
  }
12460
12470
 
12461
- class QueryFormRequest {
12462
- validate(request) {
12463
- return this.parseQuery(request);
12471
+ // ../../src/core/http/csrfMiddleware.ts
12472
+ var MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
12473
+ function samlAcsPathname() {
12474
+ const configured = process.env.SAML_ACS_URL?.trim();
12475
+ if (!configured) {
12476
+ return "/auth/saml/acs";
12464
12477
  }
12465
- }
12466
- // ../../src/core/http/authMiddleware.ts
12467
- function createAuthMiddleware(auth) {
12468
- return async (request, next) => {
12469
- const resolved = typeof auth.resolveWithSource === "function" ? await auth.resolveWithSource(request) : { user: await auth.resolve(request), credentialSource: null };
12470
- return await runWithAuthContext(resolved.user, resolved.credentialSource, async () => {
12471
- return await next();
12472
- });
12473
- };
12474
- }
12475
- // ../../src/core/http/authorizeMiddleware.ts
12476
- function createAuthorizeMiddleware(gate, auth, resource, action) {
12477
- return async (request, next) => {
12478
- const user = currentAuthUser() ?? await auth.resolve(request);
12479
- if (!gate.allows(resource, action, user)) {
12480
- const error = new ForbiddenError;
12481
- return Response.json({ error: error.message }, { status: error.status });
12482
- }
12483
- return await next();
12484
- };
12485
- }
12486
- // ../../src/core/http/middleware.ts
12487
- function isRouteHandler(value) {
12488
- return typeof value === "function";
12489
- }
12490
- function isMethodRouteMap(value) {
12491
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
12492
- return false;
12478
+ try {
12479
+ return new URL(configured, "http://strata.invalid").pathname;
12480
+ } catch {
12481
+ return "/auth/saml/acs";
12493
12482
  }
12494
- const entries = Object.entries(value);
12495
- return entries.length > 0 && entries.every(([, handler]) => isRouteHandler(handler));
12496
12483
  }
12497
- function composeMiddleware(...middleware) {
12498
- return (handler) => {
12499
- return async (request) => {
12500
- let index = 0;
12501
- const dispatch = async () => {
12502
- if (index >= middleware.length) {
12503
- return await handler(request);
12504
- }
12505
- const current = middleware[index];
12506
- index += 1;
12507
- if (!current) {
12508
- return await handler(request);
12509
- }
12510
- return await current(request, dispatch);
12511
- };
12512
- return await dispatch();
12513
- };
12514
- };
12484
+ function skipsCsrfPath(pathname) {
12485
+ return pathname === samlAcsPathname() || pathname.startsWith("/scim/") || pathname.startsWith("/billing/webhooks/");
12515
12486
  }
12516
- async function requestIdMiddleware(request, next) {
12517
- const requestId = request.headers.get("x-request-id") ?? crypto.randomUUID();
12518
- const response = await next();
12487
+ function appendSetCookie(response, cookie) {
12519
12488
  const headers = new Headers(response.headers);
12520
- headers.set("x-request-id", requestId);
12489
+ headers.append("set-cookie", cookie);
12521
12490
  return new Response(response.body, {
12522
12491
  status: response.status,
12523
12492
  statusText: response.statusText,
12524
12493
  headers
12525
12494
  });
12526
12495
  }
12527
- function wrapRouteHandler(handler, middleware) {
12528
- if (isMethodRouteMap(handler)) {
12529
- const wrapped = {};
12530
- for (const [method, routeHandler] of Object.entries(handler)) {
12531
- wrapped[method] = composeMiddleware(...middleware)(routeHandler);
12496
+ function createCsrfMiddleware() {
12497
+ return async (request, next) => {
12498
+ const credentialSource = currentCredentialSource();
12499
+ if (credentialSource === "bearer" || credentialSource === "basic") {
12500
+ return await next();
12532
12501
  }
12533
- return wrapped;
12534
- }
12535
- if (isRouteHandler(handler)) {
12536
- return composeMiddleware(...middleware)(handler);
12537
- }
12538
- return handler;
12502
+ const pathname = new URL(request.url).pathname;
12503
+ if (skipsCsrfPath(pathname)) {
12504
+ return await next();
12505
+ }
12506
+ const method = request.method.toUpperCase();
12507
+ if (!MUTATING_METHODS.has(method)) {
12508
+ const meta = currentRequestMeta();
12509
+ const csrf = meta.csrfToken ? { token: meta.csrfToken } : resolveCsrfToken(request);
12510
+ meta.csrfToken = csrf.token;
12511
+ const response = await next();
12512
+ if (!csrf.cookie) {
12513
+ return response;
12514
+ }
12515
+ return appendSetCookie(response, csrf.cookie);
12516
+ }
12517
+ const submitted = await readSubmittedCsrfTokenFromBody(request);
12518
+ if (!verifyCsrfToken(request, submitted)) {
12519
+ throw new ForbiddenError("Invalid or missing CSRF token.");
12520
+ }
12521
+ const originHeader = request.headers.get("origin");
12522
+ let headerOrigin = null;
12523
+ if (originHeader) {
12524
+ try {
12525
+ headerOrigin = new URL(originHeader).origin;
12526
+ } catch {
12527
+ headerOrigin = null;
12528
+ }
12529
+ }
12530
+ if (!headerOrigin || headerOrigin !== new URL(request.url).origin) {
12531
+ throw new ForbiddenError("Cross-site cookie requests are not allowed.");
12532
+ }
12533
+ return await next();
12534
+ };
12539
12535
  }
12540
- function applyMiddlewareToRoutes(routes, middleware) {
12541
- const wrapped = {};
12542
- for (const [path, routeHandler] of Object.entries(routes)) {
12543
- const identity = async (_request, next) => await runWithRequestMeta({ ...currentRequestMeta(), routeTemplate: path }, next);
12544
- wrapped[path] = wrapRouteHandler(routeHandler, [identity, ...middleware]);
12545
- }
12546
- return wrapped;
12536
+ // ../../src/core/http/csrfProtection.ts
12537
+ var DEFAULT_CSRF_TTL_MS = 60 * 60 * 1000;
12538
+ function createCsrfProtection(secret, options = {}) {
12539
+ const expiresIn = options.expiresIn ?? DEFAULT_CSRF_TTL_MS;
12540
+ const maxAge = options.maxAge ?? expiresIn;
12541
+ return {
12542
+ generate(_sessionKey) {
12543
+ return Bun.CSRF.generate(secret, { expiresIn });
12544
+ },
12545
+ verify(token, _sessionKey) {
12546
+ if (!token) {
12547
+ return false;
12548
+ }
12549
+ return Bun.CSRF.verify(token, { secret, maxAge });
12550
+ },
12551
+ secret
12552
+ };
12547
12553
  }
12548
- // ../../src/core/http/pagination.ts
12549
- var DEFAULT_PER_PAGE = 15;
12550
- var MAX_PER_PAGE = 100;
12551
- function parseRequiredPositiveIntQueryParam(params, name) {
12552
- const value = params.get(name);
12553
- if (value === null || value.trim() === "") {
12554
- throw new BadRequestError(`Invalid query parameter "${name}". Expected a positive integer.`);
12555
- }
12556
- const parsed = Number.parseInt(value, 10);
12557
- if (!Number.isInteger(parsed) || parsed <= 0) {
12558
- throw new BadRequestError(`Invalid query parameter "${name}". Expected a positive integer.`);
12559
- }
12560
- return parsed;
12554
+ // ../../src/core/http/flashSession.ts
12555
+ import { createHmac as createHmac5, timingSafeEqual as timingSafeEqual4 } from "crypto";
12556
+ var FLASH_COOKIE = appCookieName("flash");
12557
+ var FLASH_TTL_MS = 60 * 1000;
12558
+ function flashCookieName() {
12559
+ return process.env.FLASH_COOKIE_NAME?.trim() || appCookieName("flash");
12561
12560
  }
12562
- function parsePaginationQuery(request) {
12563
- const params = getQueryParams(request);
12564
- const pageParam = params.get("page");
12565
- const perPageParam = params.get("per_page");
12566
- const page = pageParam === null || pageParam.trim() === "" ? 1 : parseRequiredPositiveIntQueryParam(params, "page");
12567
- if (perPageParam === null || perPageParam.trim() === "") {
12568
- return { page, perPage: DEFAULT_PER_PAGE };
12569
- }
12570
- const perPage = parseRequiredPositiveIntQueryParam(params, "per_page");
12571
- if (perPage > MAX_PER_PAGE) {
12572
- throw new BadRequestError(`Invalid query parameter "per_page". Maximum allowed value is ${MAX_PER_PAGE}.`);
12573
- }
12574
- return { page, perPage };
12561
+ function resolveFlashSecret() {
12562
+ return requireConfiguredSecret(["SESSION_SECRET", "OAUTH_STATE_SECRET"], "flash-secret");
12575
12563
  }
12576
- function paginatedResponse(data, meta, init = {}) {
12577
- return Response.json({ data, meta }, init);
12564
+ function signFlashPayload(payload, issuedAt) {
12565
+ const signature = createHmac5("sha256", resolveFlashSecret()).update(`${payload}.${issuedAt}`).digest("hex");
12566
+ return `${payload}.${issuedAt}.${signature}`;
12578
12567
  }
12579
- // ../../src/core/http/uploads.ts
12580
- var DEFAULT_MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
12581
- var ALLOWED_UPLOAD_MIME_TYPES = new Set([
12582
- "application/pdf",
12583
- "application/json",
12584
- "application/zip",
12585
- "application/x-zip-compressed",
12586
- "image/jpeg",
12587
- "image/png",
12588
- "image/gif",
12589
- "image/webp",
12590
- "text/plain",
12591
- "text/csv"
12592
- ]);
12593
- function resolveMaxUploadBytes() {
12594
- const raw = process.env.MAX_UPLOAD_BYTES?.trim() ?? process.env.MAX_REQUEST_BODY_BYTES?.trim();
12595
- if (!raw) {
12596
- return DEFAULT_MAX_UPLOAD_BYTES;
12597
- }
12598
- const parsed = Number.parseInt(raw, 10);
12599
- if (!Number.isInteger(parsed) || parsed <= 0) {
12600
- return DEFAULT_MAX_UPLOAD_BYTES;
12568
+ function readFlashCookie(request) {
12569
+ const cookieHeader = request.headers.get("cookie");
12570
+ if (!cookieHeader) {
12571
+ return null;
12601
12572
  }
12602
- return parsed;
12603
- }
12604
- function normalizeMimeType(mimeType) {
12605
- return mimeType.split(";")[0]?.trim().toLowerCase() ?? "";
12606
- }
12607
- function isAllowedMimeType(mimeType) {
12608
- const normalized = normalizeMimeType(mimeType);
12609
- if (!normalized || normalized === "application/octet-stream") {
12610
- return envFlagEnabled(process.env.UPLOAD_ALLOW_UNKNOWN_MIME);
12573
+ for (const part of cookieHeader.split(";")) {
12574
+ const [name, ...rest] = part.trim().split("=");
12575
+ if (name === flashCookieName()) {
12576
+ return decodeURIComponent(rest.join("="));
12577
+ }
12611
12578
  }
12612
- return ALLOWED_UPLOAD_MIME_TYPES.has(normalized);
12613
- }
12614
-
12615
- // ../../src/core/http/parseMultipartUpload.ts
12616
- function normalizeMimeType2(mimeType) {
12617
- return mimeType.split(";")[0]?.trim().toLowerCase() || "application/octet-stream";
12618
- }
12619
- function sanitizeUploadFileName(name) {
12620
- const base = name.split(/[/\\]/).pop()?.trim() ?? "upload";
12621
- const sanitized = base.replace(/[^\w.\-()+ ]+/g, "_").slice(0, 200);
12622
- return sanitized.length > 0 ? sanitized : "upload";
12579
+ return null;
12623
12580
  }
12624
- async function validateUploadFile(file, fieldName = "file") {
12625
- if (!(file instanceof File)) {
12626
- throw new BadRequestError(`Missing upload field "${fieldName}".`);
12581
+ function parseFlashCookie(cookieValue) {
12582
+ const parts = cookieValue.split(".");
12583
+ if (parts.length < 3) {
12584
+ return null;
12627
12585
  }
12628
- if (file.size <= 0) {
12629
- throw new BadRequestError("Uploaded file is empty.");
12586
+ const signature = parts.pop();
12587
+ const issuedAtRaw = parts.pop();
12588
+ const payload = parts.join(".");
12589
+ if (!signature || !issuedAtRaw || !payload) {
12590
+ return null;
12630
12591
  }
12631
- const maxBytes = resolveMaxUploadBytes();
12632
- if (file.size > maxBytes) {
12633
- throw new PayloadTooLargeError(`Upload exceeds the ${maxBytes} byte limit.`);
12592
+ const issuedAt = Number.parseInt(issuedAtRaw, 10);
12593
+ if (!Number.isFinite(issuedAt) || Date.now() - issuedAt > FLASH_TTL_MS) {
12594
+ return null;
12634
12595
  }
12635
- const mimeType = normalizeMimeType2(file.type.trim() || "application/octet-stream");
12636
- if (!isAllowedMimeType(mimeType)) {
12637
- throw new BadRequestError(`File type "${mimeType}" is not allowed.`);
12596
+ const expectedSignature = signFlashPayload(payload, issuedAt).split(".").pop();
12597
+ if (!expectedSignature) {
12598
+ return null;
12638
12599
  }
12639
- return {
12640
- fileName: sanitizeUploadFileName(file.name),
12641
- mimeType,
12642
- size: file.size,
12643
- contents: new Uint8Array(await file.arrayBuffer())
12644
- };
12645
- }
12646
- async function parseMultipartUpload(request, fieldName = "file") {
12647
- const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
12648
- if (!contentType.includes("multipart/form-data")) {
12649
- throw new BadRequestError("Expected multipart form data.");
12600
+ const expectedBuffer = Buffer.from(expectedSignature);
12601
+ const actualBuffer = Buffer.from(signature);
12602
+ if (expectedBuffer.length !== actualBuffer.length) {
12603
+ return null;
12650
12604
  }
12651
- const formData = await request.formData();
12652
- const value = formData.get(fieldName);
12653
- if (!(value instanceof File)) {
12654
- throw new BadRequestError(`Missing upload field "${fieldName}".`);
12605
+ if (!timingSafeEqual4(expectedBuffer, actualBuffer)) {
12606
+ return null;
12655
12607
  }
12656
- return validateUploadFile(value, fieldName);
12657
- }
12658
- // ../../src/core/http/requireAuthMiddleware.ts
12659
- function createRequireAuthMiddleware(auth) {
12660
- return async (request, next) => {
12661
- if (!await auth.check(request)) {
12662
- const error = new UnauthorizedError;
12663
- return Response.json({ error: error.message }, { status: error.status });
12608
+ try {
12609
+ const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
12610
+ if (!parsed?.message || typeof parsed.message !== "string") {
12611
+ return null;
12664
12612
  }
12665
- return await next();
12666
- };
12613
+ if (parsed.level !== "success" && parsed.level !== "error" && parsed.level !== "info") {
12614
+ return null;
12615
+ }
12616
+ return parsed;
12617
+ } catch {
12618
+ return null;
12619
+ }
12667
12620
  }
12668
- // ../../src/core/http/resources.ts
12669
- function serializeDate(value) {
12670
- return value instanceof Date ? value.toISOString() : value;
12621
+ function clearFlashCookie() {
12622
+ const secure = isProductionEnv() ? "; Secure" : "";
12623
+ return `${flashCookieName()}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`;
12671
12624
  }
12672
- function whenLoaded(model, relation, transform) {
12673
- const value = model.loaded(relation);
12674
- if (value === undefined) {
12675
- return;
12676
- }
12677
- if (value === null) {
12625
+ function pullFlash(request) {
12626
+ const cookieValue = readFlashCookie(request);
12627
+ if (!cookieValue) {
12678
12628
  return null;
12679
12629
  }
12680
- return transform ? transform(value) : value;
12630
+ return parseFlashCookie(cookieValue);
12631
+ }
12632
+ function withFlashClear(response) {
12633
+ const headers = new Headers(response.headers);
12634
+ headers.append("set-cookie", clearFlashCookie());
12635
+ return new Response(response.body, {
12636
+ status: response.status,
12637
+ statusText: response.statusText,
12638
+ headers
12639
+ });
12681
12640
  }
12682
12641
 
12683
- class JsonResource {
12684
- resource;
12685
- static wrap = "data";
12686
- extra = {};
12687
- constructor(resource) {
12688
- this.resource = resource;
12689
- }
12690
- static make(resource) {
12691
- return new JsonResource(resource);
12692
- }
12693
- static collection(items) {
12694
- return new ResourceCollection(items);
12642
+ // ../../src/core/http/flashMiddleware.ts
12643
+ function createFlashMiddleware() {
12644
+ return async (request, next) => {
12645
+ const flash = pullFlash(request);
12646
+ const meta = currentRequestMeta();
12647
+ return await runWithRequestMeta({ ...meta, request, flash }, async () => {
12648
+ const response = await next();
12649
+ if (flash) {
12650
+ return withFlashClear(response);
12651
+ }
12652
+ return response;
12653
+ });
12654
+ };
12655
+ }
12656
+ // ../../src/core/http/validation.ts
12657
+ init_tenantContext();
12658
+ function buildRequestCacheKey(fallbackPath, request) {
12659
+ if (!request) {
12660
+ return fallbackPath;
12695
12661
  }
12696
- additional(data) {
12697
- this.extra = { ...this.extra, ...data };
12698
- return this;
12662
+ const url = new URL(request.url);
12663
+ const user = currentAuthUser();
12664
+ const authScope = user ? `u:${user.id}` : "guest";
12665
+ const tenantScope = `t:${currentTenant()?.id ?? "none"}`;
12666
+ return `${authScope}|${tenantScope}|${url.pathname}${url.search}`;
12667
+ }
12668
+ function getQueryParams(request) {
12669
+ if (!request) {
12670
+ return new URLSearchParams;
12699
12671
  }
12700
- when(condition, value) {
12701
- return condition ? value : undefined;
12672
+ return new URL(request.url).searchParams;
12673
+ }
12674
+ function parseOptionalPositiveIntQueryParam(params, name) {
12675
+ const value = params.get(name);
12676
+ if (value === null || value.trim() === "") {
12677
+ return;
12702
12678
  }
12703
- whenLoaded(relation, transform) {
12704
- const model = this.resource;
12705
- if (this.resource == null || typeof model.loaded !== "function") {
12706
- return;
12707
- }
12708
- return whenLoaded(model, relation, transform);
12679
+ const parsed = Number.parseInt(value, 10);
12680
+ if (!Number.isInteger(parsed) || parsed <= 0) {
12681
+ throw new BadRequestError(`Invalid query parameter "${name}". Expected a positive integer.`);
12709
12682
  }
12710
- toArray() {
12711
- if (this.resource && typeof this.resource === "object" && "toArray" in this.resource) {
12712
- return this.resource.toArray();
12713
- }
12714
- return { ...this.resource };
12683
+ return parsed;
12684
+ }
12685
+ function parseOptionalBooleanQueryParam(params, name) {
12686
+ const value = params.get(name);
12687
+ if (value === null || value.trim() === "") {
12688
+ return;
12715
12689
  }
12716
- toResponse() {
12717
- const wrap = this.constructor.wrap;
12718
- const payload = this.toArray();
12719
- if (wrap === null) {
12720
- return { ...payload, ...this.extra };
12721
- }
12722
- return { [wrap]: payload, ...this.extra };
12690
+ switch (value.toLowerCase()) {
12691
+ case "true":
12692
+ case "1":
12693
+ return true;
12694
+ case "false":
12695
+ case "0":
12696
+ return false;
12697
+ default:
12698
+ throw new BadRequestError(`Invalid query parameter "${name}". Expected a boolean.`);
12723
12699
  }
12724
12700
  }
12725
-
12726
- class ResourceCollection extends JsonResource {
12727
- toArray() {
12728
- const items = this.resource.map((item) => item instanceof JsonResource ? item.toArray() : { ...item });
12729
- const wrap = this.constructor.wrap;
12730
- if (wrap === null) {
12731
- return { data: items };
12732
- }
12733
- return { [wrap]: items };
12701
+ function parseOptionalEnumQueryParam(params, name, allowedValues) {
12702
+ const value = params.get(name);
12703
+ if (value === null || value.trim() === "") {
12704
+ return;
12734
12705
  }
12735
- toResponse() {
12736
- return { ...this.toArray(), ...this.extra };
12706
+ if (!allowedValues.includes(value)) {
12707
+ throw new BadRequestError(`Invalid query parameter "${name}". Expected one of: ${allowedValues.join(", ")}.`);
12737
12708
  }
12709
+ return value;
12738
12710
  }
12739
- function toResourceCollection(items, transformer) {
12740
- return items.map(transformer);
12711
+ function expectObject(value, label = "request body") {
12712
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
12713
+ throw new BadRequestError(`${label} must be a JSON object.`);
12714
+ }
12715
+ return value;
12741
12716
  }
12742
- function toPaginatedResourceCollection(items, meta, transformer) {
12743
- return {
12744
- data: toResourceCollection(items, transformer),
12745
- meta
12746
- };
12717
+ async function parseJsonBody(request, validator) {
12718
+ let payload;
12719
+ try {
12720
+ payload = await request.json();
12721
+ } catch {
12722
+ throw new BadRequestError("Request body must be valid JSON.");
12723
+ }
12724
+ return validator(payload);
12747
12725
  }
12748
- // ../../src/core/http/safeInternalPath.ts
12749
- var NESTED_REDIRECT_KEYS = new Set(["redirect", "next", "return", "returnTo", "return_to"]);
12750
- function looksLikeExternalTarget(value) {
12726
+ function readRequiredString(payload, field, options = {}) {
12727
+ const value = payload[field];
12728
+ if (typeof value !== "string" || value.trim() === "") {
12729
+ throw new BadRequestError(`"${field}" is required and must be a string.`);
12730
+ }
12751
12731
  const trimmed = value.trim();
12752
- if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
12753
- return true;
12732
+ if (options.minLength !== undefined && trimmed.length < options.minLength) {
12733
+ throw new BadRequestError(`"${field}" must be at least ${options.minLength} characters.`);
12754
12734
  }
12755
- if (trimmed.includes("://") || trimmed.includes(":/") || trimmed.includes(":\\")) {
12756
- return true;
12735
+ if (options.maxLength !== undefined && trimmed.length > options.maxLength) {
12736
+ throw new BadRequestError(`"${field}" must be at most ${options.maxLength} characters.`);
12757
12737
  }
12758
- try {
12759
- const decoded = decodeURIComponent(trimmed);
12760
- if (decoded.startsWith("//") || decoded.includes("\\") || /https?:/i.test(decoded)) {
12761
- return true;
12762
- }
12763
- } catch {
12764
- return true;
12738
+ if (options.pattern && !options.pattern.test(trimmed)) {
12739
+ throw new BadRequestError(`"${field}" has an invalid format.`);
12765
12740
  }
12766
- return false;
12741
+ return trimmed;
12767
12742
  }
12768
- function sanitizeNestedQuery(search) {
12769
- if (!search) {
12770
- return "";
12743
+ function readOptionalString(payload, field, options = {}) {
12744
+ if (!(field in payload) || payload[field] === undefined) {
12745
+ return;
12771
12746
  }
12772
- const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
12773
- const next = new URLSearchParams;
12774
- for (const [key, value] of params.entries()) {
12775
- if (NESTED_REDIRECT_KEYS.has(key)) {
12776
- const sanitized = sanitizeInternalPath(value, "/");
12777
- next.set(key, sanitized);
12778
- continue;
12779
- }
12780
- if (looksLikeExternalTarget(value) && value.includes("://")) {
12781
- continue;
12782
- }
12783
- next.set(key, value);
12747
+ return readRequiredString(payload, field, options);
12748
+ }
12749
+ function readRequiredEnum(payload, field, allowedValues) {
12750
+ const value = readRequiredString(payload, field);
12751
+ if (!allowedValues.includes(value)) {
12752
+ throw new BadRequestError(`"${field}" must be one of: ${allowedValues.join(", ")}.`);
12753
+ }
12754
+ return value;
12755
+ }
12756
+ function readOptionalEnum(payload, field, allowedValues) {
12757
+ if (!(field in payload) || payload[field] === undefined) {
12758
+ return;
12784
12759
  }
12785
- const serialized = next.toString();
12786
- return serialized ? `?${serialized}` : "";
12760
+ return readRequiredEnum(payload, field, allowedValues);
12787
12761
  }
12788
- function sanitizeInternalPath(raw, fallback = "/") {
12789
- const trimmed = raw.trim();
12790
- if (looksLikeExternalTarget(trimmed)) {
12791
- return fallback;
12762
+ function readRequiredPositiveInt(payload, field) {
12763
+ const value = payload[field];
12764
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
12765
+ throw new BadRequestError(`"${field}" is required and must be a positive integer.`);
12792
12766
  }
12793
- const queryIndex = trimmed.indexOf("?");
12794
- const hashIndex = trimmed.indexOf("#");
12795
- const end = [queryIndex, hashIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0];
12796
- const pathname = end === undefined ? trimmed : trimmed.slice(0, end);
12797
- if (looksLikeExternalTarget(pathname)) {
12798
- return fallback;
12767
+ return value;
12768
+ }
12769
+ function readOptionalPositiveInt(payload, field) {
12770
+ if (!(field in payload) || payload[field] === undefined) {
12771
+ return;
12799
12772
  }
12800
- const search = queryIndex >= 0 ? trimmed.slice(queryIndex, hashIndex >= 0 ? hashIndex : undefined) : "";
12801
- return `${pathname}${sanitizeNestedQuery(search)}`;
12773
+ return readRequiredPositiveInt(payload, field);
12802
12774
  }
12803
- function safeInternalRedirectPath(request, fallback = "/") {
12804
- const url = new URL(request.url);
12805
- return sanitizeInternalPath(`${url.pathname}${url.search}`, fallback);
12775
+ function parsePositiveIntParam(value, name = "id") {
12776
+ const parsed = Number.parseInt(value, 10);
12777
+ if (!Number.isInteger(parsed) || parsed <= 0) {
12778
+ throw new BadRequestError(`Invalid ${name}. Expected a positive integer.`);
12779
+ }
12780
+ return parsed;
12806
12781
  }
12807
- function loginRedirectLocation(request) {
12808
- return `/login?redirect=${encodeURIComponent(safeInternalRedirectPath(request))}`;
12782
+
12783
+ // ../../src/core/http/formRequest.ts
12784
+ class FormRequest {
12785
+ authorize(_request) {
12786
+ return true;
12787
+ }
12788
+ async validate(request) {
12789
+ if (!await this.authorize(request)) {
12790
+ throw new ForbiddenError;
12791
+ }
12792
+ return await parseJsonBody(request, (payload) => this.parse(payload));
12793
+ }
12809
12794
  }
12810
12795
 
12811
- // ../../src/core/view/htmlResponse.ts
12812
- function withCharset(contentType) {
12813
- return contentType.includes("charset=") ? contentType : `${contentType}; charset=utf-8`;
12796
+ class QueryFormRequest {
12797
+ validate(request) {
12798
+ return this.parseQuery(request);
12799
+ }
12814
12800
  }
12815
- function htmlResponse(html, init = {}) {
12816
- return new Response(html, {
12817
- status: init.status ?? 200,
12818
- statusText: init.statusText,
12819
- headers: {
12820
- "Content-Type": "text/html; charset=utf-8"
12801
+ // ../../src/core/http/authMiddleware.ts
12802
+ function createAuthMiddleware(auth) {
12803
+ return async (request, next) => {
12804
+ const resolved = typeof auth.resolveWithSource === "function" ? await auth.resolveWithSource(request) : { user: await auth.resolve(request), credentialSource: null };
12805
+ return await runWithAuthContext(resolved.user, resolved.credentialSource, async () => {
12806
+ return await next();
12807
+ });
12808
+ };
12809
+ }
12810
+ // ../../src/core/http/authorizeMiddleware.ts
12811
+ function createAuthorizeMiddleware(gate, auth, resource, action) {
12812
+ return async (request, next) => {
12813
+ const user = currentAuthUser() ?? await auth.resolve(request);
12814
+ if (!gate.allows(resource, action, user)) {
12815
+ const error = new ForbiddenError;
12816
+ return errorResponse(error);
12821
12817
  }
12822
- });
12818
+ return await next();
12819
+ };
12823
12820
  }
12824
- function isHtmxRequest(request) {
12825
- return request.headers.get("HX-Request") === "true";
12821
+ // ../../src/core/http/middleware.ts
12822
+ function isRouteHandler(value) {
12823
+ return typeof value === "function";
12826
12824
  }
12827
- function redirectResponse(location, status = 302) {
12828
- if (!location.startsWith("/") || location.startsWith("//")) {
12829
- throw new Error("Redirect location must be an internal path.");
12825
+ function isMethodRouteMap(value) {
12826
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
12827
+ return false;
12830
12828
  }
12831
- return new Response(null, {
12832
- status,
12833
- headers: {
12834
- Location: sanitizeInternalPath(location, "/")
12835
- }
12836
- });
12829
+ const entries = Object.entries(value);
12830
+ return entries.length > 0 && entries.every(([, handler]) => isRouteHandler(handler));
12837
12831
  }
12838
- function textResponse(body, init = {}) {
12839
- return new Response(body, {
12840
- status: init.status ?? 200,
12841
- headers: {
12842
- "Content-Type": "text/plain; charset=utf-8"
12843
- }
12844
- });
12832
+ function composeMiddleware(...middleware) {
12833
+ return (handler) => {
12834
+ return async (request) => {
12835
+ let index = 0;
12836
+ const dispatch = async () => {
12837
+ if (index >= middleware.length) {
12838
+ return await handler(request);
12839
+ }
12840
+ const current = middleware[index];
12841
+ index += 1;
12842
+ if (!current) {
12843
+ return await handler(request);
12844
+ }
12845
+ return await current(request, dispatch);
12846
+ };
12847
+ return await dispatch();
12848
+ };
12849
+ };
12845
12850
  }
12846
- function xmlResponse(body, init = {}) {
12847
- return new Response(body, {
12848
- status: init.status ?? 200,
12849
- headers: {
12850
- "Content-Type": withCharset(init.contentType ?? "application/xml")
12851
- }
12851
+ async function requestIdMiddleware(request, next) {
12852
+ const requestId = request.headers.get("x-request-id") ?? crypto.randomUUID();
12853
+ const response = await next();
12854
+ const headers = new Headers(response.headers);
12855
+ headers.set("x-request-id", requestId);
12856
+ return new Response(response.body, {
12857
+ status: response.status,
12858
+ statusText: response.statusText,
12859
+ headers
12852
12860
  });
12853
12861
  }
12854
- function rssResponse(body, init = {}) {
12855
- return xmlResponse(body, { ...init, contentType: "application/rss+xml" });
12856
- }
12857
-
12858
- // ../../src/core/view/webErrorView.ts
12859
- var configuredErrorView = {};
12860
- function configureWebErrorView(options) {
12861
- configuredErrorView = { ...options };
12862
- }
12863
- function escapeHtml(value) {
12864
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
12862
+ function wrapRouteHandler(handler, middleware) {
12863
+ if (isMethodRouteMap(handler)) {
12864
+ const wrapped = {};
12865
+ for (const [method, routeHandler] of Object.entries(handler)) {
12866
+ wrapped[method] = composeMiddleware(...middleware)(routeHandler);
12867
+ }
12868
+ return wrapped;
12869
+ }
12870
+ if (isRouteHandler(handler)) {
12871
+ return composeMiddleware(...middleware)(handler);
12872
+ }
12873
+ return handler;
12865
12874
  }
12866
- function errorReturnPath(request) {
12867
- if (!request)
12868
- return "/";
12869
- try {
12870
- const current = new URL(request.url);
12871
- const previous = new URL(request.headers.get("referer") ?? "");
12872
- if (previous.origin !== current.origin || (request.method === "GET" || request.method === "HEAD") && previous.pathname === current.pathname)
12873
- return "/";
12874
- return sanitizeInternalPath(previous.pathname, "/");
12875
- } catch {
12876
- return "/";
12875
+ function applyMiddlewareToRoutes(routes, middleware) {
12876
+ const wrapped = {};
12877
+ for (const [path, routeHandler] of Object.entries(routes)) {
12878
+ const identity = async (_request, next) => await runWithRequestMeta({ ...currentRequestMeta(), routeTemplate: path }, next);
12879
+ wrapped[path] = wrapRouteHandler(routeHandler, [identity, ...middleware]);
12877
12880
  }
12881
+ return wrapped;
12878
12882
  }
12879
- function renderKernelErrorChrome(input) {
12880
- const title = escapeHtml(input.title);
12881
- const message = escapeHtml(input.message);
12882
- const errorLines = Object.entries(input.errors ?? {}).flatMap(([field, messages]) => messages.map((item) => `${field}: ${item}`)).map((line) => `<li>${escapeHtml(line)}</li>`).join("");
12883
- const details = errorLines ? `<ul class="error-list">${errorLines}</ul>` : "";
12884
- const goBack = input.status === 422 ? `<p><a href="${escapeHtml(errorReturnPath(input.request ?? currentRequestMeta().request))}">Go back</a></p>` : "";
12885
- return `<!doctype html>
12886
- <html lang="en">
12887
- <head>
12888
- <meta charset="UTF-8" />
12889
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
12890
- <title>${title}</title>
12891
- <link rel="stylesheet" href="/assets/app.css" />
12892
- </head>
12893
- <body>
12894
- <header class="site-header">
12895
- <a class="brand" href="/">Home</a>
12896
- </header>
12897
- <main class="site-main">
12898
- <section class="page-header">
12899
- <h1>${title}</h1>
12900
- <p>${message}</p>
12901
- ${details}
12902
- ${goBack}
12903
- </section>
12904
- </main>
12905
- </body>
12906
- </html>
12907
- `;
12883
+ // ../../src/core/http/pagination.ts
12884
+ var DEFAULT_PER_PAGE = 15;
12885
+ var MAX_PER_PAGE = 100;
12886
+ function parseRequiredPositiveIntQueryParam(params, name) {
12887
+ const value = params.get(name);
12888
+ if (value === null || value.trim() === "") {
12889
+ throw new BadRequestError(`Invalid query parameter "${name}". Expected a positive integer.`);
12890
+ }
12891
+ const parsed = Number.parseInt(value, 10);
12892
+ if (!Number.isInteger(parsed) || parsed <= 0) {
12893
+ throw new BadRequestError(`Invalid query parameter "${name}". Expected a positive integer.`);
12894
+ }
12895
+ return parsed;
12908
12896
  }
12909
- function errorTemplateName(status) {
12910
- if (status === 404) {
12911
- return "errors/not-found";
12897
+ function parsePaginationQuery(request) {
12898
+ const params = getQueryParams(request);
12899
+ const pageParam = params.get("page");
12900
+ const perPageParam = params.get("per_page");
12901
+ const page = pageParam === null || pageParam.trim() === "" ? 1 : parseRequiredPositiveIntQueryParam(params, "page");
12902
+ if (perPageParam === null || perPageParam.trim() === "") {
12903
+ return { page, perPage: DEFAULT_PER_PAGE };
12912
12904
  }
12913
- if (status === 403) {
12914
- return "errors/forbidden";
12905
+ const perPage = parseRequiredPositiveIntQueryParam(params, "per_page");
12906
+ if (perPage > MAX_PER_PAGE) {
12907
+ throw new BadRequestError(`Invalid query parameter "per_page". Maximum allowed value is ${MAX_PER_PAGE}.`);
12915
12908
  }
12916
- return "errors/error";
12909
+ return { page, perPage };
12917
12910
  }
12918
- async function renderWebErrorHtml(input) {
12919
- const render = configuredErrorView.render;
12920
- if (!render) {
12921
- return renderKernelErrorChrome(input);
12911
+ function paginatedResponse(data, meta, init = {}) {
12912
+ return Response.json({ data, meta }, init);
12913
+ }
12914
+ // ../../src/core/http/uploads.ts
12915
+ var DEFAULT_MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
12916
+ var ALLOWED_UPLOAD_MIME_TYPES = new Set([
12917
+ "application/pdf",
12918
+ "application/json",
12919
+ "application/zip",
12920
+ "application/x-zip-compressed",
12921
+ "image/jpeg",
12922
+ "image/png",
12923
+ "image/gif",
12924
+ "image/webp",
12925
+ "text/plain",
12926
+ "text/csv"
12927
+ ]);
12928
+ function resolveMaxUploadBytes() {
12929
+ const raw = process.env.MAX_UPLOAD_BYTES?.trim() ?? process.env.MAX_REQUEST_BODY_BYTES?.trim();
12930
+ if (!raw) {
12931
+ return DEFAULT_MAX_UPLOAD_BYTES;
12922
12932
  }
12923
- try {
12924
- return await render({
12925
- ...input,
12926
- request: input.request ?? currentRequestMeta().request
12927
- });
12928
- } catch {
12929
- return renderKernelErrorChrome(input);
12933
+ const parsed = Number.parseInt(raw, 10);
12934
+ if (!Number.isInteger(parsed) || parsed <= 0) {
12935
+ return DEFAULT_MAX_UPLOAD_BYTES;
12930
12936
  }
12937
+ return parsed;
12931
12938
  }
12932
- async function htmlErrorResponse(input) {
12933
- const response = htmlResponse(await renderWebErrorHtml(input), { status: input.status });
12934
- response.headers.set("Cache-Control", "no-store");
12935
- return response;
12939
+ function normalizeMimeType(mimeType) {
12940
+ return mimeType.split(";")[0]?.trim().toLowerCase() ?? "";
12936
12941
  }
12937
- async function notFoundHtmlResponse(body) {
12938
- if (body !== undefined) {
12939
- const response = htmlResponse(body, { status: 404 });
12940
- response.headers.set("Cache-Control", "no-store");
12941
- return response;
12942
+ function isAllowedMimeType(mimeType) {
12943
+ const normalized = normalizeMimeType(mimeType);
12944
+ if (!normalized || normalized === "application/octet-stream") {
12945
+ return envFlagEnabled(process.env.UPLOAD_ALLOW_UNKNOWN_MIME);
12942
12946
  }
12943
- return htmlErrorResponse({
12944
- status: 404,
12945
- title: "Not Found",
12946
- message: "The page you requested was not found."
12947
- });
12947
+ return ALLOWED_UPLOAD_MIME_TYPES.has(normalized);
12948
12948
  }
12949
12949
 
12950
- // ../../src/core/http/contentNegotiation.ts
12951
- function requestPrefersJson(request) {
12952
- if (!request) {
12953
- return true;
12954
- }
12955
- if (request.headers.get("HX-Request") === "true") {
12956
- return false;
12950
+ // ../../src/core/http/parseMultipartUpload.ts
12951
+ function normalizeMimeType2(mimeType) {
12952
+ return mimeType.split(";")[0]?.trim().toLowerCase() || "application/octet-stream";
12953
+ }
12954
+ function sanitizeUploadFileName(name) {
12955
+ const base = name.split(/[/\\]/).pop()?.trim() ?? "upload";
12956
+ const sanitized = base.replace(/[^\w.\-()+ ]+/g, "_").slice(0, 200);
12957
+ return sanitized.length > 0 ? sanitized : "upload";
12958
+ }
12959
+ async function validateUploadFile(file, fieldName = "file") {
12960
+ if (!(file instanceof File)) {
12961
+ throw new BadRequestError(`Missing upload field "${fieldName}".`);
12957
12962
  }
12958
- const pathname = new URL(request.url).pathname;
12959
- if (pathname.startsWith("/api/")) {
12960
- return true;
12963
+ if (file.size <= 0) {
12964
+ throw new BadRequestError("Uploaded file is empty.");
12961
12965
  }
12962
- const accept = request.headers.get("accept")?.toLowerCase() ?? "";
12963
- if (accept.includes("text/html")) {
12964
- return false;
12966
+ const maxBytes = resolveMaxUploadBytes();
12967
+ if (file.size > maxBytes) {
12968
+ throw new PayloadTooLargeError(`Upload exceeds the ${maxBytes} byte limit.`);
12965
12969
  }
12966
- if (accept.includes("application/json")) {
12967
- return true;
12970
+ const mimeType = normalizeMimeType2(file.type.trim() || "application/octet-stream");
12971
+ if (!isAllowedMimeType(mimeType)) {
12972
+ throw new BadRequestError(`File type "${mimeType}" is not allowed.`);
12968
12973
  }
12974
+ return {
12975
+ fileName: sanitizeUploadFileName(file.name),
12976
+ mimeType,
12977
+ size: file.size,
12978
+ contents: new Uint8Array(await file.arrayBuffer())
12979
+ };
12980
+ }
12981
+ async function parseMultipartUpload(request, fieldName = "file") {
12969
12982
  const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
12970
- if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
12971
- return false;
12983
+ if (!contentType.includes("multipart/form-data")) {
12984
+ throw new BadRequestError("Expected multipart form data.");
12972
12985
  }
12973
- return false;
12974
- }
12975
-
12976
- // ../../src/core/http/webErrorResponse.ts
12977
- function normalizeFieldErrors(details) {
12978
- if (!details || typeof details !== "object" || Array.isArray(details)) {
12979
- return {};
12986
+ const formData = await request.formData();
12987
+ const value = formData.get(fieldName);
12988
+ if (!(value instanceof File)) {
12989
+ throw new BadRequestError(`Missing upload field "${fieldName}".`);
12980
12990
  }
12981
- const errors = {};
12982
- for (const [field, messages] of Object.entries(details)) {
12983
- if (Array.isArray(messages)) {
12984
- errors[field] = messages.map(String);
12985
- continue;
12986
- }
12987
- if (typeof messages === "string") {
12988
- errors[field] = [messages];
12991
+ return validateUploadFile(value, fieldName);
12992
+ }
12993
+ // ../../src/core/http/requireAuthMiddleware.ts
12994
+ function createRequireAuthMiddleware(auth) {
12995
+ return async (request, next) => {
12996
+ if (!await auth.check(request)) {
12997
+ const error = new UnauthorizedError;
12998
+ return errorResponse(error);
12989
12999
  }
13000
+ return await next();
13001
+ };
13002
+ }
13003
+ // ../../src/core/http/resources.ts
13004
+ function serializeDate(value) {
13005
+ return value instanceof Date ? value.toISOString() : value;
13006
+ }
13007
+ function whenLoaded(model, relation, transform) {
13008
+ const value = model.loaded(relation);
13009
+ if (value === undefined) {
13010
+ return;
12990
13011
  }
12991
- return errors;
13012
+ if (value === null) {
13013
+ return null;
13014
+ }
13015
+ return transform ? transform(value) : value;
12992
13016
  }
12993
- function errorPageTitle(status, message) {
12994
- if (status === 404) {
12995
- return "Not Found";
13017
+
13018
+ class JsonResource {
13019
+ resource;
13020
+ static wrap = "data";
13021
+ extra = {};
13022
+ constructor(resource) {
13023
+ this.resource = resource;
12996
13024
  }
12997
- if (status === 403) {
12998
- return "Forbidden";
13025
+ static make(resource) {
13026
+ return new JsonResource(resource);
12999
13027
  }
13000
- if (status >= 500) {
13001
- return "Server Error";
13028
+ static collection(items) {
13029
+ return new ResourceCollection(items);
13002
13030
  }
13003
- return message;
13004
- }
13005
- function publicErrorMessage(status, message) {
13006
- if (status >= 500 && isProductionEnv()) {
13007
- return "Something went wrong.";
13031
+ additional(data) {
13032
+ this.extra = { ...this.extra, ...data };
13033
+ return this;
13008
13034
  }
13009
- return message;
13010
- }
13011
- function logServerError(error, mappedError) {
13012
- if (mappedError.status < 500) {
13013
- return;
13035
+ when(condition, value) {
13036
+ return condition ? value : undefined;
13014
13037
  }
13015
- appLogger.error("Unhandled request error", {
13016
- status: mappedError.status,
13017
- error: error instanceof Error ? error.message : String(error),
13018
- stack: error instanceof Error ? error.stack : undefined
13019
- });
13020
- }
13021
- async function webErrorResponse(error, request) {
13022
- if (currentRequestMeta().errorFormat === "json" || !request || !isViewsEnabled() || requestPrefersJson(request)) {
13023
- return null;
13038
+ whenLoaded(relation, transform) {
13039
+ const model = this.resource;
13040
+ if (this.resource == null || typeof model.loaded !== "function") {
13041
+ return;
13042
+ }
13043
+ return whenLoaded(model, relation, transform);
13024
13044
  }
13025
- const mappedError = toHttpError(error) ?? mapDatabaseError(error);
13026
- logServerError(error, mappedError);
13027
- if (mappedError.status === 401) {
13028
- return Response.redirect(loginRedirectLocation(request), 302);
13045
+ toArray() {
13046
+ if (this.resource && typeof this.resource === "object" && "toArray" in this.resource) {
13047
+ return this.resource.toArray();
13048
+ }
13049
+ return { ...this.resource };
13050
+ }
13051
+ toResponse() {
13052
+ const wrap = this.constructor.wrap;
13053
+ const payload = this.toArray();
13054
+ if (wrap === null) {
13055
+ return { ...payload, ...this.extra };
13056
+ }
13057
+ return { [wrap]: payload, ...this.extra };
13029
13058
  }
13030
- const errors = mappedError instanceof ValidationError || mappedError.name === "ValidationError" ? normalizeFieldErrors(mappedError.details) : undefined;
13031
- return htmlErrorResponse({
13032
- status: mappedError.status,
13033
- title: errorPageTitle(mappedError.status, mappedError.message),
13034
- message: publicErrorMessage(mappedError.status, mappedError.message),
13035
- errors,
13036
- request
13037
- });
13038
13059
  }
13039
13060
 
13040
- // ../../src/core/http/response.ts
13041
- init_transactionControl();
13042
- function jsonResponse2(data, init = {}) {
13043
- return Response.json(data, {
13044
- status: init.status ?? 200,
13045
- headers: init.headers
13046
- });
13047
- }
13048
- function createdResponse(data, init = {}) {
13049
- return jsonResponse2(data, { ...init, status: init.status ?? 201 });
13050
- }
13051
- function noContentResponse() {
13052
- return new Response(null, { status: 204 });
13061
+ class ResourceCollection extends JsonResource {
13062
+ toArray() {
13063
+ const items = this.resource.map((item) => item instanceof JsonResource ? item.toArray() : { ...item });
13064
+ const wrap = this.constructor.wrap;
13065
+ if (wrap === null) {
13066
+ return { data: items };
13067
+ }
13068
+ return { [wrap]: items };
13069
+ }
13070
+ toResponse() {
13071
+ return { ...this.toArray(), ...this.extra };
13072
+ }
13053
13073
  }
13054
- function errorResponse(error) {
13055
- const mappedError = toHttpError(error) ?? mapDatabaseError(error);
13056
- logServerError(error, mappedError);
13057
- return Response.json({
13058
- error: mappedError.message,
13059
- ...mappedError.details === undefined ? {} : { details: mappedError.details }
13060
- }, { status: mappedError.status, headers: { "Cache-Control": "no-store" } });
13074
+ function toResourceCollection(items, transformer) {
13075
+ return items.map(transformer);
13061
13076
  }
13062
- function withErrorHandling(handler) {
13063
- return async (...args) => {
13064
- try {
13065
- return await handler(...args);
13066
- } catch (error) {
13067
- requestTransactionRollback();
13068
- const request = args.find((arg) => arg instanceof Request);
13069
- const webResponse = await webErrorResponse(error, request);
13070
- if (webResponse) {
13071
- return webResponse;
13072
- }
13073
- return errorResponse(error);
13074
- }
13077
+ function toPaginatedResourceCollection(items, meta, transformer) {
13078
+ return {
13079
+ data: toResourceCollection(items, transformer),
13080
+ meta
13075
13081
  };
13076
13082
  }
13077
13083
  // ../../src/core/http/route.ts
@@ -13691,7 +13697,7 @@ function createRequireAbilityMiddleware(abilityChecker) {
13691
13697
  if (isViewsEnabled() && !requestPrefersJson(request)) {
13692
13698
  throw error;
13693
13699
  }
13694
- return Response.json({ error: error.message }, { status: error.status });
13700
+ return errorResponse(new HttpError(error.status, error.message));
13695
13701
  }
13696
13702
  throw error;
13697
13703
  }
@@ -13724,7 +13730,7 @@ function createRequireGlobalAdminMiddleware() {
13724
13730
  path: new URL(_request.url).pathname
13725
13731
  });
13726
13732
  const error = new ForbiddenError("Platform admin access required.");
13727
- return Response.json({ error: error.message }, { status: error.status });
13733
+ return errorResponse(error);
13728
13734
  }
13729
13735
  return await next();
13730
13736
  };
@@ -16977,16 +16983,32 @@ class ViewRenderer {
16977
16983
  this.engine = engine;
16978
16984
  this.defaultLayout = defaultLayout;
16979
16985
  }
16980
- namespace(prefix) {
16986
+ namespace(prefix, defaults = {}) {
16981
16987
  validateRelativeViewName(prefix);
16988
+ const defaultHeaders = new Headers(defaults.headers);
16989
+ const responseHeaders = (headers) => {
16990
+ const merged = new Headers(defaultHeaders);
16991
+ const overrides = new Headers(headers);
16992
+ for (const [name, value] of overrides) {
16993
+ if (name !== "set-cookie")
16994
+ merged.set(name, value);
16995
+ }
16996
+ if (overrides.has("set-cookie")) {
16997
+ merged.delete("set-cookie");
16998
+ for (const cookie of overrides.getSetCookie())
16999
+ merged.append("set-cookie", cookie);
17000
+ }
17001
+ return merged;
17002
+ };
16982
17003
  const qualify = (name) => {
16983
17004
  validateRelativeViewName(name);
16984
17005
  return `${prefix}/${name}`;
16985
17006
  };
16986
17007
  return {
16987
- render: async (name, data, options) => this.render(qualify(name), data, options),
17008
+ render: async (name, data, options = {}) => this.render(qualify(name), data, { ...options, headers: responseHeaders(options.headers) }),
16988
17009
  renderHtmx: async (name, data, options = {}) => this.renderHtmx(qualify(name), data, {
16989
17010
  ...options,
17011
+ headers: responseHeaders(options.headers),
16990
17012
  ...options.fragment === undefined ? {} : { fragment: qualify(options.fragment) }
16991
17013
  })
16992
17014
  };
@@ -17374,7 +17396,7 @@ export {
17374
17396
  isRlsTenancy,
17375
17397
  isTenancyEnabled,
17376
17398
  jobRegistry,
17377
- jsonResponse2 as jsonResponse,
17399
+ jsonResponse,
17378
17400
  jwtTtlSeconds,
17379
17401
  loadMigrationsFromDirectory,
17380
17402
  loadSeedersFromDirectory,
@@ -17415,6 +17437,7 @@ export {
17415
17437
  pinUrlToAddress,
17416
17438
  pivotTableName,
17417
17439
  policyGate,
17440
+ privateJsonResponse,
17418
17441
  projectPluck,
17419
17442
  prometheusRegistry,
17420
17443
  qualifyColumn,