feat(auth-adapter,bff-kit,client-shell): 마지막 추출 배치

@yakcloud/auth-adapter: core 인터페이스(프레임워크 무관, ds-sdk type-only)
  + dev-bypass + next-auth 격리 계층(peer). exports 서브패스(./core·./dev-bypass).
@yakcloud/bff-kit: respond(web 표준)·serialize(Prisma-free)·ratelimit·session(AuthAdapter DI).
  auth-adapter=devDependency(type-only), 죽은 next peer 제거.
@yakcloud/client-shell: ServiceClientShell 셸 크롬(헤더/탭/pane/가드)+패널 슬롯+BackupPanel 스텁.
  (FileBrowser 는 Phase 1)

FIX-FIRST 반영: client-shell tsconfig jsx:react-jsx + @types/react(-dom),
  bff-kit/auth-adapter += @types/node. 7개 패키지 전부 tsc --noEmit 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-25 13:12:34 +09:00
parent e34a325b52
commit 362e3168a7
37 changed files with 2583 additions and 14 deletions

View File

@ -0,0 +1,61 @@
// @yakcloud/bff-kit — handlers/binding
// 앱 ↔ 서비스 바인딩 생성. 같은 클러스터 강제(유일 격리점) + 별칭 중복 방지 → createBinding + 재배포.
// Repo 는 DI. Response(accepted) 반환. yakconsole services/[id]/bindings/route.ts 하강.
import { ApiError, accepted } from "../respond";
import { requireOwnership, type SessionUser } from "../session";
import type { BindingRepo } from "./types";
export interface CreateBindingArgs {
serviceId: string;
deploymentId: string;
alias?: string | null;
}
// 소유·같은클러스터·READY·중복 검증 후 바인딩 생성 → 앱 재배포 큐잉 + 감사.
export async function createServiceBinding(
repo: BindingRepo,
user: SessionUser,
args: CreateBindingArgs,
): Promise<Response> {
const svc = await repo.getService(args.serviceId);
if (!svc || svc.deletedAt) throw new ApiError("NOT_FOUND");
requireOwnership(await repo.getCluster(svc.clusterId), user);
const dep = await repo.getDeployment(args.deploymentId);
if (!dep) throw new ApiError("NOT_FOUND", "앱을 찾을 수 없습니다.");
// LOCKED#1: 같은 클러스터의 앱에만 연결(크로스클러스터 차단) — 유일한 격리 강제점.
if (dep.clusterId !== svc.clusterId) {
throw new ApiError("VALIDATION_FAILED", "같은 클러스터의 앱에만 연결할 수 있습니다.");
}
if (svc.status !== "READY") {
throw new ApiError("VALIDATION_FAILED", "서비스가 준비(READY)된 후에 연결할 수 있습니다.");
}
const alias = args.alias ?? svc.name;
const existing = await repo.listBindingsByDeployment(dep.id);
if (existing.some((b) => b.serviceId === svc.id)) {
throw new ApiError("NAME_TAKEN", "이미 이 앱에 연결된 서비스입니다.");
}
if (existing.some((b) => b.alias === alias)) {
throw new ApiError("NAME_TAKEN", "이미 사용 중인 별칭입니다.");
}
const binding = await repo.createBinding({
serviceId: svc.id,
deploymentId: dep.id,
alias,
injectedKeys: [], // 실제 주입 키(BIND_ENV_VARS 파생)는 배포 시 백엔드가 도출.
});
// 바인딩 반영 = 재배포(deployment_manifest 가 binds 를 다시 엮음).
await repo.updateDeployment(dep.id, { status: "QUEUED", statusMessage: null });
await repo.enqueueJob({ kind: "app.deploy", payload: { deploymentId: dep.id } });
await repo.addAudit({
userId: user.id,
action: "service.bind",
targetType: "service",
targetId: svc.id,
meta: { deploymentId: dep.id, app: dep.name, alias },
});
return accepted({ id: binding.id, alias, serviceId: svc.id, deploymentId: dep.id });
}