diff --git a/src/proxy.ts b/src/proxy.ts index 59c3354..086c460 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -17,7 +17,11 @@ import { export async function proxy(request: NextRequest) { const { pathname } = request.nextUrl - // Skip static assets and auth pages + // 生成或复用 requestId(Web Crypto API,Edge Runtime 兼容) + const requestId = + request.headers.get("x-request-id") ?? crypto.randomUUID() + + // 跳过静态资源和登录页:仍注入 requestId 便于关联下游 if ( pathname.startsWith("/_next") || pathname.startsWith("/api/auth") || @@ -25,7 +29,9 @@ export async function proxy(request: NextRequest) { pathname === "/register" || pathname === "/favicon.ico" ) { - return NextResponse.next() + return NextResponse.next({ + request: { headers: injectRequestId(request, requestId) }, + }) } const token = await getToken({ @@ -33,15 +39,14 @@ export async function proxy(request: NextRequest) { secret: process.env.NEXTAUTH_SECRET, }) - // Not authenticated → redirect to login + // 未认证 → 重定向到登录页 if (!token) { const loginUrl = new URL("/login", request.url) loginUrl.searchParams.set("callbackUrl", request.url) return NextResponse.redirect(loginUrl) } - // Onboarding gate: 未完成引导的用户只能访问 /onboarding 与白名单路径 - // 修复 P2-1:用 middleware 强制重定向替代客户端 Dialog + // Onboarding gate const onboarded = Boolean(token.onboarded) const isOnboardingPath = pathname === "/onboarding" || pathname.startsWith("/onboarding/") const isWhitelistedApi = pathname.startsWith("/api/auth") || pathname.startsWith("/api/onboarding") @@ -49,7 +54,6 @@ export async function proxy(request: NextRequest) { const onboardingUrl = new URL("/onboarding", request.url) return NextResponse.redirect(onboardingUrl) } - // 已完成 onboarding 的用户不应停留在 /onboarding if (onboarded && isOnboardingPath) { const roles: string[] = (token.roles as string[]) ?? [] const defaultPath = resolveDefaultPath(roles) @@ -78,7 +82,6 @@ export async function proxy(request: NextRequest) { } // Check page route permissions - // 优先级 1:精确路由权限(覆盖前缀匹配,用于将 /admin/* 下特定页面开放给非管理员) if (Object.prototype.hasOwnProperty.call(SPECIFIC_ROUTE_PERMISSIONS, pathname)) { const requiredPerm = SPECIFIC_ROUTE_PERMISSIONS[pathname] if (!hasPermission(requiredPerm)) { @@ -88,10 +91,11 @@ export async function proxy(request: NextRequest) { redirectUrl.searchParams.set("reason", "forbidden") return NextResponse.redirect(redirectUrl) } - return NextResponse.next() + return NextResponse.next({ + request: { headers: injectRequestId(request, requestId) }, + }) } - // 优先级 2:仪表盘路由的细粒度权限(防止跨角色访问仪表盘) if (Object.prototype.hasOwnProperty.call(DASHBOARD_ROUTE_PERMISSIONS, pathname)) { const requiredPerm = DASHBOARD_ROUTE_PERMISSIONS[pathname] if (!hasPermission(requiredPerm)) { @@ -101,15 +105,15 @@ export async function proxy(request: NextRequest) { redirectUrl.searchParams.set("reason", "forbidden") return NextResponse.redirect(redirectUrl) } - return NextResponse.next() + return NextResponse.next({ + request: { headers: injectRequestId(request, requestId) }, + }) } for (const [prefix, requiredPerm] of Object.entries(ROUTE_PREFIX_PERMISSIONS)) { if (pathname.startsWith(prefix)) { if (!hasPermission(requiredPerm)) { const defaultPath = resolveDefaultPath(roles) - // Carry original path + reason in URL so the target page can explain - // why the user was redirected (Web Interface Guidelines: URL reflects state). const redirectUrl = new URL(defaultPath, request.url) redirectUrl.searchParams.set("from", pathname) redirectUrl.searchParams.set("reason", "forbidden") @@ -119,7 +123,23 @@ export async function proxy(request: NextRequest) { } } - return NextResponse.next() + const response = NextResponse.next({ + request: { headers: injectRequestId(request, requestId) }, + }) + response.headers.set("x-request-id", requestId) + return response +} + +/** + * 创建包含 x-request-id 的新 Headers 对象。 + * 通过 NextResponse.next({ request: { headers } }) 注入到下游 RSC 请求。 + * + * 注意:proxy.ts 在 Edge Runtime 运行,不能导入 node:async_hooks 或 request-context.ts。 + */ +function injectRequestId(request: NextRequest, requestId: string): Headers { + const headers = new Headers(request.headers) + headers.set("x-request-id", requestId) + return headers } export const config = { diff --git a/tests/integration/proxy-guard.test.ts b/tests/integration/proxy-guard.test.ts index 72d08f2..f1363b7 100644 --- a/tests/integration/proxy-guard.test.ts +++ b/tests/integration/proxy-guard.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it, vi } from "vitest" +import { NextRequest } from "next/server" + +import { Permissions } from "@/shared/types/permissions" +import { encodePermissionsBitmap } from "@/shared/lib/permission-bitmap" const { getTokenMock } = vi.hoisted(() => ({ getTokenMock: vi.fn(), @@ -8,7 +12,7 @@ vi.mock("next-auth/jwt", () => ({ getToken: getTokenMock, })) -import { middleware } from "@/proxy" +import { proxy } from "@/proxy" const createRequest = (pathname: string) => ({ nextUrl: { @@ -16,12 +20,13 @@ const createRequest = (pathname: string) => ({ clone: () => new URL(`http://localhost${pathname}`), }, url: `http://localhost${pathname}`, + headers: new Headers(), }) describe("proxy route guard", () => { it("redirects unauthenticated requests to login with callback", async () => { getTokenMock.mockResolvedValue(null) - const response = await middleware(createRequest("/teacher/dashboard") as never) + const response = await proxy(createRequest("/teacher/dashboard") as never) expect(response.status).toBe(307) const location = response.headers.get("location") ?? "" expect(location).toContain("/login") @@ -31,31 +36,65 @@ describe("proxy route guard", () => { it("redirects user without school:manage permission away from admin routes", async () => { getTokenMock.mockResolvedValue({ - permissions: ["homework:submit"], + onboarded: true, roles: ["student"], + permissionsBitmap: encodePermissionsBitmap([Permissions.HOMEWORK_SUBMIT]), }) - const response = await middleware(createRequest("/admin/dashboard") as never) + const response = await proxy(createRequest("/admin/dashboard") as never) expect(response.status).toBe(307) expect(response.headers.get("location")).toContain("/student/dashboard") }) it("redirects user without grade:manage permission away from management routes", async () => { getTokenMock.mockResolvedValue({ - permissions: ["exam:read"], + onboarded: true, roles: ["parent"], + permissionsBitmap: encodePermissionsBitmap([Permissions.DASHBOARD_PARENT_READ]), }) - const response = await middleware(createRequest("/management/grade/insights") as never) + const response = await proxy(createRequest("/management/grade/insights") as never) expect(response.status).toBe(307) expect(response.headers.get("location")).toContain("/parent/dashboard") }) it("allows user with grade:manage permission to access management routes", async () => { getTokenMock.mockResolvedValue({ - permissions: ["exam:read", "grade:manage"], + onboarded: true, roles: ["teacher"], + permissionsBitmap: encodePermissionsBitmap([ + Permissions.GRADE_MANAGE, + Permissions.EXAM_CREATE, + ]), }) - const response = await middleware(createRequest("/management/grade/insights") as never) + const response = await proxy(createRequest("/management/grade/insights") as never) expect(response.status).toBe(200) expect(response.headers.get("location")).toBeNull() }) }) + +describe("proxy requestId 注入", () => { + it("未带 x-request-id 请求头时,response 头包含新生成的 x-request-id", async () => { + getTokenMock.mockResolvedValue({ + onboarded: true, + roles: ["teacher"], + permissionsBitmap: encodePermissionsBitmap([Permissions.EXAM_CREATE]), + }) + const req = new NextRequest(new URL("/dashboard", "http://localhost:3000")) + const res = await proxy(req) + expect(res.headers.get("x-request-id")).toBeDefined() + expect(res.headers.get("x-request-id")).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) + }) + + it("带 x-request-id 请求头时,response 头沿用请求头中的 id", async () => { + const existingId = "client-supplied-id" + getTokenMock.mockResolvedValue({ + onboarded: true, + roles: ["teacher"], + permissionsBitmap: encodePermissionsBitmap([Permissions.EXAM_CREATE]), + }) + const req = new NextRequest(new URL("/dashboard", "http://localhost:3000"), { + headers: { "x-request-id": existingId }, + }) + const res = await proxy(req) + expect(res.headers.get("x-request-id")).toBe(existingId) + }) +})