feat(web-tanstack): wire security headers middleware and nonce threading

Register core-shared/security/tanstack server middleware in app.config.ts
as a Nitro/H3 hook that emits the six security headers and forwards the
per-request nonce. Update instrumentation-client to read the nonce from
<meta name="csp-nonce"> and pass it to initSentryClientReact.

Add nonce support to initSentryClientReact (feedbackIntegration receives
styleNonce/scriptNonce), mirroring the initSentryClient pattern already
in place for web-next.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 10:33:09 +00:00
parent dc718fd9c8
commit a540f3afb1
6 changed files with 148 additions and 10 deletions

View File

@@ -0,0 +1,41 @@
// apps/web-tanstack/app.config.ts
// TanStack Start / Nitro server configuration.
// Registers the core-shared security headers middleware so every response
// emits the six security headers and a per-request CSP nonce.
//
// Wire-up pattern (Nitro/H3 server hook):
// withSecurityHeaders() generates nonce + builds six headers.
// setHeader calls forward them to the response.
// req.headers["x-nonce"] is set so downstream loaders can call
// getNonce(event.node.req) from @repo/core-shared/security/tanstack.
//
// Note: @tanstack/start (and its defineConfig) is wired in a later story.
// Uncomment the export default block once @tanstack/start is added.
import { withSecurityHeaders } from "@repo/core-shared/security/tanstack";
interface H3SecurityEvent {
node: {
req: { headers: Record<string, string | string[] | undefined> };
res: { setHeader: (name: string, value: string) => void };
};
}
/**
* Nitro/H3 server hook: emits six security headers on every response and
* forwards the per-request nonce in req.headers["x-nonce"] for downstream
* access via getNonce() from @repo/core-shared/security/tanstack.
*/
export function applySecurityHeaders(event: H3SecurityEvent): void {
const { nonce, headers } = withSecurityHeaders();
for (const [k, v] of Object.entries(headers)) {
event.node.res.setHeader(k, v);
}
event.node.req.headers["x-nonce"] = nonce;
}
// Registration via TanStack Start (add @tanstack/start, then uncomment):
// import { defineConfig } from "@tanstack/start/config";
// export default defineConfig({
// server: { hooks: { request: applySecurityHeaders } },
// });

View File

@@ -0,0 +1,50 @@
// apps/web-tanstack/src/instrumentation-client.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
// Hoist the mock so it's active when instrumentation-client runs its
// top-level initSentryClientReact call on import.
const initSentryClientReactMock = vi.hoisted(() => vi.fn());
vi.mock("@repo/core-shared/instrumentation/sentry/init-client-react", () => ({
initSentryClientReact: initSentryClientReactMock,
}));
describe("instrumentation-client", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
});
it("passes nonce from csp-nonce meta tag to initSentryClientReact", async () => {
const meta = document.createElement("meta");
meta.setAttribute("name", "csp-nonce");
meta.setAttribute("content", "test-nonce-xyz");
document.head.appendChild(meta);
try {
await import("./instrumentation-client");
} finally {
document.head.removeChild(meta);
}
expect(initSentryClientReactMock).toHaveBeenCalledWith(
expect.objectContaining({ nonce: "test-nonce-xyz" }),
);
});
it("passes empty string nonce when no csp-nonce meta tag is present", async () => {
await import("./instrumentation-client");
expect(initSentryClientReactMock).toHaveBeenCalledWith(
expect.objectContaining({ nonce: "" }),
);
});
it("passes web-tanstack as the app tag", async () => {
await import("./instrumentation-client");
expect(initSentryClientReactMock).toHaveBeenCalledWith(
expect.objectContaining({ app: "web-tanstack" }),
);
});
});

View File

@@ -2,8 +2,17 @@
// Browser-entry hook. Imported at the top of the client entry file.
import { initSentryClientReact } from "@repo/core-shared/instrumentation/sentry/init-client-react";
function getNonce(): string {
if (typeof document === "undefined") return "";
return (
document.querySelector('meta[name="csp-nonce"]')?.getAttribute("content") ??
""
);
}
initSentryClientReact({
dsn: import.meta.env["VITE_WEB_TANSTACK_SENTRY_DSN"],
app: "web-tanstack",
release: import.meta.env["VITE_GIT_COMMIT_SHA"],
nonce: getNonce(),
});