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:
@ -26,10 +26,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3",
|
||||
"@yakcloud/ds-sdk": "workspace:*",
|
||||
"@yakcloud/auth-adapter": "workspace:*"
|
||||
"@yakcloud/ds-sdk": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": ">=15"
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@yakcloud/auth-adapter": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
61
packages/bff-kit/src/handlers/binding.ts
Normal file
61
packages/bff-kit/src/handlers/binding.ts
Normal 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 });
|
||||
}
|
||||
23
packages/bff-kit/src/handlers/index.ts
Normal file
23
packages/bff-kit/src/handlers/index.ts
Normal file
@ -0,0 +1,23 @@
|
||||
// @yakcloud/bff-kit — handlers 배럴
|
||||
// 재사용 라우트 로직(yakconsole API 라우트에서 추출). Repo + yakcloud 를 DI 로 받아 Response 반환.
|
||||
// 향후 quota/validation/provisioning orchestration 으로 확장 가능.
|
||||
|
||||
export { createServiceBinding } from "./binding";
|
||||
export type { CreateBindingArgs } from "./binding";
|
||||
|
||||
export { createService } from "./service";
|
||||
export type { CreateServiceArgs } from "./service";
|
||||
|
||||
export type {
|
||||
ClusterRow,
|
||||
DeploymentRow,
|
||||
ServiceRow,
|
||||
BindingRow,
|
||||
QuotaRow,
|
||||
ServiceRepo,
|
||||
BindingRepo,
|
||||
YakServiceSpecInput,
|
||||
YakServiceResultInput,
|
||||
YakcloudClient,
|
||||
HandlerDeps,
|
||||
} from "./types";
|
||||
130
packages/bff-kit/src/handlers/service.ts
Normal file
130
packages/bff-kit/src/handlers/service.ts
Normal file
@ -0,0 +1,130 @@
|
||||
// @yakcloud/bff-kit — handlers/service
|
||||
// 데이터소스(관리형/외부) 생성. remote=yakcloud 즉시 등록(READY), local=프로비저닝 Job.
|
||||
// 소스 소유자 = 클러스터 소유자(테넌트). Repo·yakcloud 는 DI. 크리덴셜은 DB/잡에 저장하지 않는다.
|
||||
// yakconsole services/route.ts 하강.
|
||||
|
||||
import type { ServiceTypeDTO } from "@yakcloud/ds-sdk";
|
||||
import { ApiError, accepted } from "../respond";
|
||||
import { requireOwnership, type SessionUser } from "../session";
|
||||
import { publicService } from "../serialize";
|
||||
import type { ServiceRepo, YakcloudClient, YakServiceSpecInput } from "./types";
|
||||
|
||||
// 플랜별 기본 스토리지(표시/기록용). 백엔드가 최종 결정하지만 DB엔 이 값을 저장.
|
||||
const DEFAULT_STORAGE: Record<string, number> = { small: 10, medium: 20, large: 50 };
|
||||
|
||||
// createServiceBody(zod)로 파싱된 입력 형태. bff-kit 은 스키마를 소유하지 않으므로 구조만 정의.
|
||||
export interface CreateServiceArgs {
|
||||
clusterId: string;
|
||||
type: ServiceTypeDTO;
|
||||
mode?: "local" | "remote";
|
||||
name: string;
|
||||
size: string;
|
||||
storageGb?: number | null;
|
||||
persist?: boolean | null;
|
||||
// remote 접속정보 — mode=remote 일 때만
|
||||
host?: string | null;
|
||||
port?: number | null;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
database?: string | null;
|
||||
vhost?: string | null;
|
||||
core?: string | null;
|
||||
dbNum?: number | null;
|
||||
}
|
||||
|
||||
// 쿼터·소유·이름중복 검증 → remote 즉시 등록 or local 프로비저닝 Job. 202 반환.
|
||||
export async function createService(
|
||||
repo: ServiceRepo,
|
||||
yakcloud: YakcloudClient,
|
||||
user: SessionUser,
|
||||
args: CreateServiceArgs,
|
||||
): Promise<Response> {
|
||||
const cluster = requireOwnership(await repo.getCluster(args.clusterId), user);
|
||||
if (cluster.status !== "ACTIVE") {
|
||||
throw new ApiError("VALIDATION_FAILED", "클러스터가 Active 여야 서비스를 만들 수 있습니다.");
|
||||
}
|
||||
|
||||
const me = await repo.getMe(user.id);
|
||||
const q = me?.quota;
|
||||
if (q && (await repo.countServicesByCluster(cluster.id)) >= q.maxServices) {
|
||||
throw new ApiError("QUOTA_EXCEEDED", undefined, { limit: q.maxServices });
|
||||
}
|
||||
if (await repo.isServiceNameTaken(cluster.id, args.name)) {
|
||||
throw new ApiError("NAME_TAKEN");
|
||||
}
|
||||
|
||||
// ── remote(외부 서버 연결): 프로비저닝 없이 즉시 등록. 비밀번호는 DB/잡에 저장하지 않고
|
||||
// yakcloud → 테넌트 Secret 으로만 전달. 성공 시 READY 행을 바로 생성.
|
||||
if (args.mode === "remote") {
|
||||
if (!yakcloud.configured() || !cluster.rancherClusterId) {
|
||||
throw new ApiError("VALIDATION_FAILED", "이 클러스터는 외부 연결을 지원하지 않습니다.");
|
||||
}
|
||||
const spec: YakServiceSpecInput = {
|
||||
type: args.type.toLowerCase(),
|
||||
name: args.name,
|
||||
mode: "remote",
|
||||
host: args.host,
|
||||
port: args.port,
|
||||
username: args.username,
|
||||
password: args.password,
|
||||
database: args.database,
|
||||
bucket: args.type === "MINIO" ? args.database : undefined, // minio 는 bucket 을 database 필드로 입력
|
||||
vhost: args.vhost,
|
||||
core: args.core,
|
||||
dbNum: args.dbNum,
|
||||
};
|
||||
let conn: { conn_secret_ref?: string; conn_info?: Record<string, unknown> | null };
|
||||
try {
|
||||
conn = await yakcloud.createService(cluster.rancherClusterId, spec);
|
||||
} catch (e) {
|
||||
throw new ApiError("INTERNAL", e instanceof Error ? e.message : "외부 연결 등록에 실패했습니다.");
|
||||
}
|
||||
const svc = await repo.createService({
|
||||
// 소스 소유자 = 클러스터 소유자(테넌트). 관리자가 남의 클러스터에 등록해도 소스가 새지 않게.
|
||||
ownerId: cluster.ownerId,
|
||||
clusterId: cluster.id,
|
||||
type: args.type,
|
||||
mode: "REMOTE",
|
||||
name: args.name,
|
||||
size: args.size,
|
||||
storageGb: 0,
|
||||
persist: false,
|
||||
});
|
||||
const updated = await repo.updateService(svc.id, {
|
||||
status: "READY",
|
||||
connSecretRef: conn.conn_secret_ref ?? args.name,
|
||||
connInfo: conn.conn_info ?? null,
|
||||
});
|
||||
await repo.addAudit({
|
||||
userId: user.id,
|
||||
action: "service.create",
|
||||
targetType: "service",
|
||||
targetId: svc.id,
|
||||
meta: { clusterId: cluster.id, type: args.type, name: args.name, mode: "remote" }, // 비밀 제외
|
||||
});
|
||||
return accepted(publicService(updated));
|
||||
}
|
||||
|
||||
// ── local(관리형 프로비저닝) ──
|
||||
const persist = args.persist ?? args.type !== "REDIS"; // redis 기본 비영속
|
||||
const storageGb = persist ? (args.storageGb ?? DEFAULT_STORAGE[args.size] ?? 0) : 0;
|
||||
const svc = await repo.createService({
|
||||
// 소스 소유자 = 클러스터 소유자(테넌트).
|
||||
ownerId: cluster.ownerId,
|
||||
clusterId: cluster.id,
|
||||
type: args.type,
|
||||
name: args.name,
|
||||
size: args.size,
|
||||
storageGb,
|
||||
persist,
|
||||
});
|
||||
await repo.enqueueJob({ kind: "service.provision", payload: { serviceId: svc.id } });
|
||||
await repo.addAudit({
|
||||
userId: user.id,
|
||||
action: "service.create",
|
||||
targetType: "service",
|
||||
targetId: svc.id,
|
||||
meta: { clusterId: cluster.id, type: args.type, name: args.name, size: args.size },
|
||||
});
|
||||
return accepted(publicService(svc));
|
||||
}
|
||||
124
packages/bff-kit/src/handlers/types.ts
Normal file
124
packages/bff-kit/src/handlers/types.ts
Normal file
@ -0,0 +1,124 @@
|
||||
// @yakcloud/bff-kit — handlers/types
|
||||
// 핸들러가 소비하는 최소 의존성 계약(DI). ⛔ Prisma/db/yakcloud-실구현 정적 import 금지.
|
||||
// 포털은 자기 Repo/yakcloud 클라이언트를 이 구조적 인터페이스로 주입한다.
|
||||
|
||||
import type { ServiceTypeDTO } from "@yakcloud/ds-sdk";
|
||||
import type { SessionUser } from "../session";
|
||||
import type { ServiceInput } from "../serialize";
|
||||
|
||||
// ── 도메인 형태(구조적, Prisma 모델과 호환) ──
|
||||
export interface ClusterRow {
|
||||
readonly id: string;
|
||||
readonly ownerId: string;
|
||||
readonly deletedAt?: Date | null;
|
||||
readonly status: string;
|
||||
readonly rancherClusterId?: string | null;
|
||||
readonly name: string;
|
||||
readonly displayName?: string;
|
||||
}
|
||||
|
||||
export interface DeploymentRow {
|
||||
readonly id: string;
|
||||
readonly clusterId: string;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export interface ServiceRow extends ServiceInput {
|
||||
readonly deletedAt?: Date | null;
|
||||
}
|
||||
|
||||
export interface BindingRow {
|
||||
readonly id: string;
|
||||
readonly serviceId: string;
|
||||
readonly alias: string;
|
||||
}
|
||||
|
||||
export interface QuotaRow {
|
||||
readonly maxServices: number;
|
||||
}
|
||||
|
||||
// ── Repo 계약(핸들러가 실제 호출하는 부분집합만) ──
|
||||
export interface ServiceRepo {
|
||||
getCluster(id: string): Promise<ClusterRow | null>;
|
||||
getMe(userId: string): Promise<{ quota: QuotaRow | null } | null>;
|
||||
countServicesByCluster(clusterId: string): Promise<number>;
|
||||
isServiceNameTaken(clusterId: string, name: string): Promise<boolean>;
|
||||
createService(input: {
|
||||
ownerId: string;
|
||||
clusterId: string;
|
||||
type: ServiceTypeDTO;
|
||||
mode?: "LOCAL" | "REMOTE";
|
||||
name: string;
|
||||
size: string;
|
||||
storageGb: number;
|
||||
persist: boolean;
|
||||
}): Promise<ServiceRow>;
|
||||
updateService(
|
||||
id: string,
|
||||
patch: { status?: string; connSecretRef?: string | null; connInfo?: unknown },
|
||||
): Promise<ServiceRow>;
|
||||
enqueueJob(input: { kind: string; payload: unknown }): Promise<unknown>;
|
||||
addAudit(input: {
|
||||
userId?: string | null;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId?: string | null;
|
||||
meta?: unknown;
|
||||
}): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface BindingRepo {
|
||||
getService(id: string): Promise<ServiceRow | null>;
|
||||
getCluster(id: string): Promise<ClusterRow | null>;
|
||||
getDeployment(id: string): Promise<DeploymentRow | null>;
|
||||
listBindingsByDeployment(deploymentId: string): Promise<BindingRow[]>;
|
||||
createBinding(input: {
|
||||
serviceId: string;
|
||||
deploymentId: string;
|
||||
alias: string;
|
||||
injectedKeys: string[];
|
||||
}): Promise<{ id: string }>;
|
||||
updateDeployment(
|
||||
id: string,
|
||||
patch: { status?: string; statusMessage?: string | null },
|
||||
): Promise<unknown>;
|
||||
enqueueJob(input: { kind: string; payload: unknown }): Promise<unknown>;
|
||||
addAudit(input: {
|
||||
userId?: string | null;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId?: string | null;
|
||||
meta?: unknown;
|
||||
}): Promise<unknown>;
|
||||
}
|
||||
|
||||
// ── yakcloud 클라이언트 계약(원격 서비스 등록에 쓰는 부분집합만) ──
|
||||
export interface YakServiceSpecInput {
|
||||
type: string; // 소문자 서비스 타입
|
||||
name: string;
|
||||
mode?: "local" | "remote";
|
||||
host?: string | null;
|
||||
port?: number | null;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
database?: string | null;
|
||||
bucket?: string | null;
|
||||
vhost?: string | null;
|
||||
core?: string | null;
|
||||
dbNum?: number | null;
|
||||
}
|
||||
|
||||
export interface YakServiceResultInput {
|
||||
conn_secret_ref?: string;
|
||||
conn_info?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface YakcloudClient {
|
||||
configured(): boolean;
|
||||
createService(cluster: string, spec: YakServiceSpecInput): Promise<YakServiceResultInput>;
|
||||
}
|
||||
|
||||
// 핸들러 공통 의존성 번들.
|
||||
export interface HandlerDeps {
|
||||
readonly user: SessionUser;
|
||||
}
|
||||
@ -1,4 +1,37 @@
|
||||
// @yakcloud/bff-kit
|
||||
// 서버·BFF: respond/session/ratelimit/serialize/handlers. ⛔ api-client 참조 금지.
|
||||
// B1 스켈레톤 — 실제 추출은 후속(scaffold-input.md 판정표 참조).
|
||||
export const __package = "@yakcloud/bff-kit";
|
||||
// ApiCode/CODE_STATUS 는 ds-sdk 리터럴 재사용. auth 는 auth-adapter 인터페이스를 DI(정적 import 하지 않음).
|
||||
|
||||
// ── respond (HTTP 봉투·오류) ──
|
||||
export { ok, fail, accepted, handle, parseBody, ApiError, CODE_STATUS } from "./respond";
|
||||
export type { ApiCode } from "./respond";
|
||||
|
||||
// ── session (인증/인가 정책 — getSessionUser DI) ──
|
||||
export {
|
||||
requireUser,
|
||||
requireOwnership,
|
||||
requireOwnershipAllowDeleted,
|
||||
requireAdmin,
|
||||
requireServiceToken,
|
||||
ipv4InCidr,
|
||||
} from "./session";
|
||||
export type {
|
||||
SessionUser,
|
||||
SessionUserProvider,
|
||||
OwnableCluster,
|
||||
AdminGateOptions,
|
||||
} from "./session";
|
||||
|
||||
// ── ratelimit (교체 가능 백엔드) ──
|
||||
export { rateLimit, MemoryRatelimiter, defaultRatelimiter, BUCKETS } from "./ratelimit";
|
||||
export type { Bucket, Ratelimiter } from "./ratelimit";
|
||||
|
||||
// ── serialize (DTO 마스킹 — Prisma-free 구조적 입력) ──
|
||||
export { publicService, publicDeployment, envOf } from "./serialize";
|
||||
export type { ServiceInput, DeploymentInput, PublicServiceExtra } from "./serialize";
|
||||
|
||||
// ── handlers (재사용 라우트 로직 — Repo + yakcloud DI) ──
|
||||
export * from "./handlers";
|
||||
|
||||
// ── auth-adapter 인터페이스 재export (type-only; 구현체는 정적 import 하지 않음) ──
|
||||
export type { AuthAdapter, Session, User } from "@yakcloud/auth-adapter/core";
|
||||
|
||||
47
packages/bff-kit/src/ratelimit.ts
Normal file
47
packages/bff-kit/src/ratelimit.ts
Normal file
@ -0,0 +1,47 @@
|
||||
// @yakcloud/bff-kit — ratelimit
|
||||
// per-user 토큰 버킷(슬라이딩 윈도우). 한도 초과 시 RATE_LIMITED throw(detail.retryAfterSec 포함).
|
||||
// 백엔드 교체 가능(Ratelimiter 인터페이스) — 기본은 in-memory Map, prod 는 Redis(INCR+EXPIRE) 주입.
|
||||
// yakconsole api/ratelimit.ts 하강.
|
||||
|
||||
import { ApiError } from "./respond";
|
||||
|
||||
// write 10/min, kubeconfig 5/min, terminal 30/min (§4.1). terminal 은 탭 열기/재연결로 자주 호출되므로 분리.
|
||||
export const BUCKETS = {
|
||||
write: { limit: 10, windowMs: 60_000 },
|
||||
kubeconfig: { limit: 5, windowMs: 60_000 },
|
||||
terminal: { limit: 30, windowMs: 60_000 },
|
||||
} as const;
|
||||
export type Bucket = keyof typeof BUCKETS;
|
||||
|
||||
// 교체 가능한 rate limiter 백엔드 계약. 한도 초과 시 ApiError('RATE_LIMITED') throw.
|
||||
export interface Ratelimiter {
|
||||
check(userId: string, bucket: Bucket): void | Promise<void>;
|
||||
}
|
||||
|
||||
// in-memory 슬라이딩 윈도우 구현. 모듈 레벨 Map 은 재시작 시 초기화·인스턴스 간 미공유 → prod 는 Redis.
|
||||
export class MemoryRatelimiter implements Ratelimiter {
|
||||
private readonly hits = new Map<string, number[]>(); // key=`${bucket}:${userId}` -> timestamps
|
||||
|
||||
check(userId: string, bucket: Bucket): void {
|
||||
const { limit, windowMs } = BUCKETS[bucket];
|
||||
const key = `${bucket}:${userId}`;
|
||||
const now = Date.now();
|
||||
const arr = (this.hits.get(key) ?? []).filter((t) => now - t < windowMs);
|
||||
if (arr.length >= limit) {
|
||||
const first = arr[0] ?? now;
|
||||
const retry = Math.ceil((windowMs - (now - first)) / 1000);
|
||||
throw new ApiError("RATE_LIMITED", undefined, { retryAfterSec: retry });
|
||||
}
|
||||
arr.push(now);
|
||||
this.hits.set(key, arr);
|
||||
}
|
||||
}
|
||||
|
||||
// 기본(프로세스 전역) limiter. 핸들러가 별도 limiter 를 주입하지 않으면 이걸 쓴다.
|
||||
export const defaultRatelimiter: Ratelimiter = new MemoryRatelimiter();
|
||||
|
||||
// 편의 함수: 기본 limiter 로 검사. rateLimit(userId, bucket) throws RATE_LIMITED → 호출자는 handle() 로 감싼다.
|
||||
export function rateLimit(userId: string, bucket: Bucket): void {
|
||||
const r = defaultRatelimiter.check(userId, bucket);
|
||||
void r; // MemoryRatelimiter 는 동기지만 인터페이스는 Promise 도 허용.
|
||||
}
|
||||
107
packages/bff-kit/src/respond.ts
Normal file
107
packages/bff-kit/src/respond.ts
Normal file
@ -0,0 +1,107 @@
|
||||
// @yakcloud/bff-kit — respond
|
||||
// HTTP 응답 봉투 & 오류 처리. web 표준 Request/Response(Fetch API)만 사용 — ⛔ next 정적 import 금지.
|
||||
// ApiCode/CODE_STATUS 는 ds-sdk 리터럴 재사용(서버→클라 역참조 방지). yakconsole api/respond.ts 하강.
|
||||
|
||||
import { ZodError, z } from "zod";
|
||||
import { CODE_STATUS } from "@yakcloud/ds-sdk";
|
||||
import type { ApiCode } from "@yakcloud/ds-sdk";
|
||||
|
||||
// ds-sdk 계약 재export — bff-kit 소비자가 한 곳에서 코드 맵을 얻도록.
|
||||
export { CODE_STATUS };
|
||||
export type { ApiCode };
|
||||
|
||||
// 기본 한국어 사용자 메시지 (i18n 은 §6-4). detail 은 기계 판독용.
|
||||
const DEFAULT_MSG: Record<ApiCode, string> = {
|
||||
UNAUTHORIZED: "로그인이 필요합니다.",
|
||||
FORBIDDEN: "권한이 없습니다.",
|
||||
NOT_FOUND: "찾을 수 없습니다.",
|
||||
VALIDATION_FAILED: "입력값이 올바르지 않습니다.",
|
||||
NAME_TAKEN: "이미 사용 중인 이름입니다.",
|
||||
CONFLICT: "다른 리소스가 사용 중이라 처리할 수 없습니다.",
|
||||
NAME_RESERVED: "시스템이 예약한 이름이라 사용할 수 없습니다.",
|
||||
QUOTA_EXCEEDED: "쿼터를 초과했습니다.",
|
||||
CAPACITY_UNAVAILABLE: "현재 용량이 부족합니다. 잠시 후 다시 시도해 주세요.",
|
||||
RANCHER_UPSTREAM_ERROR: "백엔드 통신 오류가 발생했습니다.",
|
||||
RATE_LIMITED: "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.",
|
||||
NOT_IMPLEMENTED: "아직 제공되지 않는 기능입니다.",
|
||||
INTERNAL: "서버 오류가 발생했습니다.",
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly code: ApiCode;
|
||||
readonly status: number;
|
||||
readonly detail?: unknown;
|
||||
constructor(code: ApiCode, message?: string, detail?: unknown) {
|
||||
super(message ?? DEFAULT_MSG[code]);
|
||||
this.name = "ApiError";
|
||||
this.code = code;
|
||||
this.status = CODE_STATUS[code];
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
// success 봉투: { data }. init 으로 상태·헤더 설정 가능.
|
||||
export function ok<T>(data: T, init?: ResponseInit): Response {
|
||||
return Response.json({ data }, init);
|
||||
}
|
||||
|
||||
// 202 Accepted + (선택) Location 헤더 — 비동기 프로비저닝 응답.
|
||||
export function accepted<T>(data: T, location?: string): Response {
|
||||
const headers = location ? { Location: location } : undefined;
|
||||
return Response.json({ data }, { status: 202, headers });
|
||||
}
|
||||
|
||||
// error 봉투: { error: { code, message, detail? } }
|
||||
export function fail(err: ApiError): Response {
|
||||
const body = {
|
||||
error: {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
...(err.detail !== undefined ? { detail: err.detail } : {}),
|
||||
},
|
||||
};
|
||||
return Response.json(body, { status: err.status });
|
||||
}
|
||||
|
||||
// handle(): 라우트 본문을 감싸 던진 ApiError/ZodError/Prisma P2002/미지 오류를 봉투로 직렬화.
|
||||
// ⚠️ Prisma 를 정적 import 하지 않는다 — 던져진 오류의 구조(code === "P2002")만 덕타이핑으로 검사.
|
||||
export function handle<A extends unknown[]>(
|
||||
fn: (...args: A) => Promise<Response> | Response,
|
||||
): (...args: A) => Promise<Response> {
|
||||
return async (...args: A): Promise<Response> => {
|
||||
try {
|
||||
return await fn(...args);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) return fail(e);
|
||||
if (e instanceof ZodError) {
|
||||
// zod v4: 인스턴스 .flatten() 제거됨 → z.flattenError 사용.
|
||||
return fail(new ApiError("VALIDATION_FAILED", undefined, z.flattenError(e)));
|
||||
}
|
||||
// Prisma 유니크 위반(P2002): TOCTOU 경합에서 발생 가능 → 409(재시도 가능)로 매핑.
|
||||
if (
|
||||
e !== null &&
|
||||
typeof e === "object" &&
|
||||
"code" in e &&
|
||||
(e as { code?: unknown }).code === "P2002"
|
||||
) {
|
||||
return fail(new ApiError("NAME_TAKEN", "값이 충돌했습니다. 잠시 후 다시 시도해 주세요."));
|
||||
}
|
||||
console.error("[bff] unhandled", e);
|
||||
return fail(new ApiError("INTERNAL"));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// JSON 본문을 zod 스키마로 파싱·검증 (ZodError → 422 via handle).
|
||||
export async function parseBody<S extends z.ZodType>(
|
||||
req: Request,
|
||||
schema: S,
|
||||
): Promise<z.infer<S>> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await req.json();
|
||||
} catch {
|
||||
throw new ApiError("VALIDATION_FAILED", "요청 본문이 JSON이 아닙니다.");
|
||||
}
|
||||
return schema.parse(raw) as z.infer<S>;
|
||||
}
|
||||
75
packages/bff-kit/src/serialize.ts
Normal file
75
packages/bff-kit/src/serialize.ts
Normal file
@ -0,0 +1,75 @@
|
||||
// @yakcloud/bff-kit — serialize
|
||||
// DTO 마스킹·필터. Prisma-free: 전체 모델 대신 필요한 필드만 가진 구조적 입력 타입을 받는다.
|
||||
// connSecretRef(내부 Secret 이름) 및 시크릿 env 값은 절대 출력에 노출하지 않는다(마스킹 계약).
|
||||
// yakconsole api/serialize.ts 하강.
|
||||
|
||||
import type { EnvVarDTO } from "@yakcloud/ds-sdk";
|
||||
|
||||
// ── 구조적 입력 타입(Repo 결과에서 핸들러가 구성; Prisma 모델도 구조적으로 호환) ──
|
||||
|
||||
// 날짜는 Repo 가 Date 로 주든 ISO string 으로 주든 그대로 통과시킨다(직렬화는 Response.json 소관).
|
||||
type DateLike = Date | string;
|
||||
|
||||
export interface DeploymentInput {
|
||||
env: unknown; // Deployment.env(Json) — 배열이 아니면 []
|
||||
[k: string]: unknown; // 나머지 필드는 그대로 스프레드(구조적)
|
||||
}
|
||||
|
||||
export interface ServiceInput {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
clusterId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
mode: string;
|
||||
status: string;
|
||||
statusMessage: string | null;
|
||||
size: string;
|
||||
storageGb: number;
|
||||
persist: boolean;
|
||||
connSecretRef?: string | null; // ⛔ 출력에서 제외(마스킹)
|
||||
connInfo: unknown; // 비밀 제외 접속 메타 JSON
|
||||
createdAt: DateLike;
|
||||
updatedAt: DateLike;
|
||||
}
|
||||
|
||||
export interface PublicServiceExtra {
|
||||
clusterName?: string;
|
||||
bindings?: { app: string; alias: string }[];
|
||||
}
|
||||
|
||||
// Deployment.env(Json) → EnvVar[]. 배열이 아니면 빈 배열.
|
||||
export function envOf(dep: DeploymentInput): EnvVarDTO[] {
|
||||
const raw = dep.env;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw as EnvVarDTO[];
|
||||
}
|
||||
|
||||
// API 응답용: 시크릿 환경변수의 값을 마스킹(빈 문자열)해 클라이언트로 노출하지 않는다.
|
||||
export function publicDeployment<D extends DeploymentInput>(dep: D): Omit<D, "env"> & { env: EnvVarDTO[] } {
|
||||
const env = envOf(dep).map((e) => (e.secret ? { key: e.key, value: "", secret: true } : e));
|
||||
return { ...dep, env };
|
||||
}
|
||||
|
||||
// 외부 서비스 응답 DTO — connSecretRef 미노출, connInfo(host/port/db/bucket)만.
|
||||
// 크리덴셜은 애초에 포털에 저장되지 않으므로 여기서 마스킹할 비밀이 없다(설계 C).
|
||||
export function publicService(svc: ServiceInput, extra?: PublicServiceExtra) {
|
||||
return {
|
||||
id: svc.id,
|
||||
ownerId: svc.ownerId,
|
||||
clusterId: svc.clusterId,
|
||||
name: svc.name,
|
||||
type: svc.type,
|
||||
mode: svc.mode,
|
||||
status: svc.status,
|
||||
statusMessage: svc.statusMessage,
|
||||
size: svc.size,
|
||||
storageGb: svc.storageGb,
|
||||
persist: svc.persist,
|
||||
connInfo: (svc.connInfo as Record<string, unknown> | null) ?? null,
|
||||
createdAt: svc.createdAt,
|
||||
updatedAt: svc.updatedAt,
|
||||
...(extra?.clusterName !== undefined ? { clusterName: extra.clusterName } : {}),
|
||||
...(extra?.bindings !== undefined ? { bindings: extra.bindings } : {}),
|
||||
};
|
||||
}
|
||||
118
packages/bff-kit/src/session.ts
Normal file
118
packages/bff-kit/src/session.ts
Normal file
@ -0,0 +1,118 @@
|
||||
// @yakcloud/bff-kit — session
|
||||
// 인증/인가 정책 강제. auth-adapter 인터페이스를 DI 로 받는다 — ⛔ next-auth/next/prisma/db 정적 import 금지.
|
||||
// getSessionUser 는 포털이 주입(next-auth + DEV_BYPASS + Repo 조회). 여기는 정책만 알고 조회는 모른다.
|
||||
// yakconsole api/session.ts 하강.
|
||||
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import type { User as AuthUser } from "@yakcloud/auth-adapter/core";
|
||||
import { ApiError } from "./respond";
|
||||
|
||||
// ── 구조적 입력 타입(Prisma-free) ──
|
||||
// 포털 User 의 정책 판단에 필요한 최소 필드만. auth-adapter 의 User 와 호환(role/status).
|
||||
export type SessionUser = AuthUser;
|
||||
|
||||
// 소유권 검사에 필요한 클러스터 최소 필드만(구조적). 전체 Prisma 모델을 통과시켜도 호환된다.
|
||||
export interface OwnableCluster {
|
||||
readonly ownerId: string;
|
||||
readonly deletedAt?: Date | null;
|
||||
}
|
||||
|
||||
// 현재 세션 → 포털 User(또는 null). 포털이 주입(DI).
|
||||
export type SessionUserProvider = (req: Request) => Promise<SessionUser | null>;
|
||||
|
||||
// /api/v1 핸들러용: 유저 또는 UNAUTHORIZED throw. SUSPENDED 는 FORBIDDEN.
|
||||
export async function requireUser(
|
||||
req: Request,
|
||||
getSessionUser: SessionUserProvider,
|
||||
): Promise<SessionUser> {
|
||||
const user = await getSessionUser(req);
|
||||
if (!user) throw new ApiError("UNAUTHORIZED");
|
||||
if (user.status === "SUSPENDED") throw new ApiError("FORBIDDEN", "정지된 계정입니다.");
|
||||
return user;
|
||||
}
|
||||
|
||||
// 소유권: 남의(또는 삭제된) 클러스터는 403 아닌 404 로 존재를 숨김.
|
||||
// ADMIN 은 삭제되지 않은 어떤 사용자의 클러스터도 열람·관리 가능(운영자 조망).
|
||||
export function requireOwnership<C extends OwnableCluster>(
|
||||
cluster: C | null,
|
||||
user: SessionUser,
|
||||
): C {
|
||||
if (!cluster || cluster.deletedAt) throw new ApiError("NOT_FOUND");
|
||||
if (user.role !== "ADMIN" && cluster.ownerId !== user.id) throw new ApiError("NOT_FOUND");
|
||||
return cluster;
|
||||
}
|
||||
|
||||
// 소유권만 검사(삭제된 클러스터도 허용) — 삭제된 클러스터의 고아 데이터소스 조회/삭제용.
|
||||
export function requireOwnershipAllowDeleted<C extends OwnableCluster>(
|
||||
cluster: C | null,
|
||||
user: SessionUser,
|
||||
): C {
|
||||
if (!cluster) throw new ApiError("NOT_FOUND");
|
||||
if (user.role !== "ADMIN" && cluster.ownerId !== user.id) throw new ApiError("NOT_FOUND");
|
||||
return cluster;
|
||||
}
|
||||
|
||||
// /admin: role=ADMIN AND source IP ∈ VPN CIDR. 어떤 실패든 404 (design §2.5-⑦).
|
||||
export async function requireAdmin(
|
||||
req: Request,
|
||||
getSessionUser: SessionUserProvider,
|
||||
opts?: AdminGateOptions,
|
||||
): Promise<SessionUser> {
|
||||
try {
|
||||
const user = await requireUser(req, getSessionUser);
|
||||
if (user.role !== "ADMIN") throw new ApiError("NOT_FOUND");
|
||||
if (!sourceIpAllowed(req, opts)) throw new ApiError("NOT_FOUND");
|
||||
return user;
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.code !== "NOT_FOUND") throw new ApiError("NOT_FOUND");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdminGateOptions {
|
||||
/** 허용 CIDR 목록. 미지정 시 process.env.ADMIN_VPN_CIDRS(콤마 구분) 사용. */
|
||||
readonly vpnCidrs?: readonly string[];
|
||||
/** CIDR 미설정 시 dev 는 허용·prod 는 거부. 미지정 시 process.env.NODE_ENV 로 판정. */
|
||||
readonly allowWhenUnset?: boolean;
|
||||
}
|
||||
|
||||
function sourceIpAllowed(req: Request, opts?: AdminGateOptions): boolean {
|
||||
const cidrs = (
|
||||
opts?.vpnCidrs ??
|
||||
(process.env.ADMIN_VPN_CIDRS ?? "").split(",")
|
||||
)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
// 미설정: dev 는 허용, prod-misconfig 는 거부.
|
||||
if (cidrs.length === 0) {
|
||||
return opts?.allowWhenUnset ?? process.env.NODE_ENV !== "production";
|
||||
}
|
||||
// 최좌측 XFF 는 위조 가능하므로 신뢰 금지. 신뢰 프록시(ingress)가 세팅하는 x-real-ip 를 우선 사용,
|
||||
// 없으면 XFF 최우측(마지막 신뢰 홉이 append 한 값)을 쓴다.
|
||||
const xff = req.headers.get("x-forwarded-for");
|
||||
const ip = (req.headers.get("x-real-ip") ?? (xff ? (xff.split(",").pop() ?? "") : null) ?? "").trim();
|
||||
return cidrs.some((c) => ipv4InCidr(ip, c));
|
||||
}
|
||||
|
||||
// ── CIDR 유틸(프레임워크 무관 순수 함수) ──
|
||||
export function ipv4InCidr(ip: string, cidr: string): boolean {
|
||||
const [range, bitsStr] = cidr.split("/");
|
||||
if (range === undefined) return false;
|
||||
const bits = Number(bitsStr ?? 32);
|
||||
const toInt = (s: string): number =>
|
||||
s.split(".").reduce((a, o) => (a << 8) + (Number(o) & 255), 0) >>> 0;
|
||||
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) return false;
|
||||
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
|
||||
return (toInt(ip) & mask) === (toInt(range) & mask);
|
||||
}
|
||||
|
||||
// /internal/* 용 서비스 토큰 게이트(워커·edge-sync). 길이 분기(타이밍 오라클) 없이
|
||||
// 양쪽을 고정 길이 SHA-256 다이제스트로 만들어 상수시간 비교. 미설정 => 거부(404).
|
||||
export function requireServiceToken(req: Request, expectedToken?: string): void {
|
||||
const expected = expectedToken ?? process.env.INTERNAL_SERVICE_TOKEN;
|
||||
const got = req.headers.get("x-internal-token") ?? "";
|
||||
if (!expected) throw new ApiError("NOT_FOUND");
|
||||
const a = createHash("sha256").update(got).digest();
|
||||
const b = createHash("sha256").update(expected).digest();
|
||||
if (!timingSafeEqual(a, b)) throw new ApiError("NOT_FOUND");
|
||||
}
|
||||
Reference in New Issue
Block a user