feat(web-tanstack): register security middleware and wire nonce to __root
- Add @tanstack/start + vinxi to deps so defineConfig is available - Uncomment defineConfig registration in app.config.ts — middleware is now actually wired into the Nitro server hook, not just defined - Update __root.tsx loader to call getNonce(getEvent().node.req) from @repo/core-shared/security/tanstack so the per-request nonce is read server-side and injected via <meta name="csp-nonce"> - Update __root.test.tsx: mock provides useLoaderData and asserts the nonce meta tag is rendered with the correct content Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,10 +8,8 @@
|
|||||||
// setHeader calls forward them to the response.
|
// setHeader calls forward them to the response.
|
||||||
// req.headers["x-nonce"] is set so downstream loaders can call
|
// req.headers["x-nonce"] is set so downstream loaders can call
|
||||||
// getNonce(event.node.req) from @repo/core-shared/security/tanstack.
|
// 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 { defineConfig } from "@tanstack/start/config";
|
||||||
import { withSecurityHeaders } from "@repo/core-shared/security/tanstack";
|
import { withSecurityHeaders } from "@repo/core-shared/security/tanstack";
|
||||||
|
|
||||||
interface H3SecurityEvent {
|
interface H3SecurityEvent {
|
||||||
@@ -34,8 +32,6 @@ export function applySecurityHeaders(event: H3SecurityEvent): void {
|
|||||||
event.node.req.headers["x-nonce"] = nonce;
|
event.node.req.headers["x-nonce"] = nonce;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registration via TanStack Start (add @tanstack/start, then uncomment):
|
export default defineConfig({
|
||||||
// import { defineConfig } from "@tanstack/start/config";
|
server: { hooks: { request: applySecurityHeaders } },
|
||||||
// export default defineConfig({
|
});
|
||||||
// server: { hooks: { request: applySecurityHeaders } },
|
|
||||||
// });
|
|
||||||
|
|||||||
@@ -21,8 +21,10 @@
|
|||||||
"@sentry/react": "^10.52.0",
|
"@sentry/react": "^10.52.0",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
"@tanstack/react-router": "^1.120.0",
|
"@tanstack/react-router": "^1.120.0",
|
||||||
|
"@tanstack/start": "^1.120.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"vinxi": "0.5.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.50.0",
|
"@playwright/test": "^1.50.0",
|
||||||
|
|||||||
@@ -2,22 +2,28 @@ import { describe, it, expect, vi } from "vitest";
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
|
|
||||||
// Mock @tanstack/react-router so we don't need a full router context
|
// Mock @tanstack/react-router so we don't need a full router context.
|
||||||
|
// useLoaderData is supplied so the component can read the nonce from loader data.
|
||||||
vi.mock("@tanstack/react-router", () => ({
|
vi.mock("@tanstack/react-router", () => ({
|
||||||
createRootRoute: vi.fn((opts: { component: React.ComponentType }) => ({
|
createRootRoute: vi.fn((opts: { component: React.ComponentType }) => ({
|
||||||
options: { component: opts.component },
|
options: { component: opts.component },
|
||||||
|
useLoaderData: () => ({ nonce: "test-nonce-abc" }),
|
||||||
})),
|
})),
|
||||||
Outlet: () => <div data-testid="outlet" />,
|
Outlet: () => <div data-testid="outlet" />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("Root route", () => {
|
describe("Root route", () => {
|
||||||
it("wraps Outlet with TanstackTrpcProvider (children render)", async () => {
|
it("renders csp-nonce meta tag and Outlet", async () => {
|
||||||
const { Route } = await import("./__root");
|
const { Route } = await import("./__root");
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const RootComponent = (Route as any).options.component as React.ComponentType;
|
const RootComponent = (Route as any).options
|
||||||
|
.component as React.ComponentType;
|
||||||
|
|
||||||
render(<RootComponent />);
|
render(<RootComponent />);
|
||||||
|
|
||||||
expect(screen.getByTestId("outlet")).toBeInTheDocument();
|
expect(screen.getByTestId("outlet")).toBeInTheDocument();
|
||||||
|
const metaTag = document.querySelector('meta[name="csp-nonce"]');
|
||||||
|
expect(metaTag).toBeInTheDocument();
|
||||||
|
expect(metaTag?.getAttribute("content")).toBe("test-nonce-abc");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
import { Outlet, createRootRoute } from "@tanstack/react-router";
|
import { Outlet, createRootRoute } from "@tanstack/react-router";
|
||||||
|
import { getNonce } from "@repo/core-shared/security/tanstack";
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: () => <Outlet />,
|
loader: async () => {
|
||||||
|
try {
|
||||||
|
// Server-side during SSR: read nonce set by applySecurityHeaders middleware.
|
||||||
|
// Fails gracefully on client-side navigation (nonce already in DOM from SSR).
|
||||||
|
const { getEvent } = await import("vinxi/http");
|
||||||
|
return { nonce: getNonce(getEvent().node.req) };
|
||||||
|
} catch {
|
||||||
|
return { nonce: "" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: () => {
|
||||||
|
const { nonce } = Route.useLoaderData();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* nonce exposed to client so instrumentation-client.ts can read it */}
|
||||||
|
<meta name="csp-nonce" content={nonce} />
|
||||||
|
<Outlet />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
5748
pnpm-lock.yaml
generated
5748
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user