Merge pull request 'chore: CSP script nonces, deploy health check, dead-code cleanup' (#14) from chore/phase1-csp-deploy-knip into main
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m32s

Reviewed-on: #14
This commit is contained in:
Simo committed 2026-07-21 20:31:05 +02:00
commit 160c05a511
20 files changed
+141 -358

No files matched your search

+20
View File
@@ -309,4 +309,24 @@ jobs:
exit 1
fi
echo "Waiting for HTTP health check..."
HEALTH_URL="${DEPLOY_HEALTH_URL:-http://127.0.0.1:3000/api/health}"
HEALTH_OK=0
for i in $(seq 1 15); do
BODY="$(curl -sf --max-time 5 "${HEALTH_URL}" 2>/dev/null || true)"
if echo "${BODY}" | grep -q '"database":true'; then
echo "Health OK (${HEALTH_URL})"
HEALTH_OK=1
break
fi
echo "Health attempt ${i}/15 failed, retrying..."
sleep 2
done
if [ "${HEALTH_OK}" != "1" ]; then
echo "ERROR: Health check failed after deploy (${HEALTH_URL})" >&2
echo "Last body: ${BODY:-<empty>}" >&2
journalctl -u atom-nexst.service -n 40 --no-pager >&2 || true
exit 1
fi
echo "--- Deployed successfully ---"
+1 -15
View File
@@ -16,21 +16,7 @@ const securityHeaders = [
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",
},
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://static.cloudflareinsights.com",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/",
"connect-src 'self' https: wss:",
"font-src 'self' data:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
].join("; "),
},
// CSP is set per-request in src/proxy.ts with a script nonce (no 'unsafe-inline' for scripts).
];
const nextConfig: NextConfig = {
+1 -1
View File
@@ -49,7 +49,7 @@
"music-metadata": "^11.14.0",
"mysql2": "^3.23.0",
"next": "^16.2.11",
"next-auth": "5.0.0-beta.31",
"next-auth": "5.0.0-beta.32",
"next-intl": "^4.13.3",
"next-view-transitions": "^0.3.5",
"nodemailer": "^7.0.13",
+11 -11
View File
@@ -92,8 +92,8 @@ importers:
specifier: ^16.2.11
version: 16.2.11(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
next-auth:
specifier: 5.0.0-beta.31
version: 5.0.0-beta.31([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected])
specifier: 5.0.0-beta.32
version: 5.0.0-beta.32([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected])
next-intl:
specifier: ^4.13.3
version: 4.13.3([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected])
@@ -221,12 +221,12 @@ packages:
'@apm-js-collab/[email protected]':
resolution: {integrity: sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw==}
'@auth/[email protected].2':
resolution: {integrity: sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==}
'@auth/[email protected].3':
resolution: {integrity: sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2
nodemailer: ^7.0.7
nodemailer: ^7.0.7 || ^8.0.5
peerDependenciesMeta:
'@simplewebauthn/browser':
optional: true
@@ -3508,13 +3508,13 @@ packages:
[email protected]:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
[email protected]1:
resolution: {integrity: sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==}
[email protected]2:
resolution: {integrity: sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2
next: ^14.0.0-0 || ^15.0.0 || ^16.0.0
nodemailer: ^7.0.7
nodemailer: ^7.0.7 || ^8.0.5
react: ^18.2.0 || ^19.0.0
peerDependenciesMeta:
'@simplewebauthn/browser':
@@ -4325,7 +4325,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@auth/[email protected].2([email protected])':
'@auth/[email protected].3([email protected])':
dependencies:
'@panva/hkdf': 1.2.1
jose: 6.2.3
@@ -7123,9 +7123,9 @@ snapshots:
[email protected]: {}
[email protected]1([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected]):
[email protected]2([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected]):
dependencies:
'@auth/core': 0.41.2([email protected])
'@auth/core': 0.41.3([email protected])
next: 16.2.11(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
react: 19.2.8
optionalDependencies:
-53
View File
@@ -58,56 +58,3 @@ export async function saveLogo(
};
}
}
export async function saveLogoFromUrl(
gifUrl: string,
): Promise<{ success: boolean; url?: string; error?: string }> {
try {
const res = await fetch(gifUrl);
if (!res.ok)
return { success: false, error: `Failed to fetch GIF: ${res.status}` };
const contentType = res.headers.get("content-type") ?? "image/gif";
const buffer = Buffer.from(await res.arrayBuffer());
const ext =
contentType === "image/png"
? "png"
: contentType === "image/gif"
? "gif"
: contentType === "image/jpeg"
? "jpg"
: contentType === "image/webp"
? "webp"
: "gif";
const filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const baseDir = MEDIA_DIR;
const filePath = path.resolve(baseDir, filename);
if (!filePath.startsWith(baseDir + path.sep)) {
return { success: false, error: "Invalid path" };
}
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { recursive: true });
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, buffer);
const url = `/api/media/logo/${filename}`;
await prisma.websiteSetting.upsert({
where: { key: "cms_logo" },
update: { value: url },
create: { key: "cms_logo", value: url, comment: "Logo (generator)" },
});
siteSettings.reload();
revalidatePath("/", "layout");
return { success: true, url };
} catch (e) {
return {
success: false,
error: e instanceof Error ? e.message : "Unknown error",
};
}
}
+3
View File
@@ -1,4 +1,5 @@
import { getTranslations } from "next-intl/server";
import { headers } from "next/headers";
import { RegisterForm } from "@/components/auth/register-form";
import { captchaConfig } from "@/lib/services/captcha";
import { siteSettings } from "@/lib/services/site-settings";
@@ -12,6 +13,7 @@ export default async function RegisterPage({
}) {
const t = await getTranslations("pages.register");
const { error } = await searchParams;
const nonce = (await headers()).get("x-nonce") ?? undefined;
const [hotelName, captcha] = await Promise.all([
siteSettings.get("hotel_name", "Atom"),
@@ -26,6 +28,7 @@ export default async function RegisterPage({
hotelName={hotelName ?? "Atom"}
captcha={captcha}
error={error}
nonce={nonce}
/>
</div>
</main>
+7 -1
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Nunito, Pixelify_Sans } from "next/font/google";
import { headers } from "next/headers";
import Script from "next/script";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
@@ -61,6 +62,7 @@ export default async function RootLayout({
const messages = await getMessages();
const defaultDark = await siteSettings.getBool("default_dark", false);
const nitroUrl = await siteSettings.get("nitro_client_url", "");
const nonce = (await headers()).get("x-nonce") ?? undefined;
return (
<html
@@ -69,7 +71,11 @@ export default async function RootLayout({
>
<head>
<meta name="theme-default-dark" content={String(defaultDark)} />
<Script src="/scripts/theme-init.js" strategy="beforeInteractive" />
<Script
src="/scripts/theme-init.js"
strategy="beforeInteractive"
nonce={nonce}
/>
<link rel="preconnect" href="https://www.habbo.com" />
<link rel="dns-prefetch" href="https://www.habbo.com" />
{nitroUrl?.startsWith("http") ? (
+24 -1
View File
@@ -2,6 +2,7 @@
import Image from "next/image";
import Link from "next/link";
import Script from "next/script";
import { useTranslations } from "next-intl";
import { useActionState, useState } from "react";
import { register } from "@/actions/register";
@@ -10,9 +11,15 @@ interface RegisterFormProps {
hotelName: string;
captcha: { provider: string; siteKey?: string };
error?: string;
nonce?: string;
}
export function RegisterForm({ hotelName, captcha, error }: RegisterFormProps) {
export function RegisterForm({
hotelName,
captcha,
error,
nonce,
}: RegisterFormProps) {
const t = useTranslations("pages.register");
const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey;
const [serverError, formAction, isPending] = useActionState(register, null);
@@ -20,6 +27,22 @@ export function RegisterForm({ hotelName, captcha, error }: RegisterFormProps) {
return (
<div className="mx-auto w-full max-w-[800px]">
{showCaptcha && captcha.provider === "turnstile" ? (
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async
defer
nonce={nonce}
/>
) : null}
{showCaptcha && captcha.provider === "recaptcha" ? (
<Script
src="https://www.google.com/recaptcha/api.js"
async
defer
nonce={nonce}
/>
) : null}
{/* Header Banner */}
<div
className="relative overflow-hidden bg-center bg-cover rounded-t-lg"
-31
View File
@@ -22,34 +22,3 @@ export function Reveal({ children, className, delay = 0 }: RevealProps) {
</motion.div>
);
}
export function RevealStagger({ children, className }: RevealProps) {
return (
<motion.div
className={className}
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-50px" }}
variants={{
hidden: {},
visible: { transition: { staggerChildren: 0.08, delayChildren: 0.05 } },
}}
>
{children}
</motion.div>
);
}
export function RevealItem({ children, className }: RevealProps) {
return (
<motion.div
className={className}
variants={{
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0, transition: { duration: 0.35 } },
}}
>
{children}
</motion.div>
);
}
-23
View File
@@ -32,29 +32,6 @@ export function calcPagination(total: number, page: number, perPage: number) {
};
}
/** Generate CSV content from rows */
export function generateCsv(
rows: Record<string, unknown>[],
columns: { key: string; label: string }[],
): string {
const BOM = "\uFEFF";
const header = columns.map((c) => escapeCsv(c.label)).join(",");
const body = rows
.map((row) =>
columns.map((c) => escapeCsv(String(row[c.key] ?? ""))).join(","),
)
.join("\n");
return `${BOM + header}\n${body}`;
}
function escapeCsv(value: string): string {
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}
export function formatTimestamp(ts: number): string {
if (!ts) return "N/A";
return new Date(ts * 1000).toLocaleString();
-41
View File
@@ -1,16 +1,4 @@
import { NextResponse } from "next/server";
import { ZodError, type z } from "zod";
import { reportError } from "@/lib/report-error";
/** Throwable API error with HTTP status code */
export class ApiError extends Error {
status: number;
constructor(message: string, status: number = 400) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
/** Standard success response: { ok: true, ...data } */
export function apiOk(data?: Record<string, unknown>) {
@@ -21,32 +9,3 @@ export function apiOk(data?: Record<string, unknown>) {
export function apiError(message: string, status: number = 400) {
return NextResponse.json({ error: message }, { status });
}
/** Validation error from Zod: { error: fieldErrors } with 400 */
export function apiValidationError(zodError: z.ZodError) {
return NextResponse.json(
{ error: zodError.flatten().fieldErrors },
{ status: 400 },
);
}
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
export function handleApiError(error: unknown): Response {
if (error instanceof ApiError) {
return apiError(error.message, error.status);
}
if (error instanceof ZodError) {
return apiValidationError(error);
}
// Prisma P2025 "Record not found"
if (
error instanceof Error &&
(error.constructor.name === "PrismaClientKnownRequestError" ||
error.name === "PrismaClientKnownRequestError") &&
(error as Error & { code?: string }).code === "P2025"
) {
return apiError("Not found", 404);
}
reportError(error, "API error");
return apiError("Internal server error", 500);
}
-5
View File
@@ -15,8 +15,3 @@ export function cached<T>(
return data;
});
}
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
export function bustCache(key: string): void {
store.delete(key);
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { buildContentSecurityPolicy, createCspNonce } from "./csp";
describe("csp", () => {
it("creates a non-empty base64 nonce", () => {
const nonce = createCspNonce();
expect(nonce.length).toBeGreaterThan(8);
expect(nonce).toMatch(/^[A-Za-z0-9+/=]+$/);
});
it("uses a script nonce and omits script unsafe-inline", () => {
const csp = buildContentSecurityPolicy("testNonce123");
expect(csp).toContain("script-src");
expect(csp).toContain("'nonce-testNonce123'");
expect(csp).not.toMatch(/script-src[^;]*'unsafe-inline'/);
expect(csp).toContain("style-src 'self' 'unsafe-inline'");
expect(csp).toContain("https://challenges.cloudflare.com");
expect(csp).toContain("https://cdn.jsdelivr.net");
});
});
+36
View File
@@ -0,0 +1,36 @@
/**
* Build a Content-Security-Policy value.
* Scripts: nonce for Next.js / first-party inline; host allowlists for captcha,
* Cloudflare Insights, and TinyMCE CDN. style-src keeps 'unsafe-inline' for
* theme CSS variables (ThemeVars) — nonce styles are a follow-up.
*/
export function buildContentSecurityPolicy(nonce: string): string {
const isDev = process.env.NODE_ENV === "development";
const scriptSrc = [
"'self'",
`'nonce-${nonce}'`,
"https://challenges.cloudflare.com",
"https://www.google.com/recaptcha/",
"https://www.gstatic.com/recaptcha/",
"https://static.cloudflareinsights.com",
"https://cdn.jsdelivr.net",
...(isDev ? ["'unsafe-eval'"] : []),
].join(" ");
return [
"default-src 'self'",
`script-src ${scriptSrc}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/",
"connect-src 'self' https: wss:",
"font-src 'self' data:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
].join("; ");
}
export function createCspNonce(): string {
return Buffer.from(crypto.randomUUID()).toString("base64");
}
+12
View File
@@ -62,4 +62,16 @@ describe("production deploy workflow", () => {
'export NEXT_PUBLIC_APP_VERSION="$' + "{APP_VERSION}\"",
);
});
it("runs an HTTP health check before declaring deploy success", () => {
expect(workflow).toContain("/api/health");
expect(workflow).toContain('"database":true');
const deployJob = workflow.slice(workflow.indexOf("\n deploy:"));
const startAt = deployJob.indexOf("systemctl start atom-nexst.service");
const healthAt = deployJob.indexOf("/api/health");
const successAt = deployJob.indexOf("--- Deployed successfully ---");
expect(startAt).toBeGreaterThan(-1);
expect(healthAt).toBeGreaterThan(startAt);
expect(successAt).toBeGreaterThan(healthAt);
});
});
-76
View File
@@ -1,68 +1,5 @@
import type { Variants } from "framer-motion";
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
export const fadeIn: Variants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.4 } },
};
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
export const fadeInUp: Variants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0, transition: { duration: 0.4 } },
};
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
export const fadeInDown: Variants = {
hidden: { opacity: 0, y: -12 },
visible: { opacity: 1, y: 0, transition: { duration: 0.3 } },
};
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
export const fadeInLeft: Variants = {
hidden: { opacity: 0, x: -20 },
visible: { opacity: 1, x: 0, transition: { duration: 0.35 } },
};
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
export const fadeInRight: Variants = {
hidden: { opacity: 0, x: 20 },
visible: { opacity: 1, x: 0, transition: { duration: 0.35 } },
};
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
export const scaleIn: Variants = {
hidden: { opacity: 0, scale: 0.95 },
visible: { opacity: 1, scale: 1, transition: { duration: 0.25 } },
};
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
export const slideUp: Variants = {
hidden: { opacity: 0, y: 30 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: [0.25, 0.1, 0.25, 1] },
},
};
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
export const staggerContainer: Variants = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.07,
delayChildren: 0.1,
},
},
};
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
export const staggerItem: Variants = {
hidden: { opacity: 0, y: 16 },
visible: { opacity: 1, y: 0, transition: { duration: 0.35 } },
};
export const pageTransition: Variants = {
initial: { opacity: 0, y: 8 },
enter: {
@@ -111,16 +48,3 @@ export const modalContentVariants: Variants = {
},
exit: { opacity: 0, scale: 0.95, y: 10, transition: { duration: 0.15 } },
};
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
export const hoverScale = {
whileHover: { scale: 1.03 },
whileTap: { scale: 0.97 },
transition: { type: "spring" as const, stiffness: 400, damping: 17 },
};
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
export const hoverLift = {
whileHover: { y: -3, transition: { duration: 0.2 } },
whileTap: { y: 0 },
};
-76
View File
@@ -203,79 +203,3 @@ export function canAccess(
): boolean {
return permissions.has(slug);
}
/**
* For mod panel server components: get session + load permissions.
* Redirects to login if unauthenticated and to / without moderator ACL access.
*/
export async function getModContext() {
const session = await auth();
if (!session?.user) {
redirectSafe("/login", "/login");
}
const userId = sessionUserId(session.user.id);
if (!userId) redirectSafe("/login", "/login");
const state = await getCurrentAuthorizationState(userId);
if (!state) redirectSafe("/", "/");
const permissions = await loadUserPermissions(
userId,
state.actor.rank,
state.highestRank,
);
if (!canAccess(permissions, PERMS.MOD_DASHBOARD)) redirectSafe("/", "/");
return {
session: {
...session,
user: {
...session.user,
id: userId,
username: state.actor.username,
rank: state.actor.rank,
},
},
permissions,
};
}
// ── Legacy single-check functions (kept for backward compatibility) ──
/** Check if a user has a CMS permission using their current database rank. */
export async function checkPermission(
userId: number,
_rank: number,
permission: string,
): Promise<boolean> {
const state = await getCurrentAuthorizationState(userId);
if (!state) return false;
const perms = await loadUserPermissions(
userId,
state.actor.rank,
state.highestRank,
);
return perms.has(permission);
}
/** Check multiple permissions (user needs ALL of them) */
export async function checkAllPermissions(
userId: number,
_rank: number,
permissions: string[],
): Promise<boolean> {
const state = await getCurrentAuthorizationState(userId);
if (!state) return false;
const perms = await loadUserPermissions(
userId,
state.actor.rank,
state.highestRank,
);
return perms.hasAll(...permissions);
}
/** Check if user has admin access */
export async function hasAdminAccess(
userId: number,
rank: number,
): Promise<boolean> {
return checkPermission(userId, rank, PERMS.ADMIN_DASHBOARD);
}
-10
View File
@@ -33,16 +33,6 @@ export async function redisCache<T>(
return fresh;
}
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
export async function invalidateCache(key: string): Promise<void> {
if (!redis) return;
try {
await redis.del(key);
} catch {
// ignore
}
}
/**
* Build a namespaced cache key for API routes.
*/
-14
View File
@@ -40,17 +40,3 @@ function createRedis(): Redis | null {
export const redis: Redis | null =
globalForRedis.redis !== undefined ? globalForRedis.redis : createRedis();
if (globalForRedis.redis === undefined) globalForRedis.redis = redis;
export async function withRedis<T>(
fallback: () => Promise<T>,
redisFn: (client: Redis) => Promise<T>,
): Promise<T> {
if (redis) {
try {
return await redisFn(redis);
} catch {
return fallback();
}
}
return fallback();
}
+6
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { buildContentSecurityPolicy, createCspNonce } from "@/lib/csp";
import { shouldRedirectAdminRequest } from "@/lib/proxy-access";
import { proxyAuth } from "@/lib/proxy-auth";
@@ -18,8 +19,12 @@ export const proxy = proxyAuth((req) => {
return NextResponse.redirect(new URL("/login", req.url));
}
const nonce = createCspNonce();
const csp = buildContentSecurityPolicy(nonce);
const headers = new Headers(req.headers);
headers.set("x-pathname", req.nextUrl.pathname);
headers.set("x-nonce", nonce);
const ip =
req.headers.get("cf-connecting-ip") ??
@@ -33,6 +38,7 @@ export const proxy = proxyAuth((req) => {
for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
response.headers.set(key, value);
}
response.headers.set("Content-Security-Policy", csp);
return response;
});