From 12ae15ed3ac515b1f32a49228c16ebf80034c12a Mon Sep 17 00:00:00 2001 From: 134 Date: Sat, 11 Jul 2026 16:24:42 +0800 Subject: [PATCH] feat: integrate authenticated Pascal service --- .env.example | 3 + .../pascal-agent/v1/chat/completions/route.ts | 127 ++++++++++++++++++ app/api/internal/pascal/access/route.ts | 26 ++++ deploy/nginx/pascal-service.conf.template | 43 ++++++ docs/pascal-editor-integration.md | 25 ++++ lib/pascal/internal-auth.ts | 27 ++++ proxy.ts | 23 ++++ 7 files changed, 274 insertions(+) create mode 100644 app/api/internal/pascal-agent/v1/chat/completions/route.ts create mode 100644 app/api/internal/pascal/access/route.ts create mode 100644 deploy/nginx/pascal-service.conf.template create mode 100644 lib/pascal/internal-auth.ts diff --git a/.env.example b/.env.example index f226d0491..a736c667e 100644 --- a/.env.example +++ b/.env.example @@ -113,6 +113,9 @@ ZOO_BASE_URL=https://api.zoo.dev # /vendor/pascal-editor/index.html bundle. NEXT_PUBLIC_PASCAL_EDITOR_URL= PASCAL_EDITOR_URL= +# Shared only by the main app, Nginx, and the private Pascal service. Generate a +# long random value; never expose it through NEXT_PUBLIC_* variables. +PASCAL_PROXY_SECRET= # RUXA / video callback. RUXA_CALLBACK_URL= diff --git a/app/api/internal/pascal-agent/v1/chat/completions/route.ts b/app/api/internal/pascal-agent/v1/chat/completions/route.ts new file mode 100644 index 000000000..89343118c --- /dev/null +++ b/app/api/internal/pascal-agent/v1/chat/completions/route.ts @@ -0,0 +1,127 @@ +import { NextResponse } from "next/server"; +import { getProviderDefaultBaseUrl } from "@/lib/ai/provider-defaults"; +import { resolveModelRuntimeId } from "@/lib/ai/model-runtime"; +import { getAgentAiModelWithProvider } from "@/lib/db/queries"; +import { createLogger } from "@/lib/observability/logger"; +import { isPascalInternalRequest } from "@/lib/pascal/internal-auth"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 600; +export const runtime = "nodejs"; + +const logger = createLogger("pascal-model-gateway"); +const OPENAI_COMPATIBLE_PROVIDER_TYPES = new Set([ + "alibaba", + "custom", + "openai", + "zhipu", +]); + +function upstreamChatUrl(baseUrl: string) { + const normalized = baseUrl.replace(/\/+$/, ""); + return normalized.endsWith("/chat/completions") + ? normalized + : `${normalized}/chat/completions`; +} + +function openAiError(message: string, status: number) { + return NextResponse.json( + { error: { message, type: "pascal_gateway_error" } }, + { status } + ); +} + +export async function GET(request: Request) { + if (!isPascalInternalRequest(request)) { + return NextResponse.json({ error: "not_found" }, { status: 404 }); + } + + const configured = await getAgentAiModelWithProvider(); + if (!configured) { + return NextResponse.json({ configured: false }); + } + + return NextResponse.json({ + configured: true, + compatible: OPENAI_COMPATIBLE_PROVIDER_TYPES.has(configured.provider.type), + model: configured.model.displayName || configured.model.modelId, + provider: configured.provider.name, + providerType: configured.provider.type, + }); +} + +export async function POST(request: Request) { + if (!isPascalInternalRequest(request)) { + return NextResponse.json({ error: "not_found" }, { status: 404 }); + } + + let body: Record; + try { + const parsed = await request.json(); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return openAiError("Invalid request body.", 400); + } + body = parsed as Record; + } catch { + return openAiError("Invalid JSON body.", 400); + } + + const configured = await getAgentAiModelWithProvider(); + if (!configured) { + return openAiError("No enabled Agent text model is configured.", 503); + } + + const { model, provider } = configured; + if (!OPENAI_COMPATIBLE_PROVIDER_TYPES.has(provider.type)) { + return openAiError( + `The configured Agent provider type '${provider.type}' is not OpenAI-compatible.`, + 503 + ); + } + + const baseUrl = + provider.baseUrl?.trim() || getProviderDefaultBaseUrl(provider.type) || ""; + const runtimeModelId = resolveModelRuntimeId(model, { + providerType: provider.type, + }); + if (!baseUrl || !runtimeModelId || !provider.apiKey) { + return openAiError("The configured Agent model is incomplete.", 503); + } + + const startedAt = Date.now(); + try { + const upstream = await fetch(upstreamChatUrl(baseUrl), { + method: "POST", + headers: { + Authorization: `Bearer ${provider.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ ...body, model: runtimeModelId }), + cache: "no-store", + signal: request.signal, + }); + + logger.info("upstream completed", { + elapsedMs: Date.now() - startedAt, + modelId: model.id, + providerId: provider.id, + status: upstream.status, + }); + + const headers = new Headers({ + "Cache-Control": "no-cache, no-transform", + "Content-Type": + upstream.headers.get("content-type") || "application/json; charset=utf-8", + "X-Accel-Buffering": "no", + }); + return new Response(upstream.body, { status: upstream.status, headers }); + } catch (error) { + logger.error("upstream failed", { + elapsedMs: Date.now() - startedAt, + error: error instanceof Error ? error.message : String(error), + modelId: model.id, + providerId: provider.id, + }); + return openAiError("The configured Agent model is temporarily unavailable.", 502); + } +} diff --git a/app/api/internal/pascal/access/route.ts b/app/api/internal/pascal/access/route.ts new file mode 100644 index 000000000..ff2953771 --- /dev/null +++ b/app/api/internal/pascal/access/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { isPascalInternalRequest } from "@/lib/pascal/internal-auth"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET(request: Request) { + if (!isPascalInternalRequest(request)) { + return NextResponse.json({ error: "not_found" }, { status: 404 }); + } + + const session = await auth(); + const userId = session?.user?.id?.trim(); + if (!userId) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + return new NextResponse(null, { + status: 204, + headers: { + "Cache-Control": "no-store", + "X-Pascal-User-Id": userId, + }, + }); +} diff --git a/deploy/nginx/pascal-service.conf.template b/deploy/nginx/pascal-service.conf.template new file mode 100644 index 000000000..14befbe70 --- /dev/null +++ b/deploy/nginx/pascal-service.conf.template @@ -0,0 +1,43 @@ +# Include this file inside the authenticated bbyy.ai HTTPS server block. +# Replace __PASCAL_PROXY_SECRET__ during deployment and chmod the rendered file 600. + +location = /_pascal_auth { + internal; + proxy_pass http://127.0.0.1:3125/api/internal/pascal/access; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header Host $host; + proxy_set_header Cookie $http_cookie; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Pascal-Proxy-Secret "__PASCAL_PROXY_SECRET__"; +} + +location = /pascal-service { + return 302 /pascal-service/scenes; +} + +location ^~ /pascal-service/ { + auth_request /_pascal_auth; + auth_request_set $pascal_user_id $upstream_http_x_pascal_user_id; + + proxy_pass http://127.0.0.1:3002; + proxy_http_version 1.1; + proxy_hide_header X-Powered-By; + proxy_set_header Host $host; + proxy_set_header Cookie ""; + proxy_set_header Authorization ""; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Pascal-User-Id $pascal_user_id; + proxy_set_header X-Pascal-Agent-Proxy-Secret "__PASCAL_PROXY_SECRET__"; + proxy_set_header X-Pascal-Scene-Token "__PASCAL_PROXY_SECRET__"; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_request_buffering off; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + proxy_connect_timeout 30s; +} diff --git a/docs/pascal-editor-integration.md b/docs/pascal-editor-integration.md index b2a0397a1..7fe9e6baf 100644 --- a/docs/pascal-editor-integration.md +++ b/docs/pascal-editor-integration.md @@ -90,6 +90,31 @@ Keep any separately deployed Pascal service behind the same private network, reverse proxy, or SSO boundary if direct access to that service must also be restricted. +### Same-domain authenticated service + +The production integration can expose Pascal below `/pascal-service` while the +service itself only listens on a loopback port. Configure: + +```env +PASCAL_EDITOR_URL=/pascal-service/scenes +PASCAL_PROXY_SECRET=replace_with_a_long_random_value +``` + +Nginx must use `auth_request` against `/api/internal/pascal/access`, strip any +client-supplied Pascal identity or secret headers, and inject the authenticated +`X-Pascal-User-Id` into the upstream request. The Pascal scene store uses that +id as `ownerId`, so scene listing and every scene mutation remain user-scoped. +The reviewed configuration is kept in +`deploy/nginx/pascal-service.conf.template`; render its secret placeholder and +include it inside the main HTTPS `server` block. + +The private Pascal service points its OpenAI-compatible model base URL to +`/api/internal/pascal-agent/v1` on this application and authenticates with +`PASCAL_PROXY_SECRET`. That gateway resolves the current enabled Agent model +and provider from system settings on every request. OpenAI-compatible +`openai`, `custom`, `alibaba`, and `zhipu` providers work without copying their +API keys into the Pascal service. + ## Next Step After the iframe POC is stable, the native integration path is: diff --git a/lib/pascal/internal-auth.ts b/lib/pascal/internal-auth.ts new file mode 100644 index 000000000..e09c9b836 --- /dev/null +++ b/lib/pascal/internal-auth.ts @@ -0,0 +1,27 @@ +import "server-only"; + +import { timingSafeEqual } from "node:crypto"; + +export const PASCAL_INTERNAL_SECRET_HEADER = "x-pascal-proxy-secret"; + +function safeEqual(left: string, right: string) { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + if (leftBuffer.length !== rightBuffer.length) return false; + return timingSafeEqual(leftBuffer, rightBuffer); +} + +function bearerToken(request: Request) { + const authorization = request.headers.get("authorization") || ""; + const match = authorization.match(/^Bearer\s+(.+)$/i); + return match?.[1]?.trim() || ""; +} + +export function isPascalInternalRequest(request: Request) { + const expected = String(process.env.PASCAL_PROXY_SECRET || "").trim(); + if (!expected) return false; + const supplied = + request.headers.get(PASCAL_INTERNAL_SECRET_HEADER)?.trim() || + bearerToken(request); + return Boolean(supplied) && safeEqual(supplied, expected); +} diff --git a/proxy.ts b/proxy.ts index e11bc088d..a4f3f1ad8 100644 --- a/proxy.ts +++ b/proxy.ts @@ -144,6 +144,24 @@ function isRoutineAuthRead(pathname: string, method: string) { ); } +function isPascalInternalProxyRequest(request: NextRequest) { + const pathname = request.nextUrl.pathname; + if ( + pathname !== "/api/internal/pascal/access" && + pathname !== "/api/internal/pascal-agent/v1/chat/completions" + ) { + return false; + } + + const expected = String(process.env.PASCAL_PROXY_SECRET || "").trim(); + if (!expected) return false; + const authorization = request.headers.get("authorization") || ""; + const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1]?.trim() || ""; + const supplied = + request.headers.get("x-pascal-proxy-secret")?.trim() || bearer; + return supplied === expected; +} + function shouldBypassLocalAuthSessionRateLimit(pathname: string, ip: string | null) { return ( process.env.CANVAS_E2E_LOCAL === "true" && @@ -674,6 +692,11 @@ export async function proxy(request: NextRequest, event: NextFetchEvent) { return NextResponse.next(); } + // Pascal 服务到服务调用由对应 Route 再次验证共享密钥。 + if (isPascalInternalProxyRequest(request)) { + return NextResponse.next(); + } + // 允许定时任务接口由服务端调用(具体鉴权由接口内部 CRON_SECRET 负责) if (pathname.startsWith("/api/jobs/")) { return NextResponse.next();