서버 계약
clientSecret이 필요한 BrandApp 기능은 Surface에 직접 넣지 않습니다. server route가 SDK를 호출하고 Surface는 endpoint만 받습니다.
reopt designUpdated
1. Contract matrix
| Surface | Endpoints | Secret boundary |
|---|---|---|
| ReoptAiChat | apiEndpoint POST, optional modelsEndpoint GET, agentsEndpoint GET, creditsEndpoint GET | getBrandappProvider/getBrandappSDK stays behind server route handlers |
| ReoptAiImageStudio | apiEndpoint POST, optional image modelsEndpoint GET | sdk.ai.generateImage and typed errors stay on server |
| ReoptRecordTable | recordsEndpoint GET | sdk.eav.records(...).list() stays on server |
2. Lazy server clients
Next.js build와 static evaluation은 route module을 runtime env 없이 평가할 수 있습니다. BrandApp SDK와 AI provider는 module scope에서 만들지 말고, server-only helper의 lazy getter 안에서 초기화합니다.
// lib/brandapp-server.ts
import { createReoptSDK } from "@reopt-ai/brandapp-sdk";
import { createBrandappProvider } from "@reopt-ai/brandapp-sdk/ai-provider";
function requireEnv(name: string) {
const value = process.env[name];
if (!value) throw new Error(name + " is required");
return value;
}
function getBrandappConfig() {
return {
clientId: requireEnv("BRANDAPP_CLIENT_ID"),
clientSecret: requireEnv("BRANDAPP_CLIENT_SECRET"),
brandappId: requireEnv("BRANDAPP_ID"),
};
}
let sdk: ReturnType<typeof createReoptSDK> | null = null;
export function getBrandappSDK() {
sdk ??= createReoptSDK(getBrandappConfig());
return sdk;
}
let provider: ReturnType<typeof createBrandappProvider> | null = null;
export function getBrandappProvider() {
provider ??= createBrandappProvider(getBrandappConfig());
return provider;
}3. AI chat route
ReoptAiChat은 opt-chat UI와 transport를 제공합니다. 실제 BrandApp AI provider, model fallback, typed error copy는 route handler에서 결정합니다.
// app/api/chat/route.ts
import { APICallError } from "@ai-sdk/provider";
import {
isCreditLimitError,
isModelAccessError,
} from "@reopt-ai/brandapp-sdk";
import { convertToModelMessages, streamText } from "ai";
import { getBrandappProvider } from "@/lib/brandapp-server";
export async function POST(req: Request) {
const { messages, model, agentId } = await req.json();
const brandapp = getBrandappProvider();
const result = streamText({
model: brandapp(model ?? "anthropic/claude-haiku-4.5"),
messages: convertToModelMessages(messages),
// agentId는 BrandApp agent routing 정책에 맞게 route에서 해석합니다.
});
return result.toUIMessageStreamResponse({
onError: (error) => {
const cause = APICallError.isInstance(error) ? error.cause : error;
if (isCreditLimitError(cause)) return "AI 크레딧을 모두 사용했습니다.";
if (isModelAccessError(cause)) {
return "현재 플랜에서 사용할 수 없는 모델입니다.";
}
return cause instanceof Error ? cause.message : "생성 중 오류가 발생했습니다.";
},
});
}// app/api/brandapp-models/route.ts
import { getBrandappSDK } from "@/lib/brandapp-server";
export async function GET() {
const sdk = getBrandappSDK();
const models = await sdk.ai.models();
return Response.json(
models
.filter((model) => model.modality === "chat")
.sort((a, b) => Number(b.isDefault) - Number(a.isDefault))
.map((model) => ({
id: model.id,
label: model.displayName,
description: model.description,
provider: model.provider,
...(model.isDefault ? { badge: "기본" } : {}),
})),
);
}4. Image generation route
ReoptAiImageStudio는 typed error code를 사용자 친화 메시지로 매핑할 수 있습니다. 서버 route가 SDK error를 { error: { code, message } } shape로 내려주면 됩니다.
// app/api/brandapp-image/route.ts
import { isReoptSDKError } from "@reopt-ai/brandapp-sdk";
import { getBrandappSDK } from "@/lib/brandapp-server";
export async function POST(req: Request) {
const sdk = getBrandappSDK();
const body = await req.json();
try {
const result = await sdk.ai.generateImage(body);
return Response.json(result);
} catch (error) {
if (isReoptSDKError(error)) {
return Response.json(
{ error: { code: error.code, message: error.message } },
{ status: error.status },
);
}
throw error;
}
}5. EAV record proxy
ReoptRecordTable은 array, data, records 응답을 모두 행 배열로 정규화합니다. 그래도 권장 응답은 EAV list page 그대로입니다.
// app/api/brandapp-records/route.ts
import { getBrandappSDK } from "@/lib/brandapp-server";
export async function GET(req: Request) {
const sdk = getBrandappSDK();
const { searchParams } = new URL(req.url);
const entityId = searchParams.get("entityId");
if (!entityId) return Response.json({ data: [] });
const page = await sdk.eav.records(entityId).list({
page: Number(searchParams.get("page") ?? 1),
pageSize: Number(searchParams.get("pageSize") ?? 50),
});
return Response.json(page);
}6. 401 session event
보호된 API가 401을 반환하면 SessionExpiredDialog가 알아들을 수 있는 window event를 dispatch합니다. Surface 내부에서 fetch helper를 강제하지 않으므로 consumer app의 API wrapper에 넣습니다.
// lib/protected-fetch.ts
import { SESSION_EXPIRED_EVENT } from "@/components/session-expired-dialog";
export async function protectedFetch(input: RequestInfo, init?: RequestInit) {
const response = await fetch(input, init);
if (response.status === 401 && typeof window !== "undefined") {
window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT));
}
return response;
}