서버 계약
clientSecret이 필요한 BrandApp 기능은 Surface에 직접 넣지 않습니다. server route가 SDK를 호출하고 Surface는 endpoint만 받습니다.
reopt designUpdated
1. Contract matrix
| Surface | Endpoints | Secret boundary |
|---|---|---|
| ReoptAiChat | chatEndpoint POST, optional modelsEndpoint GET, agentsEndpoint GET, creditsEndpoint GET | getBrandappProvider/getBrandappSDK stays behind server route handlers |
| ReoptAiImageStudio | generateEndpoint POST, optional image modelsEndpoint GET | sdk.ai.generateImage and typed errors stay on server |
| ReoptRecordTable | recordsEndpoint GET | sdk.eav.records.list(entityId) 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 "server-only";
import { createLazySDK } 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"),
};
}
// 첫 접근 때 초기화되는 SDK 싱글턴 — module scope에서 env를 읽지 않습니다.
const sdk = createLazySDK(getBrandappConfig);
export function getBrandappSDK() {
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,
createUIMessageStreamResponse,
streamText,
toUIMessageStream,
} 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: await convertToModelMessages(messages),
// agentId는 BrandApp agent routing 정책에 맞게 route에서 해석합니다.
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({
stream: result.stream,
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 type { ReoptAiModelsResponse } from "@/components/reopt-ai-chat";
import { getBrandappSDK } from "@/lib/brandapp-server";
export async function GET() {
const sdk = getBrandappSDK();
const models = await sdk.ai.models();
// Surface가 export하는 응답 계약 타입으로 shape를 고정합니다.
const body: ReoptAiModelsResponse = 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: "기본" } : {}),
}));
return Response.json(body);
}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 type {
ReoptImageGenerateRequest,
ReoptImageGenerateResponse,
} from "@/components/reopt-ai-image-studio";
import { getBrandappSDK } from "@/lib/brandapp-server";
export async function POST(req: Request) {
const sdk = getBrandappSDK();
const body = (await req.json()) as ReoptImageGenerateRequest;
try {
const result: ReoptImageGenerateResponse = 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 응답을 모두 행 배열로 정규화합니다. 권장 응답은 Surface가 export하는 ReoptRecordsResponse로, EAV list 결과에 attributeLabels를 더한 shape입니다. page/onPageChange를 쓰면 응답의 total/limit로 서버 페이지네이션 컨트롤이 그려집니다.
// app/api/brandapp-records/route.ts
import type { ReoptRecordsResponse } from "@/components/reopt-record-table";
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({ records: [] });
// list limit은 최대 100. record.values는 attribute id 키입니다.
const [entity, result] = await Promise.all([
sdk.eav.entities.get(entityId),
sdk.eav.records.list(entityId, {
page: Number(searchParams.get("page") ?? 1),
limit: 50,
}),
]);
const body: ReoptRecordsResponse = {
...result,
// id → 라벨 맵을 함께 내려주면 자동 추론 컬럼 헤더가 읽기 쉬워집니다.
attributeLabels: Object.fromEntries(
entity.attributes.map((a) => [a.id, a.label ?? a.name]),
),
};
return Response.json(body);
}6. 401 session event
보호된 API가 401을 반환하면 notifySessionExpired()가 SessionExpiredDialog를 엽니다. 같은 파일이 export하는 withSessionExpiry(fetch)로 앱의 fetch를 감싸면 되고, 데이터 Surface에는 fetcher prop으로 넘깁니다.
// lib/protected-fetch.ts
import { withSessionExpiry } from "@/components/session-expired-dialog";
// 401을 받으면 notifySessionExpired()를 호출하는 fetch. 데이터 Surface의
// fetcher prop에 그대로 넘겨도 됩니다: <ReoptRecordTable fetcher={protectedFetch} />
export const protectedFetch = withSessionExpiry(fetch);