diff --git a/bindings/csharp/FusionFramework/BuiltinMiddleware.cs b/bindings/csharp/FusionFramework/BuiltinMiddleware.cs
new file mode 100644
index 0000000..61644ad
--- /dev/null
+++ b/bindings/csharp/FusionFramework/BuiltinMiddleware.cs
@@ -0,0 +1,11 @@
+namespace FusionFramework;
+
+/// Convenience aliases for built-in middleware factories used in scaffolded apps.
+public static class BuiltinMiddleware
+{
+ public static FusionMiddleware FrameworkHeaders() => Middleware.FrameworkHeaders();
+ public static FusionMiddleware SecurityHeaders() => Middleware.SecurityHeaders();
+ public static FusionMiddleware Cors() => Middleware.Cors();
+ public static FusionMiddleware CacheHeaders() => Middleware.CacheHeaders();
+ public static FusionMiddleware RequestId() => Middleware.RequestId();
+}
diff --git a/bindings/csharp/FusionFramework/FusionApp.cs b/bindings/csharp/FusionFramework/FusionApp.cs
index 689d747..f5cb1d0 100644
--- a/bindings/csharp/FusionFramework/FusionApp.cs
+++ b/bindings/csharp/FusionFramework/FusionApp.cs
@@ -20,8 +20,6 @@ public FusionApp(FusionSettings? settings = null)
settings ??= SettingsStore.Current;
Native.fusion_app_set_settings(_app, settings.Handle);
- // Default: advertise Fusion to clients / Wappalyzer-style detectors.
- _middleware.Add(Middleware.FrameworkHeaders());
}
public FusionApp Use(FusionMiddleware middleware)
diff --git a/bindings/csharp/FusionFramework/Middleware.cs b/bindings/csharp/FusionFramework/Middleware.cs
index ad1c598..30ce04b 100644
--- a/bindings/csharp/FusionFramework/Middleware.cs
+++ b/bindings/csharp/FusionFramework/Middleware.cs
@@ -186,7 +186,125 @@ public static FusionMiddleware RequireRoles(
public static FusionMiddleware RequireRoles(params string[] roles) =>
RequireRoles((IEnumerable)roles);
- /// Default identity middleware — advertises Fusion on every response.
+ /// Common security response headers.
+ public static FusionMiddleware SecurityHeaders(
+ string contentTypeOptions = "nosniff",
+ string frameOptions = "DENY",
+ string referrerPolicy = "strict-origin-when-cross-origin",
+ string permissionsPolicy = "camera=(), microphone=(), geolocation=(), payment=()",
+ string coop = "same-origin",
+ string corp = "same-origin",
+ string? csp = null,
+ string? hsts = null)
+ {
+ var extra = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["X-Content-Type-Options"] = contentTypeOptions,
+ ["X-Frame-Options"] = frameOptions,
+ ["Referrer-Policy"] = referrerPolicy,
+ ["Permissions-Policy"] = permissionsPolicy,
+ ["Cross-Origin-Opener-Policy"] = coop,
+ ["Cross-Origin-Resource-Policy"] = corp,
+ };
+ if (!string.IsNullOrEmpty(csp)) extra["Content-Security-Policy"] = csp!;
+ if (!string.IsNullOrEmpty(hsts)) extra["Strict-Transport-Security"] = hsts!;
+
+ return (request, callNext) =>
+ {
+ var result = ResolveAwaitable(callNext(request));
+ return MergeResponseHeaders(result, extra);
+ };
+ }
+
+ /// Set Cache-Control on responses.
+ public static FusionMiddleware CacheHeaders(string defaultValue = "no-store") =>
+ (request, callNext) =>
+ {
+ var result = ResolveAwaitable(callNext(request));
+ return MergeResponseHeaders(result, new Dictionary
+ {
+ ["Cache-Control"] = defaultValue,
+ });
+ };
+
+ /// Echo or generate X-Request-Id on each request.
+ public static FusionMiddleware RequestId(string header = "X-Request-Id", bool incoming = true) =>
+ (request, callNext) =>
+ {
+ string? rid = incoming ? GetHeader(request, header) : null;
+ if (string.IsNullOrEmpty(rid))
+ rid = Guid.NewGuid().ToString();
+ EnsureState(request)["request_id"] = rid;
+ var result = ResolveAwaitable(callNext(request));
+ return MergeResponseHeaders(result, new Dictionary { [header] = rid });
+ };
+
+ /// CORS middleware; answers OPTIONS preflight with 204.
+ public static FusionMiddleware Cors(
+ IEnumerable? allowOrigins = null,
+ IEnumerable? allowMethods = null,
+ IEnumerable? allowHeaders = null,
+ IEnumerable? exposeHeaders = null,
+ bool allowCredentials = false,
+ int maxAge = 600)
+ {
+ var origins = (allowOrigins ?? new[] { "*" }).Select(o => o.ToString()).ToList();
+ var methods = (allowMethods ?? new[]
+ {
+ "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD",
+ }).Select(m => m.ToUpperInvariant()).ToList();
+ var headers = (allowHeaders ?? new[]
+ {
+ "Authorization", "Content-Type", "Accept", "Origin", "X-Request-Id",
+ }).ToList();
+ var expose = (exposeHeaders ?? new[] { "X-Request-Id" }).ToList();
+ var allowAll = origins.Contains("*");
+
+ Dictionary CorsHeaders(string? origin)
+ {
+ var chosen = "*";
+ if (!allowAll)
+ {
+ if (!string.IsNullOrEmpty(origin) && origins.Contains(origin))
+ chosen = origin;
+ else if (origins.Count > 0)
+ chosen = origins[0];
+ }
+
+ var map = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["Access-Control-Allow-Origin"] = chosen,
+ ["Access-Control-Allow-Methods"] = string.Join(", ", methods),
+ ["Access-Control-Allow-Headers"] = string.Join(", ", headers),
+ ["Access-Control-Expose-Headers"] = string.Join(", ", expose),
+ ["Access-Control-Max-Age"] = maxAge.ToString(),
+ ["Vary"] = "Origin",
+ };
+ if (allowCredentials && chosen != "*")
+ map["Access-Control-Allow-Credentials"] = "true";
+ return map;
+ }
+
+ return (request, callNext) =>
+ {
+ var origin = GetHeader(request, "Origin");
+ var extra = CorsHeaders(origin);
+ if (string.Equals(request.Method, "OPTIONS", StringComparison.OrdinalIgnoreCase))
+ {
+ return new Dictionary
+ {
+ ["status"] = 204,
+ ["body"] = "",
+ ["headers"] = extra,
+ };
+ }
+
+ var result = ResolveAwaitable(callNext(request));
+ return MergeResponseHeaders(result, extra);
+ };
+ }
+
+ /// Optional identity middleware — not enabled by default. Add via app.Use(Middleware.FrameworkHeaders()).
public static FusionMiddleware FrameworkHeaders()
{
var extra = Header.Fingerprint();
@@ -228,6 +346,18 @@ static object Error(int status, string detail) =>
["body"] = new Dictionary { ["detail"] = detail },
};
+ static string? GetHeader(FusionRequest request, string name)
+ {
+ if (request.Headers.TryGetValue(name, out var direct) && !string.IsNullOrEmpty(direct))
+ return direct;
+ foreach (var kv in request.Headers)
+ {
+ if (string.Equals(kv.Key, name, StringComparison.OrdinalIgnoreCase))
+ return kv.Value;
+ }
+ return null;
+ }
+
static byte[] Base64UrlDecode(string input)
{
var s = input.Replace('-', '+').Replace('_', '/');
diff --git a/crates/fusion-core/src/lib.rs b/crates/fusion-core/src/lib.rs
index f1ae6a6..29775a5 100644
--- a/crates/fusion-core/src/lib.rs
+++ b/crates/fusion-core/src/lib.rs
@@ -91,7 +91,7 @@ impl App {
let fingerprint = self
.settings
.get_bool("fingerprint.enabled")
- .unwrap_or(true);
+ .unwrap_or(false);
if fingerprint {
server::listen(self.router, addr).await
} else {
diff --git a/crates/fusion-core/src/server.rs b/crates/fusion-core/src/server.rs
index bc0198a..ced7f0c 100644
--- a/crates/fusion-core/src/server.rs
+++ b/crates/fusion-core/src/server.rs
@@ -24,13 +24,13 @@ use crate::router::Router;
/// Options for [`listen_with`].
#[derive(Debug, Clone)]
pub struct ListenOptions {
- /// Add Fusion identity headers (`X-Powered-By`, …) on every response. Default: true.
+ /// Add Fusion identity headers (`X-Powered-By`, …) on every response. Default: false.
pub fingerprint: bool,
}
impl Default for ListenOptions {
fn default() -> Self {
- Self { fingerprint: true }
+ Self { fingerprint: false }
}
}
diff --git a/crates/fusion-core/src/templates.rs b/crates/fusion-core/src/templates.rs
index e454ef2..2485223 100644
--- a/crates/fusion-core/src/templates.rs
+++ b/crates/fusion-core/src/templates.rs
@@ -85,7 +85,7 @@ fn collect_templates(
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
- if ext != "html" && ext != "tera" {
+ if ext != "html" && ext != "tera" && ext != "css" {
continue;
}
let rel = path
@@ -144,4 +144,21 @@ mod tests {
assert!(html.contains("Go"));
let _ = std::fs::remove_dir_all(&dir);
}
+
+ #[test]
+ fn includes_css_partial() {
+ clear_template_cache();
+ let dir = std::env::temp_dir().join("fusion_tpl_css_test");
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(dir.join("home")).unwrap();
+ std::fs::write(dir.join("home/style.css"), "body { color: red; }").unwrap();
+ std::fs::write(
+ dir.join("home/index.html"),
+ r#""#,
+ )
+ .unwrap();
+ let html = render_template("home/index.html", &json!({}), &dir).unwrap();
+ assert!(html.contains("color: red"));
+ let _ = std::fs::remove_dir_all(&dir);
+ }
}
diff --git a/crates/fusion-node/README.md b/crates/fusion-node/README.md
index 00fba04..f1ca53b 100644
--- a/crates/fusion-node/README.md
+++ b/crates/fusion-node/README.md
@@ -37,7 +37,7 @@ export const ItemModule = route('/api/[module]/{id}')(
},
)
-const MIDDLEWARE = [] // your middleware; Fusion already adds frameworkHeaders() by default
+const MIDDLEWARE = [] // add middleware explicitly, e.g. frameworkHeaders()
settings.ensureLoaded()
const app = new FusionApp(getSettings())
diff --git a/crates/fusion-node/index.d.ts b/crates/fusion-node/index.d.ts
index 86e0a30..6840944 100644
--- a/crates/fusion-node/index.d.ts
+++ b/crates/fusion-node/index.d.ts
@@ -163,6 +163,33 @@ export type FusionMiddleware = (
export function frameworkHeaders(): FusionMiddleware
+export function securityHeaders(options?: {
+ contentTypeOptions?: string
+ frameOptions?: string
+ referrerPolicy?: string
+ permissionsPolicy?: string
+ coop?: string
+ corp?: string
+ csp?: string
+ hsts?: string
+}): FusionMiddleware
+
+export function cors(options?: {
+ allowOrigins?: string | string[]
+ allowMethods?: string[]
+ allowHeaders?: string[]
+ exposeHeaders?: string[]
+ allowCredentials?: boolean
+ maxAge?: number
+}): FusionMiddleware
+
+export function cacheHeaders(options?: { default?: string; value?: string }): FusionMiddleware
+
+export function requestId(options?: {
+ header?: string
+ incoming?: boolean
+}): FusionMiddleware
+
export type FusionResponse =
| string
| {
diff --git a/crates/fusion-node/index.js b/crates/fusion-node/index.js
index 3bc0512..5d7e6d0 100644
--- a/crates/fusion-node/index.js
+++ b/crates/fusion-node/index.js
@@ -168,6 +168,105 @@ function frameworkHeaders() {
}
}
+function getHeader(request, name) {
+ const headers = request.headers || {}
+ const target = String(name).toLowerCase()
+ for (const [key, value] of Object.entries(headers)) {
+ if (String(key).toLowerCase() === target) return String(value)
+ }
+ return null
+}
+
+function headerMiddleware(extra) {
+ return async (request, callNext) => {
+ const result = await awaitMaybe(callNext(request))
+ return mergeResponseHeaders(result, extra)
+ }
+}
+
+function securityHeaders(options = {}) {
+ const extra = {
+ 'X-Content-Type-Options': options.contentTypeOptions ?? 'nosniff',
+ 'X-Frame-Options': options.frameOptions ?? 'DENY',
+ 'Referrer-Policy': options.referrerPolicy ?? 'strict-origin-when-cross-origin',
+ 'Permissions-Policy':
+ options.permissionsPolicy ?? 'camera=(), microphone=(), geolocation=(), payment=()',
+ 'Cross-Origin-Opener-Policy': options.coop ?? 'same-origin',
+ 'Cross-Origin-Resource-Policy': options.corp ?? 'same-origin',
+ }
+ if (options.csp) extra['Content-Security-Policy'] = String(options.csp)
+ if (options.hsts) extra['Strict-Transport-Security'] = String(options.hsts)
+ return headerMiddleware(extra)
+}
+
+function cacheHeaders(options = {}) {
+ return headerMiddleware({
+ 'Cache-Control': options.default ?? options.value ?? 'no-store',
+ })
+}
+
+function requestId(options = {}) {
+ const headerName = options.header ?? 'X-Request-Id'
+ const incoming = options.incoming !== false
+ return async (request, callNext) => {
+ const state = ensureState(request)
+ let rid = incoming ? getHeader(request, headerName) : null
+ if (!rid) {
+ rid =
+ typeof crypto !== 'undefined' && crypto.randomUUID
+ ? crypto.randomUUID()
+ : `${Date.now()}-${Math.random().toString(16).slice(2)}`
+ }
+ state.request_id = rid
+ const result = await awaitMaybe(callNext(request))
+ return mergeResponseHeaders(result, { [headerName]: rid })
+ }
+}
+
+function cors(options = {}) {
+ const origins = Array.isArray(options.allowOrigins)
+ ? options.allowOrigins.map(String)
+ : [String(options.allowOrigins ?? '*')]
+ const methods = (
+ options.allowMethods ?? ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD']
+ ).map((m) => String(m).toUpperCase())
+ const allowHeaders = (
+ options.allowHeaders ?? ['Authorization', 'Content-Type', 'Accept', 'Origin', 'X-Request-Id']
+ ).map(String)
+ const exposeHeaders = (options.exposeHeaders ?? ['X-Request-Id']).map(String)
+ const allowCredentials = !!options.allowCredentials
+ const maxAge = Number(options.maxAge ?? 600)
+ const allowAll = origins.includes('*')
+
+ function corsHeaders(origin) {
+ let chosen = '*'
+ if (!allowAll) {
+ if (origin && origins.includes(origin)) chosen = origin
+ else if (origins.length) chosen = origins[0]
+ }
+ const out = {
+ 'Access-Control-Allow-Origin': chosen,
+ 'Access-Control-Allow-Methods': methods.join(', '),
+ 'Access-Control-Allow-Headers': allowHeaders.join(', '),
+ 'Access-Control-Expose-Headers': exposeHeaders.join(', '),
+ 'Access-Control-Max-Age': String(maxAge),
+ Vary: 'Origin',
+ }
+ if (allowCredentials && chosen !== '*') out['Access-Control-Allow-Credentials'] = 'true'
+ return out
+ }
+
+ return async (request, callNext) => {
+ const origin = getHeader(request, 'Origin')
+ const extra = corsHeaders(origin)
+ if (String(request.method || 'GET').toUpperCase() === 'OPTIONS') {
+ return { status: 204, body: '', headers: extra }
+ }
+ const result = await awaitMaybe(callNext(request))
+ return mergeResponseHeaders(result, extra)
+ }
+}
+
class FusionBaseApi {
constructor(request) {
this.request = request && typeof request === 'object' ? request : emptyRequest()
@@ -1014,8 +1113,7 @@ class FusionApp {
this.settings = getSettings()
this.engine = new NativeApp()
this.mounted = false
- // Default: advertise Fusion to clients / Wappalyzer-style detectors.
- this._middleware = [frameworkHeaders()]
+ this._middleware = []
}
use(middleware) {
@@ -1162,6 +1260,10 @@ module.exports = {
bearerJwt,
requireRoles,
frameworkHeaders,
+ securityHeaders,
+ cors,
+ cacheHeaders,
+ requestId,
runMiddlewareChain,
coerceParam,
parsePagination,
diff --git a/crates/fusion-node/package-lock.json b/crates/fusion-node/package-lock.json
new file mode 100644
index 0000000..322844d
--- /dev/null
+++ b/crates/fusion-node/package-lock.json
@@ -0,0 +1,40 @@
+{
+ "name": "fusion-framework",
+ "version": "1.2.6",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "fusion-framework",
+ "version": "1.2.6",
+ "license": "BSD-3-Clause",
+ "os": [
+ "linux",
+ "darwin",
+ "win32"
+ ],
+ "devDependencies": {
+ "@napi-rs/cli": "^2.18.4"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@napi-rs/cli": {
+ "version": "2.18.4",
+ "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz",
+ "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==",
+ "dev": true,
+ "bin": {
+ "napi": "scripts/index.js"
+ },
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ }
+ }
+}
diff --git a/crates/fusion-py/README.md b/crates/fusion-py/README.md
index dfcac83..ec8e9ea 100644
--- a/crates/fusion-py/README.md
+++ b/crates/fusion-py/README.md
@@ -30,7 +30,7 @@ class ItemModule(FusionBaseApi):
return self.response({"id": id}, status=status.HTTP_SUCCESS)
-MIDDLEWARE: list = [] # your middleware; Fusion already adds framework_headers() by default
+MIDDLEWARE: list = [] # add middleware explicitly, e.g. framework_headers()
def main() -> None:
diff --git a/crates/fusion-py/python/fusion_framework/__init__.py b/crates/fusion-py/python/fusion_framework/__init__.py
index a6d1572..6f807f2 100644
--- a/crates/fusion-py/python/fusion_framework/__init__.py
+++ b/crates/fusion-py/python/fusion_framework/__init__.py
@@ -2,7 +2,16 @@
from fusion_framework.config import settings
from fusion_framework.http import HTTPException
-from fusion_framework.middleware import bearer_jwt, framework_headers, require_roles, use
+from fusion_framework.middleware import (
+ bearer_jwt,
+ cache_headers,
+ cors,
+ framework_headers,
+ request_id,
+ require_roles,
+ security_headers,
+ use,
+)
from fusion_framework.pagination import PaginationParams, paginated_body, parse_pagination
from fusion_framework.template import FusionBaseTemplate, render_template
from . import header, status
@@ -13,8 +22,12 @@
"header",
"HTTPException",
"bearer_jwt",
+ "cache_headers",
+ "cors",
"framework_headers",
+ "request_id",
"require_roles",
+ "security_headers",
"use",
"PaginationParams",
"parse_pagination",
diff --git a/crates/fusion-py/python/fusion_framework/app.py b/crates/fusion-py/python/fusion_framework/app.py
index b649ed7..0196e10 100644
--- a/crates/fusion-py/python/fusion_framework/app.py
+++ b/crates/fusion-py/python/fusion_framework/app.py
@@ -11,7 +11,7 @@
has_unversioned_routes as _has_unversioned_routes,
)
from fusion_framework.config import get_settings, load_settings_module, settings as settings_store
-from fusion_framework.middleware import framework_headers, set_active_global
+from fusion_framework.middleware import set_active_global
def _as_dict(value: Any) -> dict:
@@ -365,8 +365,7 @@ def __init__(self, app_settings=None):
self.settings = app_settings or get_settings()
self._engine = App()
self._mounted = False
- # Default: advertise Fusion to clients / Wappalyzer-style detectors.
- self._middleware: list = [framework_headers()]
+ self._middleware: list = []
def use(self, middleware) -> None:
"""Register global middleware: ``(request, call_next) -> response``."""
diff --git a/crates/fusion-py/python/fusion_framework/middleware.py b/crates/fusion-py/python/fusion_framework/middleware.py
index 88151bd..810d17a 100644
--- a/crates/fusion-py/python/fusion_framework/middleware.py
+++ b/crates/fusion-py/python/fusion_framework/middleware.py
@@ -222,11 +222,11 @@ async def _await_merge() -> Any:
def framework_headers() -> Middleware:
- """Default identity middleware: advertise Fusion on every response.
+ """Optional identity middleware: advertise Fusion on responses.
Adds ``X-Powered-By``, ``X-Framework``, and ``X-Fusion-Version``.
- The Rust core also injects these on the wire (covers 404s). Disable with
- ``fingerprint.enabled: false`` in ``fusion..json``.
+ Not enabled by default — add with ``app.use(framework_headers())``.
+ Wire-level injection is off unless ``fingerprint.enabled: true`` in settings.
"""
try:
from fusion_framework._fusion import fingerprint_headers as _fp
@@ -256,6 +256,132 @@ def middleware(request: RequestDict, call_next: Callable[[RequestDict], Any]) ->
return middleware
+def _get_header(request: RequestDict, name: str) -> str | None:
+ headers = request.get("headers") or {}
+ target = name.lower()
+ for key, value in headers.items():
+ if str(key).lower() == target:
+ return str(value)
+ return None
+
+
+def security_headers(
+ *,
+ content_type_options: str = "nosniff",
+ frame_options: str = "DENY",
+ referrer_policy: str = "strict-origin-when-cross-origin",
+ permissions_policy: str = "camera=(), microphone=(), geolocation=(), payment=()",
+ coop: str = "same-origin",
+ corp: str = "same-origin",
+ csp: str | None = None,
+ hsts: str | None = None,
+) -> Middleware:
+ """Add common security response headers."""
+ extra: dict[str, str] = {
+ "X-Content-Type-Options": content_type_options,
+ "X-Frame-Options": frame_options,
+ "Referrer-Policy": referrer_policy,
+ "Permissions-Policy": permissions_policy,
+ "Cross-Origin-Opener-Policy": coop,
+ "Cross-Origin-Resource-Policy": corp,
+ }
+ if csp:
+ extra["Content-Security-Policy"] = csp
+ if hsts:
+ extra["Strict-Transport-Security"] = hsts
+
+ def middleware(request: RequestDict, call_next: Callable[[RequestDict], Any]) -> Any:
+ return _call_next_merge_headers(call_next, request, extra)
+
+ return middleware
+
+
+def cache_headers(*, default: str = "no-store") -> Middleware:
+ """Set ``Cache-Control`` on responses that do not already define it."""
+ extra = {"Cache-Control": default}
+
+ def middleware(request: RequestDict, call_next: Callable[[RequestDict], Any]) -> Any:
+ return _call_next_merge_headers(call_next, request, extra)
+
+ return middleware
+
+
+def request_id(
+ *,
+ header: str = "X-Request-Id",
+ incoming: bool = True,
+) -> Middleware:
+ """Attach a request id to ``state`` and echo it on the response."""
+ import uuid
+
+ def middleware(request: RequestDict, call_next: Callable[[RequestDict], Any]) -> Any:
+ state = _ensure_state(request)
+ rid = _get_header(request, header) if incoming else None
+ if not rid:
+ rid = str(uuid.uuid4())
+ state["request_id"] = rid
+ return _call_next_merge_headers(call_next, request, {header: rid})
+
+ return middleware
+
+
+def cors(
+ *,
+ allow_origins: Iterable[str] | str = "*",
+ allow_methods: Iterable[str] | None = None,
+ allow_headers: Iterable[str] | None = None,
+ expose_headers: Iterable[str] | None = None,
+ allow_credentials: bool = False,
+ max_age: int = 600,
+) -> Middleware:
+ """CORS middleware; short-circuits ``OPTIONS`` preflight with 204."""
+ origins = (
+ [str(allow_origins)]
+ if isinstance(allow_origins, str)
+ else [str(o) for o in allow_origins]
+ )
+ methods = (
+ ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
+ if allow_methods is None
+ else [str(m).upper() for m in allow_methods]
+ )
+ headers = (
+ ["Authorization", "Content-Type", "Accept", "Origin", "X-Request-Id"]
+ if allow_headers is None
+ else [str(h) for h in allow_headers]
+ )
+ expose = ["X-Request-Id"] if expose_headers is None else [str(h) for h in expose_headers]
+ allow_all = "*" in origins
+
+ def _cors_headers(origin: str | None) -> dict[str, str]:
+ chosen = "*"
+ if not allow_all:
+ if origin and origin in origins:
+ chosen = origin
+ elif origins:
+ chosen = origins[0]
+ out = {
+ "Access-Control-Allow-Origin": chosen,
+ "Access-Control-Allow-Methods": ", ".join(methods),
+ "Access-Control-Allow-Headers": ", ".join(headers),
+ "Access-Control-Expose-Headers": ", ".join(expose),
+ "Access-Control-Max-Age": str(max_age),
+ "Vary": "Origin",
+ }
+ if allow_credentials and chosen != "*":
+ out["Access-Control-Allow-Credentials"] = "true"
+ return out
+
+ def middleware(request: RequestDict, call_next: Callable[[RequestDict], Any]) -> Any:
+ origin = _get_header(request, "Origin")
+ extra = _cors_headers(origin)
+ if str(request.get("method", "GET")).upper() == "OPTIONS":
+ return {"status": 204, "body": "", "headers": extra}
+ return _call_next_merge_headers(call_next, request, extra)
+
+ return middleware
+
+
def dispatch_route(
request: RequestDict,
handler: Callable[[RequestDict], Any],
diff --git a/crates/fusion-py/python/fusion_framework/test_middleware.py b/crates/fusion-py/python/fusion_framework/test_middleware.py
index a1538ff..82cc48b 100644
--- a/crates/fusion-py/python/fusion_framework/test_middleware.py
+++ b/crates/fusion-py/python/fusion_framework/test_middleware.py
@@ -6,8 +6,10 @@
from fusion_framework.middleware import (
bearer_jwt,
clear_active_global,
+ cors,
dispatch_route,
framework_headers,
+ request_id,
require_roles,
set_active_global,
)
@@ -52,6 +54,50 @@ def test_bearer_jwt_populates_state():
clear_active_global()
+def test_no_middleware_by_default():
+ """FusionApp does not inject framework headers unless explicitly added."""
+ clear_active_global()
+ set_active_global([])
+ try:
+ request = {"path": "/", "headers": {}, "method": "GET"}
+ result = dispatch_route(request, _handler, [])
+ assert result["status"] == 200
+ headers = {str(k).lower(): v for k, v in (result.get("headers") or {}).items()}
+ assert "x-powered-by" not in headers
+ finally:
+ clear_active_global()
+
+
+def test_request_id_header():
+ clear_active_global()
+ set_active_global([request_id()])
+ try:
+ request = {"path": "/", "headers": {}, "method": "GET"}
+ result = dispatch_route(request, _handler, [])
+ headers = {str(k).lower(): v for k, v in (result.get("headers") or {}).items()}
+ assert "x-request-id" in headers
+ assert result["body"]["state"]["request_id"] == headers["x-request-id"]
+ finally:
+ clear_active_global()
+
+
+def test_cors_options_preflight():
+ clear_active_global()
+ set_active_global([cors()])
+ try:
+ request = {
+ "path": "/api",
+ "headers": {"Origin": "https://example.com"},
+ "method": "OPTIONS",
+ }
+ result = dispatch_route(request, _handler, [])
+ assert result["status"] == 204
+ headers = {str(k).lower(): v for k, v in (result.get("headers") or {}).items()}
+ assert headers.get("access-control-allow-origin")
+ finally:
+ clear_active_global()
+
+
def test_framework_headers_awaits_async_handler():
"""Regression: sync framework_headers must not stringify coroutine bodies.