docs(app): explain route proxy authentication and page composition

This commit is contained in:
2026-11-12 01:41:22 +08:00
parent aacafa7a9b
commit 86dac408c3
9 changed files with 92 additions and 0 deletions
+39
View File
@@ -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<string, string> = { "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 };
+7
View File
@@ -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(
+4
View File
@@ -1 +1,5 @@
/**
* 认证回调页面复用登录页。真正读取URL片段、邮箱验证或PKCE交换的逻辑集中在login/page,
* 防止两条入口分别实现不同的令牌/Cookie处理规则。
*/
export { default } from "../../login/page";
@@ -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 (
+5
View File
@@ -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 (
<ActiveBoardProvider>
+5
View File
@@ -1,3 +1,8 @@
/**
* 工作台根路径的空叶页面。共享dashboard布局继续显示侧栏,
* 这里返回null是路由占位,不代表工作区没有内容或需要创建一张空画布。
*/
/** 保留共享布局,不额外重复渲染工作台内容。 */
export default function DashboardPage() {
return null;
}
+6
View File
@@ -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 (
<html lang="zh-CN" data-scroll-behavior="smooth">
+14
View File
@@ -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 (
<div className="auth-page">
<aside className="auth-story">
+7
View File
@@ -1,7 +1,12 @@
/**
* 公开首页与已登录工作台的服务器分流。Cookie存在不是有效会话证明,需向profile端点验证后才重定向。
* 会话无效或服务暂不可达仍显示公开LandingPage,不因为一枚旧Cookie隐藏公开内容。
*/
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import LandingPage from "@/components/landing-page";
/** 异步读取请求Cookie,带15秒时限/禁缓存验证上游;只需成功状态,不消费完整profile JSON。 */
export default async function Home() {
const session = (await cookies()).get("foundry_session")?.value;
let authenticated = false;
@@ -15,12 +20,14 @@ export default async function Home() {
redirect: "manual",
signal: AbortSignal.timeout(15000),
});
// 按响应状态确定是否已认证,再丢弃不需要的body;此处不解析profile字段。
authenticated = response.ok;
await response.body?.cancel();
} catch {
// An unverified session must not hide the public landing page.
}
}
// 仅在确认成功后跳工作台,未认证时沿用同一静态展示组件。
if (authenticated) redirect("/dashboard");
return <LandingPage />;
}