From 86dac408c3c505692e634e2e5800b08990844d33 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 01:41:22 +0800 Subject: [PATCH] docs(app): explain route proxy authentication and page composition --- src/app/api/foundry/[...path]/route.ts | 39 +++++++++++++++++++ src/app/api/oauth/[provider]/route.ts | 7 ++++ src/app/auth/callback/page.tsx | 4 ++ .../dashboard/[workspace]/[board]/page.tsx | 5 +++ src/app/dashboard/layout.tsx | 5 +++ src/app/dashboard/page.tsx | 5 +++ src/app/layout.tsx | 6 +++ src/app/login/page.tsx | 14 +++++++ src/app/page.tsx | 7 ++++ 9 files changed, 92 insertions(+) diff --git a/src/app/api/foundry/[...path]/route.ts b/src/app/api/foundry/[...path]/route.ts index 057afe0..11e018c 100644 --- a/src/app/api/foundry/[...path]/route.ts +++ b/src/app/api/foundry/[...path]/route.ts @@ -1,14 +1,25 @@ +/** + * Foundry的同源服务端代理。浏览器只持HttpOnly会话Cookie,代理转为上游Bearer; + * 限定可转发路径/查询/header,处理体积、超时、素材类型和会话续期。领域回执/模型校验仍由客户端服务适配器执行。 + * canonical文档和AI冻结写入保留原字节,模型/图片大响应流式转发,避免代理重编码破坏请求身份或额外缓存整份模型。 + */ import { fetchCanvasResponse } from "@/lib/canvas-response"; import { sessionCookie, sessionMaxAge, SESSION_EXPIRY_HEADER } from "@/lib/session-cookie"; import { NextRequest, NextResponse } from "next/server"; +/** 普通账户/工作区/material接口白名单;canvas与asset细分端点由下方独立表达式允许。 */ const allowed = /^(profile|auth\/(login|register|verify-email|exchange|me|logout)|workspaces(?:\/[a-zA-Z0-9_-]+(?:\/(?:materials(?:\/[a-zA-Z0-9_-]+(?:\/canvas)?)?|search))?)?)$/; +/** 明确列出旧canvas、新model增量/迁移、子图事务和AI端点,拒绝把代理变成任意上游路径通道。 */ const canvasRoute = /^workspaces\/[a-zA-Z0-9_-]+\/materials\/[a-zA-Z0-9_-]+\/canvas(?:\/(?:model(?:\/(?:migrate|operations(?:\/[a-zA-Z0-9_-]+)?))?|operations(?:\/[a-zA-Z0-9_-]+)?|connections|capabilities|subgraphs(?:\/(?:migrate|[a-zA-Z0-9_-]+\/(?:members|dissolve)))?|transactions\/[a-zA-Z0-9_-]+\/undo|ai\/(?:models|operations\/[a-zA-Z0-9_-]+|runs(?:\/[a-zA-Z0-9_-]+(?:\/(?:cancel|apply))?)?)))?$/; +/** 素材上传/原件读取、操作回执和授权元数据是不同路径,后续按类别选择解析与响应方式。 */ const assetRoute = /^workspaces\/[a-zA-Z0-9_-]+\/assets(?:\/(?:(?:operations|metadata)\/[a-zA-Z0-9_-]+|[a-zA-Z0-9_-]+))?$/; +/** 普通请求体上限1MiB;画布5MiB、multipart素材21MiB由handler显式覆盖。 */ const bodyLimit = 1024 * 1024; // Route Handlers do not have the Pages Router bodyParser size limit. +/** 逐块计数后再组装请求体,超限取消reader,finally释放锁。 + * binary原样返回字节供冻结JSON/multipart转发,其它请求才解析JSON;这不是无限body缓冲。 */ async function readBody(req: NextRequest, limit = bodyLimit, binary = false) { const reader = req.body?.getReader(); if (!reader) return {}; @@ -28,11 +39,14 @@ async function readBody(req: NextRequest, limit = bodyLimit, binary = false) { } finally { reader.releaseLock(); } + // 空body普通模式用空对象,binary模式保留空字节;无reader则早已返回空对象。 if (!size) return binary ? new Uint8Array() : {}; const bytes = Buffer.concat(chunks); return binary ? bytes : JSON.parse(bytes.toString("utf8")); } +/** 共用GET/POST/PATCH/DELETE处理器。params是当前Next版本的异步路由参数,先await再匹配路径。 + * 所有本代理响应默认no-store;HTTP成功/失败保持上游状态,上游调用/响应解析异常另转为502或超时504。 */ async function handler( req: NextRequest, { params }: { params: Promise<{ path: string[] }> }, @@ -40,8 +54,10 @@ async function handler( const requestStarted = performance.now(); const requestStartedAt = new Date().toISOString(); const responseHeaders = new Headers({ "Cache-Control": "no-store" }); + /** 复用当前响应头集合,JSON错误和普通结果都带相同缓存/续期/诊断头策略。 */ const json = (body: unknown, status: number) => NextResponse.json(body, { status, headers: responseHeaders }); + // 非GET且带Origin时比对host;缺Origin的请求在此不被拒绝,最终身份/权限仍由上游校验。 const requestOrigin = req.headers.get("origin"); if (req.method !== "GET" && requestOrigin) { try { @@ -53,6 +69,7 @@ async function handler( } const path = (await params).path.join("/"); if (!allowed.test(path) && !canvasRoute.test(path) && !assetRoute.test(path)) return json({ error: "not_found" }, 404); + // operations/metadata返回JSON,不能被当成原图流,也不使用图片multipart请求体上限。 const assetReceipt = assetRoute.test(path) && /\/assets\/operations\//.test(path); const assetMetadata = assetRoute.test(path) && /\/assets\/metadata\//.test(path); const asset = assetRoute.test(path) && !assetReceipt && !assetMetadata; @@ -60,6 +77,7 @@ async function handler( const canonical = canvas && /\/canvas\/model(?:\/|$)/.test(path); const frozenAI = canvas && /\/canvas\/ai\/runs(?:\/|$)/.test(path); let requestBody: unknown; + // 在网络转发前完成请求体限额:原始图片含multipart开销,新模型/AI写入保留原请求字节。 if (req.method !== "GET") { try { requestBody = await readBody(req, asset ? 21 * 1024 * 1024 : canonical || path.endsWith("/canvas") || path.endsWith("/canvas/operations") ? 5 * 1024 * 1024 : bodyLimit, asset || canonical || frozenAI); @@ -69,18 +87,24 @@ async function handler( } } const bodyReceived = performance.now(); + // 上游地址来自服务端环境,客户端path白名单不会让调用者自己指定任意目标源站。 const origin = process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com"; const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap"; + // multipart沿用浏览器Content-Type和boundary,其余发JSON;不透传浏览器任意请求头。 const headers: Record = { "Content-Type": asset ? (req.headers.get("content-type") || "application/octet-stream") : "application/json" }; if (canvas) headers.Accept = "application/vnd.foundry.canvas+json;version=2"; + // 仅精确协商的canonical GET模型流改变Accept,普通canvas请求继续兼容v2表示。 const modelStream = canonical && path.endsWith('/canvas/model') && req.method==='GET' && req.headers.get('accept')==='application/vnd.foundry.canvas-model+ndjson;version=1'; if(modelStream)headers.Accept='application/vnd.foundry.canvas-model+ndjson;version=1'; + // Bearer从服务端读Cookie,禁止直接依赖客户端自行提供的Authorization冒充当前会话。 const session = req.cookies.get("foundry_session")?.value; if (session) headers.Authorization = "Bearer " + session; + // 明确允许OAuth交换、条件版本、幂等键、账户绑定和资源ID;这些值仍需上游验证。 for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match", "idempotency-key", "x-expected-account", "x-resource-id"]) { const value = req.headers.get(h); if (value !== null) headers[h] = value; } + // 查询参数限定名单、单值且每值<300字符,重复值不选择性取第一项,避免前后端解释分歧。 const query = new URLSearchParams(); for (const key of ["kind", "sort", "q", "limit", "offset", "after_seq", "status", "include_descendants", "include_subgraphs", "cursor", "variant", "width"]) { const values = req.nextUrl.searchParams.getAll(key); @@ -88,18 +112,22 @@ async function handler( } const encodingStarted = performance.now(); // Frozen document and AI requests cross the proxy byte-for-byte. + // binary Buffer直接转发,不JSON.parse/stringify;普通业务JSON才重编码,GET不带body。 const outboundBody = req.method === "GET" ? undefined : asset || canonical || frozenAI ? requestBody as BodyInit : JSON.stringify(requestBody || {}); const encodeMs = performance.now() - encodingStarted; const started = performance.now(); try { + // 模型/旧快照与图片读取使用分层超时流;其它请求组合15秒总超时和客户端取消。 const snapshotRead = canvas && (path.endsWith("/canvas") || canonical) && req.method === "GET"; const streamedRead = snapshotRead || asset && req.method === "GET"; const snapshotWrite = canvas && path.endsWith("/canvas") && req.method === "PATCH"; const traceId = streamedRead || snapshotWrite ? crypto.randomUUID() : ""; + /** 大响应只记录传输时序/字节数/资源路径,不把图像/模型内容或会话令牌写入trace。 */ const requestUpstream = streamedRead ? (url: string, options: RequestInit) => fetchCanvasResponse(url, options, undefined, fetch, (trace) => { console.info(snapshotRead ? "[canvas-transfer]" : "[asset-transfer]", JSON.stringify({ trace_id: traceId, resource: path, ...trace })); }) : fetch; if (streamedRead || snapshotWrite) responseHeaders.set("X-Canvas-Trace-Id", traceId); + // 禁止fetch缓存和自动跟随上游跳转;流读取继承请求取消,具体头/body/idle计时由fetchCanvasResponse管理。 const upstream = await requestUpstream( origin + "/projects/" + encodeURIComponent(project) + "/" + path + (query.size ? "?" + query.toString() : ""), @@ -112,17 +140,20 @@ async function handler( cache: "no-store", }, ); + // 转发明确允许的重试、版本和schema提示,不能用代理自己的时间推算服务端写入版本。 const retryAfter = upstream.headers.get("retry-after"); if (retryAfter) responseHeaders.set("Retry-After", retryAfter); const etag = upstream.headers.get("etag"); if (etag) responseHeaders.set("ETag", etag); const schema = upstream.headers.get("x-canvas-schema-version"); if (schema) responseHeaders.set("X-Canvas-Schema-Version", schema); + // 既有会话仅在非401且上游给合法剩余秒数时续期;Secure还取决于生产环境及代理HTTPS头。 const renewal = upstream.headers.get(SESSION_EXPIRY_HEADER); if (session && upstream.status !== 401 && renewal !== null && /^\d+$/.test(renewal)) { const secure = process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https"; responseHeaders.set("Set-Cookie", sessionCookie(session, sessionMaxAge(renewal), secure)); } + // 成功素材GET只透传允许的图片MIME并加nosniff;不是任意内容的同源文件托管。 if (asset && upstream.ok && req.method === "GET") { const type = upstream.headers.get("content-type") || ""; if (!/^image\/(png|jpeg|webp|gif)(?:;|$)/i.test(type)) { await upstream.body?.cancel(); return json({ error: "invalid_asset" }, 502); } @@ -135,12 +166,15 @@ async function handler( const headersReceived = performance.now(); // Snapshots contain no login exchange payload. Forward them without buffering // and parsing/re-encoding multi-megabyte inline image JSON in the proxy. + // canonical含错误回执也原流透传;旧snapshot只有成功GET走此路径。此处不读取/删除模型payload字段。 if (canonical || canvas && path.endsWith("/canvas") && req.method === "GET" && upstream.ok) { responseHeaders.set("Content-Type", modelStream && upstream.ok && upstream.headers.get('content-type')?.startsWith('application/vnd.foundry.canvas-model+ndjson;') ? upstream.headers.get('content-type')! : "application/json; charset=utf-8"); responseHeaders.set("Server-Timing", `upstream;dur=${(headersReceived - started).toFixed(1)}, proxy_headers;dur=${(performance.now() - headersReceived).toFixed(1)}`); return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders }); } + // 普通响应整体解JSON,因为登录/交换成功时须把access_token换成HttpOnly Cookie并从body移除。 const body = upstream.status === 204 ? null : await upstream.json(); + // 旧快照写入trace分出接收/重编码/上游/响应耗时,不输出请求正文;字节数仅对字符串body计算。 if (snapshotWrite) console.info("[canvas-save]", JSON.stringify({ trace_id: traceId, resource: path, status: upstream.status, started_at: requestStartedAt, finished_at: new Date().toISOString(), client_body_ms: Math.round((bodyReceived - requestStarted) * 10) / 10, @@ -155,6 +189,7 @@ async function handler( `upstream;dur=${(headersReceived - started).toFixed(1)}, total;dur=${(performance.now() - started).toFixed(1)}`, ); const secure = process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https"; + // 登录/交换令牌只进Set-Cookie,不回到浏览器可读JSON;其它会话响应按上游剩余时间更新。 if (body?.access_token) { responseHeaders.set("Set-Cookie", sessionCookie(body.access_token, sessionMaxAge(body.expires_in), secure)); delete body.access_token; @@ -162,6 +197,7 @@ async function handler( const renewed = upstream.headers.get(SESSION_EXPIRY_HEADER); if (renewed !== null && /^\d+$/.test(renewed)) responseHeaders.set("Set-Cookie", sessionCookie(session, sessionMaxAge(renewed), secure)); } + // 仅上游确认登出成功后清Cookie,不把一次网络失败当成成功退出。 if (path === "auth/logout" && upstream.ok) responseHeaders.set( "Set-Cookie", @@ -170,6 +206,7 @@ async function handler( if (upstream.status === 204) return new NextResponse(null, { status: 204, headers: responseHeaders }); return json(body || { error: "upstream_unavailable" }, upstream.status); + // 这里只归一化代理传输/解析错误;成功开始转发后的流错误由流自身传播,不能补发第二个JSON响应。 } catch (error) { responseHeaders.set("Server-Timing", `total;dur=${(performance.now() - started).toFixed(1)}`); const timeout = error instanceof Error && error.name === "TimeoutError"; @@ -177,6 +214,7 @@ async function handler( } } +/** 显式拒绝PUT/HEAD/OPTIONS并说明允许方法,不依赖框架自动推导另一套接口行为。 */ function methodNotAllowed() { return NextResponse.json({ error: "method_not_allowed" }, { status: 405, @@ -184,5 +222,6 @@ function methodNotAllowed() { }); } +// 同一白名单/体积/会话策略覆盖允许的四种方法;具体操作是否合法仍由服务端端点决定。 export { handler as GET, handler as POST, handler as PATCH, handler as DELETE }; export { methodNotAllowed as PUT, methodNotAllowed as HEAD, methodNotAllowed as OPTIONS }; diff --git a/src/app/api/oauth/[provider]/route.ts b/src/app/api/oauth/[provider]/route.ts index 5ddf5c8..3cc881c 100644 --- a/src/app/api/oauth/[provider]/route.ts +++ b/src/app/api/oauth/[provider]/route.ts @@ -1,5 +1,11 @@ +/** + * 第三方登录的服务端跳转入口。只接受固定provider及单一PKCE challenge, + * 重定向到配置的Foundry项目,不交换令牌、不读取verifier,也不在此创建会话Cookie。 + */ import { NextRequest, NextResponse } from "next/server"; +/** await异步provider参数,challenge须为43字符base64url;多值或未知provider直接400。 + * 302显式no-store,避免浏览器把某一次登录challenge的跳转当成可复用响应。 */ export async function GET( req: NextRequest, { params }: { params: Promise<{ provider: string }> }, @@ -13,6 +19,7 @@ export async function GET( !/^[-_a-zA-Z0-9]{43}$/.test(challenge) ) return NextResponse.json({ error: "invalid_request" }, { status: 400 }); + // 上游源站/项目由宿主环境装配,不让查询参数决定任意OAuth重定向目标。 const origin = process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com"; const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap"; return NextResponse.redirect( diff --git a/src/app/auth/callback/page.tsx b/src/app/auth/callback/page.tsx index a32805d..b0c3259 100644 --- a/src/app/auth/callback/page.tsx +++ b/src/app/auth/callback/page.tsx @@ -1 +1,5 @@ +/** + * 认证回调页面复用登录页。真正读取URL片段、邮箱验证或PKCE交换的逻辑集中在login/page, + * 防止两条入口分别实现不同的令牌/Cookie处理规则。 + */ export { default } from "../../login/page"; diff --git a/src/app/dashboard/[workspace]/[board]/page.tsx b/src/app/dashboard/[workspace]/[board]/page.tsx index 0759de4..0411059 100644 --- a/src/app/dashboard/[workspace]/[board]/page.tsx +++ b/src/app/dashboard/[workspace]/[board]/page.tsx @@ -1,6 +1,11 @@ +/** + * 工作台画布路由的服务器入口,只解析异步路径参数并装配GraphPage。 + * 具体board引用解析、账户作用域、新旧模型选择与编辑器生命周期由GraphPage及其加载器负责。 + */ import { Suspense } from "react"; import GraphPage from "@/components/workspace/graph-page"; +/** params按当前Next版本await;Suspense给客户端画布准备过程提供状态占位,不在此发模型请求。 */ export default async function Page({ params }: { params: Promise<{ workspace: string; board: string }> }) { const { workspace, board } = await params; return ( diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 81cea96..099b018 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -1,9 +1,14 @@ +/** + * 工作台共享布局,装配当前画布Context、侧栏及子路由,并加载React Flow默认样式。 + * 布局本身只放侧栏与children;根叶页面为空,画布子路由通过children提供主编辑区。 + */ import "@xyflow/react/dist/style.css"; import { Suspense, type ReactNode } from "react"; import { ActiveBoardProvider } from "@/components/workspace/active-board"; import DashboardSidebar from "./dashboard"; import styles from "@/components/workspace/workspace.module.css"; +/** 当前board信息由ActiveBoardProvider跨侧栏/画布共享;Suspense只包侧栏,fallback保留其布局宽度。 */ export default function DashboardLayout({ children }: { children: ReactNode }) { return ( diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 8bf79c1..d14df73 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,3 +1,8 @@ +/** + * 工作台根路径的空叶页面。共享dashboard布局继续显示侧栏, + * 这里返回null是路由占位,不代表工作区没有内容或需要创建一张空画布。 + */ +/** 保留共享布局,不额外重复渲染工作台内容。 */ export default function DashboardPage() { return null; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1a6a8f3..020a238 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,9 +1,14 @@ +/** + * 整个App Router的根布局:统一页面元数据、全局CSS、应用反馈和CanvasRuntimeHost装配。 + * 子页面/画布视图借用同一个宿主,普通路由切换不应各自创建另一份项目运行时。 + */ import { CanvasRuntimeHost } from "@/components/workspace/canvas-runtime-host"; import { AppFeedback } from "@/components/ui/app-feedback"; import type { Metadata } from "next"; import type { ReactNode } from "react"; import "@/styles/globals.css"; +/** 标题/描述及不同色彩偏好的favicon,交给Next元数据系统生成页面头,不在客户端effect中改DOM。 */ export const metadata: Metadata = { title: "啡跃 Endoleap · 让灵感有形", description: "收藏灵感、连接知识,在自己的创作空间让想法慢慢成形。", @@ -17,6 +22,7 @@ export const metadata: Metadata = { }, }; +/** 服务端布局输出中文html/body壳;客户端反馈Provider包在runtime宿主外侧,供宿主状态提示使用。 */ export default function RootLayout({ children }: { children: ReactNode }) { return ( diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index b40625b..28393f8 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,3 +1,7 @@ +/** + * 登录、注册及认证回调共用的客户端页面。左侧品牌说明为展示内容,右侧管理表单/OAuth/验证状态。 + * 成功会话由同源代理写HttpOnly Cookie,组件不持有access_token;busy仅控制当前表单交互和加载提示。 + */ "use client"; import { useEffect, useState, type FormEvent } from "react"; @@ -13,6 +17,7 @@ import { import { Brand } from "@/components/brand"; import { Button } from "@/components/ui/button"; import { api, errorText, startOAuth } from "@/lib/api"; +/** 状态分别表示登录/注册模式、邮箱密码、密码可见性、请求忙碌和成功/错误文案,均不存入业务文档。 */ export default function Login() { const router = useRouter(); const [register, setRegister] = useState(false), @@ -22,6 +27,8 @@ export default function Login() { [busy, setBusy] = useState(false), [message, setMessage] = useState(""), [error, setError] = useState(""); + /** 页面挂载读取回调片段,先清掉URL查询/片段再发验证或交换请求;这样重跑不会再次消费同一片段。 + * 此effect没有单独AbortController,不具备画布loader的导航代次隔离;结果由此页面的router/state回调处理。 */ useEffect(() => { const hash = new URLSearchParams(location.hash.slice(1)); const code = hash.get("code"), @@ -34,8 +41,10 @@ export default function Login() { return; } setBusy(true); + // verifier从本标签页sessionStorage取出即删除,避免后续登录误复用;verify_email分支优先于code交换。 const verifier = sessionStorage.getItem("foundry_verifier"); sessionStorage.removeItem("foundry_verifier"); + // 邮箱验证提交token;OAuth交换只用一次性code+verifier,请求代理负责把返回令牌改写成Cookie。 (verify ? api("auth/verify-email", { method: "POST", @@ -55,6 +64,8 @@ export default function Login() { .catch((e) => setError(errorText(e))) .finally(() => setBusy(false)); }, [router]); + /** 阻止原生表单跳转,提交当前模式对应端点;注册后提示验证邮件,登录成功才push工作台。 + * HTML输入限制给即时反馈,服务端仍负责真实凭证/注册校验。 */ async function submit(e: FormEvent) { e.preventDefault(); setBusy(true); @@ -74,6 +85,7 @@ export default function Login() { setBusy(false); } } + /** 清旧提示,创建PKCE后整页跳转;发起失败恢复表单状态,成功保持busy等待浏览器导航。 */ async function oauth(p: "google" | "slack") { setError(""); setMessage("正在前往 " + (p === "google" ? "Google" : "Slack") + "…"); @@ -86,6 +98,8 @@ export default function Login() { setError("无法启动登录,请重新尝试。"); } } + // 模式切换只清提示,已有邮箱/密码和可见性独立保留。 + // 渲染分为品牌故事、模式切换、受控凭证表单、OAuth按钮与demo入口;错误用alert,非错误提示用status。 return (