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(),
});

View File

@@ -1,16 +1,16 @@
{
"generatedAt": "2026-05-20T10:09:50.939Z",
"commit": "de458a6",
"generatedAt": "2026-05-20T10:32:38.076Z",
"commit": "dc718fd",
"repo": {
"statements": 97.43,
"branches": 92.57,
"branches": 92.56,
"functions": 97.28,
"lines": 97.43,
"counts": {
"lf": 6079,
"lh": 5923,
"brf": 1224,
"brh": 1133,
"brf": 1223,
"brh": 1132,
"fnf": 368,
"fnh": 358
}
@@ -102,14 +102,14 @@
},
"@repo/core-shared": {
"statements": 98.39,
"branches": 96.48,
"branches": 96.47,
"functions": 93.5,
"lines": 98.39,
"counts": {
"lf": 1304,
"lh": 1283,
"brf": 369,
"brh": 356,
"brf": 368,
"brh": 355,
"fnf": 123,
"fnh": 115
}

View File

@@ -1,17 +1,22 @@
// packages/core-shared/src/instrumentation/sentry/init-client-react.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
const { replayIntegration } = vi.hoisted(() => {
const { replayIntegration, feedbackIntegration } = vi.hoisted(() => {
const replayIntegration = vi.fn((opts: unknown) => ({
name: "Replay",
_opts: opts,
}));
return { replayIntegration };
const feedbackIntegration = vi.fn((opts: unknown) => ({
name: "Feedback",
_opts: opts,
}));
return { replayIntegration, feedbackIntegration };
});
vi.mock("@sentry/react", () => ({
init: vi.fn(),
replayIntegration,
feedbackIntegration,
}));
import * as SentryReact from "@sentry/react";
@@ -59,4 +64,29 @@ describe("initSentryClientReact", () => {
initSentryClientReact({ dsn: "", app: "web-tanstack" });
expect(SentryReact.init).not.toHaveBeenCalled();
});
it("attaches feedbackIntegration when SentryReact.feedbackIntegration is available", () => {
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
expect(feedbackIntegration).toHaveBeenCalledTimes(1);
});
it("passes styleNonce and scriptNonce to feedbackIntegration when nonce provided", () => {
initSentryClientReact({
dsn: "https://x@y/1",
app: "web-tanstack",
nonce: "abc123",
});
const feedbackOpts = (feedbackIntegration as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>;
expect(feedbackOpts["styleNonce"]).toBe("abc123");
expect(feedbackOpts["scriptNonce"]).toBe("abc123");
});
it("omits nonce props from feedbackIntegration when nonce not provided", () => {
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
const feedbackOpts = (feedbackIntegration as ReturnType<typeof vi.fn>).mock
.calls[0]![0] as Record<string, unknown>;
expect(feedbackOpts["styleNonce"]).toBeUndefined();
expect(feedbackOpts["scriptNonce"]).toBeUndefined();
});
});

View File

@@ -72,6 +72,7 @@ export function initSentryClientReact(opts: InitClientOpts): void {
if (!opts.dsn) return;
const isProd = process.env["NODE_ENV"] === "production";
const { nonce } = opts;
const tracesSampleRate =
process.env["SENTRY_TRACES_SAMPLE_RATE"] !== undefined
? Number(process.env["SENTRY_TRACES_SAMPLE_RATE"])
@@ -127,6 +128,13 @@ export function initSentryClientReact(opts: InitClientOpts): void {
maskAllInputs: true,
blockAllMedia: true,
}),
...(SentryReact.feedbackIntegration
? [
SentryReact.feedbackIntegration({
...(nonce ? { styleNonce: nonce, scriptNonce: nonce } : {}),
}),
]
: []),
],
initialScope: { tags: { app: opts.app } },
});