diff --git a/packages/auth-adapter/package.json b/packages/auth-adapter/package.json index f43fa31..2b8bcb9 100644 --- a/packages/auth-adapter/package.json +++ b/packages/auth-adapter/package.json @@ -10,6 +10,14 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./core": { + "types": "./dist/core/index.d.ts", + "import": "./dist/core/index.js" + }, + "./dev-bypass": { + "types": "./dist/dev-bypass/index.d.ts", + "import": "./dist/dev-bypass/index.js" } }, "files": [ @@ -26,5 +34,8 @@ }, "peerDependencies": { "next-auth": ">=5" + }, + "devDependencies": { + "@types/node": "^20" } } diff --git a/packages/auth-adapter/src/core/helpers.ts b/packages/auth-adapter/src/core/helpers.ts new file mode 100644 index 0000000..23c0889 --- /dev/null +++ b/packages/auth-adapter/src/core/helpers.ts @@ -0,0 +1,66 @@ +// @yakcloud/auth-adapter — core/helpers +// 순수 함수. ⛔ next-auth / next / prisma 정적 import 금지. ds-sdk 는 type-only. + +import { CODE_STATUS } from "@yakcloud/ds-sdk"; +import type { ApiCode } from "@yakcloud/ds-sdk"; +import type { AuthAdapter, Session, User } from "./types"; + +/** + * 인증/인가 실패를 나타내는 프레임워크 무관 에러. + * bff-kit 의 respond.handle() 이 code → HTTP status 로 변환한다(ds-sdk CODE_STATUS 재사용). + */ +export class AuthError extends Error { + readonly code: ApiCode; + readonly status: number; + constructor(code: ApiCode, message?: string) { + super(message ?? code); + this.name = "AuthError"; + this.code = code; + this.status = CODE_STATUS[code]; + } +} + +/** + * 요청에서 세션을 해석한다. 미인증이면 null. + * 어댑터 구현(next-auth 등)은 DI 로 주입된다. + */ +export async function getSession( + req: Request, + adapter: AuthAdapter, + ctx?: C, +): Promise { + return adapter.resolveSession(req, ctx); +} + +/** + * 세션 → 포털 User 로 승격하는 리졸버. + * ⚠️ auth-adapter 는 Repo 를 모른다: consuming app(yakconsole) 이 + * getSessionUser() (Repo 조회/JIT upsert)를 이 시그니처로 주입한다. + */ +export type UserResolver = ( + session: S, +) => Promise; + +/** + * 인증된 포털 User 를 반환하거나 throw. + * - 세션 없음/User 없음 → UNAUTHORIZED + * - status === "SUSPENDED" → FORBIDDEN + * + * @param req Fetch API Request. + * @param adapter 세션 해석 어댑터(DI). + * @param resolve 세션 → 포털 User 리졸버(DI; Repo 접근은 여기 안에서). + * @param ctx 어댑터별 컨텍스트(선택). + */ +export async function requireUser( + req: Request, + adapter: AuthAdapter, + resolve: UserResolver, + ctx?: C, +): Promise { + const session = await getSession(req, adapter, ctx); + if (!session) throw new AuthError("UNAUTHORIZED"); + const user = await resolve(session); + if (!user) throw new AuthError("UNAUTHORIZED"); + if (user.status === "SUSPENDED") throw new AuthError("FORBIDDEN", "정지된 계정입니다."); + return user; +} diff --git a/packages/auth-adapter/src/core/index.ts b/packages/auth-adapter/src/core/index.ts new file mode 100644 index 0000000..1340a05 --- /dev/null +++ b/packages/auth-adapter/src/core/index.ts @@ -0,0 +1,5 @@ +// @yakcloud/auth-adapter — core +// 프레임워크 무관 계약 재export. 런타임 의존 0 (ds-sdk 는 type/const 리프). +export type { AuthAdapter, Session, User, Role, UserStatus } from "./types"; +export { AuthError, getSession, requireUser } from "./helpers"; +export type { UserResolver } from "./helpers"; diff --git a/packages/auth-adapter/src/core/types.ts b/packages/auth-adapter/src/core/types.ts new file mode 100644 index 0000000..72648fa --- /dev/null +++ b/packages/auth-adapter/src/core/types.ts @@ -0,0 +1,59 @@ +// @yakcloud/auth-adapter — core/types +// 프레임워크 무관 인증 계약. ⛔ next-auth / next / prisma 정적 import 금지. +// 여기 있는 것은 순수 타입뿐이며 런타임 코드는 없다. + +/** 사용자 역할. Keycloak realm_access.roles → 정규화. */ +export type Role = "USER" | "ADMIN"; + +/** 포털 사용자 계정 상태. */ +export type UserStatus = "ACTIVE" | "SUSPENDED"; + +/** + * Session = IdP(JWT) 관점의 최소 세션 뷰. + * 클라이언트에 노출되는 표면은 최소화한다: access_token/refresh_token 은 절대 포함하지 않는다. + * (§4.4 kubeconfig 교환은 서버에서 getToken() 으로만 원 토큰을 읽는다.) + */ +export interface Session { + /** Keycloak subject (sub) — Repo 사용자 조회 키. */ + readonly sub: string; + readonly email?: string; + readonly displayName?: string; + readonly role: Role; + /** + * 서버 전용(선택). 클라이언트 세션에는 노출하지 않는다. + * getToken() 을 통해서만 채워지는 백채널 액세스 토큰. + */ + readonly accessToken?: string; + /** refresh 실패 등 오류 신호(예: "RefreshAccessTokenError"). */ + readonly error?: string; +} + +/** + * User = 포털(Repo) 사용자 모델의 프레임워크 무관 뷰. + * ⚠️ Prisma 모델이 아니다 — 필요한 필드만 가진 구조적 인터페이스. + * 실제 Repo 조회/upsert 는 consuming app(yakconsole) 에서 수행한다. + */ +export interface User { + readonly id: string; + readonly email: string; + readonly displayName: string; + readonly role: Role; + readonly status: UserStatus; +} + +/** + * AuthAdapter = 교체 가능한 세션 해석기. + * - C: 어댑터별 컨텍스트(예: 쿠키 저장소 핸들). 기본 unknown. + * - S: 어댑터가 반환하는 세션 형태(기본 Session). + * + * bff-kit / middleware 는 이 인터페이스만 알고, 구체 구현(next-auth 등)은 주입(DI)받는다. + * ⛔ 어떤 패키지도 auth-adapter 구현체를 정적 import 하지 않는다. + */ +export interface AuthAdapter { + /** + * 요청에서 세션을 해석한다. 미인증이면 null. + * @param req Fetch API 표준 Request. + * @param ctx 어댑터별 컨텍스트(선택). + */ + resolveSession(req: Request, ctx?: C): Promise; +} diff --git a/packages/auth-adapter/src/dev-bypass/index.ts b/packages/auth-adapter/src/dev-bypass/index.ts new file mode 100644 index 0000000..b58912f --- /dev/null +++ b/packages/auth-adapter/src/dev-bypass/index.ts @@ -0,0 +1,42 @@ +// @yakcloud/auth-adapter — dev-bypass +// IdP(Keycloak) 가 없을 때만 활성화되는 mock 세션 제공자. +// ⛔ IdP/Keycloak/next-auth 정적 import 금지. consuming app 에서 조건부로만 사용. + +import type { Role, Session, User } from "../core/types"; + +/** + * DEV-BYPASS: KEYCLOAK_ISSUER 가 없을 때만 true. + * 어떤 환경이든 KEYCLOAK_ISSUER 를 설정하면 결정적으로 false (잊을 수 있는 별도 opt-in 플래그 없음). + */ +export const DEV_BYPASS: boolean = !process.env.KEYCLOAK_ISSUER; + +/** dev-bypass 전용 개발 신원(seed 와 이메일 일치). */ +export interface DevIdentity { + readonly id: string; + readonly keycloakSub: string; + readonly email: string; + readonly displayName: string; + readonly role: Role; +} + +export const DEV_USER: DevIdentity = { + id: "usr_dev", + keycloakSub: "dev-sub-yakenator", + email: "yakenator@gmail.com", + displayName: "최정우", + role: "USER", +}; + +/** + * mock 세션 팩토리. DevIdentity(또는 포털 User) 로부터 Session 을 만든다. + * consuming app 의 getSessionUser() 가 DEV_BYPASS 분기에서 사용. + */ +export function createDevBypassSession(user: DevIdentity | User): Session { + const sub = "keycloakSub" in user ? user.keycloakSub : user.id; + return { + sub, + email: user.email, + displayName: user.displayName, + role: user.role, + }; +} diff --git a/packages/auth-adapter/src/index.ts b/packages/auth-adapter/src/index.ts index bbac82c..62d5a8c 100644 --- a/packages/auth-adapter/src/index.ts +++ b/packages/auth-adapter/src/index.ts @@ -1,4 +1,24 @@ // @yakcloud/auth-adapter -// 인증 어댑터(대부분 신규): IdP 인터페이스, auth.ts 씨앗. ⛔ 클라 패키지 정적 import 금지. -// B1 스켈레톤 — 실제 추출은 후속(scaffold-input.md 판정표 참조). -export const __package = "@yakcloud/auth-adapter"; +// 인증 어댑터: (1) core = 프레임워크 무관 계약, (2) dev-bypass = mock 세션, +// (3) next-auth = Next.js + Keycloak 격리 구현(peer: next-auth). +// +// ⚠️ 이 배럴은 next-auth 팩토리도 재export 하므로, 이 모듈을 통째로 import 하면 +// next-auth 를 정적 로드한다. 프레임워크 무관 소비자(bff-kit 등)는 +// "@yakcloud/auth-adapter/core" / "/dev-bypass" 서브패스로만 import 할 것. +// (bff-kit 은 AuthAdapter 를 DI 로 받고 이 패키지를 deps 에 넣지 않는다.) + +// ── core (framework-free) ── +export type { AuthAdapter, Session, User, Role, UserStatus, UserResolver } from "./core"; +export { AuthError, getSession, requireUser } from "./core"; + +// ── dev-bypass (opt-in mock; IdP 미가용 시) ── +export { DEV_BYPASS, DEV_USER, createDevBypassSession } from "./dev-bypass"; +export type { DevIdentity } from "./dev-bypass"; + +// ── next-auth (⚠ peer: next-auth >=5 필요) ── +export { createAuthConfig, auth, handlers, signIn, signOut } from "./next-auth"; +export type { AuthConfigOptions } from "./next-auth"; +export { createAuthMiddleware, matcher } from "./next-auth/middleware"; +export { getToken } from "./next-auth/handlers"; +export type { KeycloakToken } from "./next-auth/keycloak"; +export { makeKeycloak, keycloakProviders, refreshKeycloakToken } from "./next-auth/keycloak"; diff --git a/packages/auth-adapter/src/next-auth/augment.d.ts b/packages/auth-adapter/src/next-auth/augment.d.ts new file mode 100644 index 0000000..78a0d50 --- /dev/null +++ b/packages/auth-adapter/src/next-auth/augment.d.ts @@ -0,0 +1,28 @@ +// @yakcloud/auth-adapter — next-auth 모듈 보강(augmentation) +// session 콜백이 노출하는 최소 필드(keycloakSub, role, error) 와 +// JWT 에 보관하는 Keycloak 토큰 필드의 타입을 next-auth 에 주입한다. +// ⚠️ next-auth 하위 구현에만 관계 — core/dev-bypass 는 이 파일과 무관. + +import "next-auth"; +import "next-auth/jwt"; + +declare module "next-auth" { + interface Session { + user: { + keycloakSub?: string; + role?: "USER" | "ADMIN"; + } & import("next-auth").DefaultSession["user"]; + error?: string; + } +} + +declare module "next-auth/jwt" { + interface JWT { + access_token?: string; + refresh_token?: string; + id_token?: string; + expires_at?: number; + role?: "USER" | "ADMIN"; + error?: string; + } +} diff --git a/packages/auth-adapter/src/next-auth/handlers.ts b/packages/auth-adapter/src/next-auth/handlers.ts new file mode 100644 index 0000000..769d11f --- /dev/null +++ b/packages/auth-adapter/src/next-auth/handlers.ts @@ -0,0 +1,24 @@ +// @yakcloud/auth-adapter — next-auth/handlers +// Next.js app 용 라우트 핸들러 재export + 서버측 Keycloak 토큰 접근(getToken). +// next-auth/jwt 는 peerDependency. + +import { getToken as nextAuthGetToken } from "next-auth/jwt"; +import type { KeycloakToken } from "./keycloak"; + +export { auth, handlers, signIn, signOut } from "./index"; + +/** + * 서버 라우트에서 Keycloak 토큰(access/refresh/id)을 직접 읽는다. + * §4.4 kubeconfig 교환처럼 원 access_token 이 필요할 때만 사용(클라이언트엔 노출 금지). + * + * @param req Fetch API Request(App Router route handler 의 req). + * @param secret AUTH_SECRET (기본 process.env.AUTH_SECRET). + */ +export async function getToken( + req: Request, + secret = process.env.AUTH_SECRET, +): Promise { + if (!secret) return null; + const token = await nextAuthGetToken({ req, secret }); + return (token as KeycloakToken | null) ?? null; +} diff --git a/packages/auth-adapter/src/next-auth/index.ts b/packages/auth-adapter/src/next-auth/index.ts new file mode 100644 index 0000000..7f7de72 --- /dev/null +++ b/packages/auth-adapter/src/next-auth/index.ts @@ -0,0 +1,83 @@ +// @yakcloud/auth-adapter — next-auth +// NextAuth 초기화 격리 계층. next-auth 는 peerDependency (consuming app 이 제공). +// ⚠️ 이 모듈을 import 하면 next-auth 를 정적 로드한다 — dev-bypass/core 만 필요하면 import 하지 말 것. + +/// +import NextAuth from "next-auth"; +import type { NextAuthConfig, NextAuthResult } from "next-auth"; +import { keycloakProviders, refreshKeycloakToken } from "./keycloak"; +import type { KeycloakToken } from "./keycloak"; +import { DEV_BYPASS } from "../dev-bypass"; + +/** createAuthConfig 옵션(선택 DI). */ +export interface AuthConfigOptions { + /** 로그인 페이지 경로(기본 "/login"). */ + signInPath?: string; + /** 추가/오버라이드 config(edge-sync 바인딩 등 후속 DI 지점). */ + overrides?: Partial; +} + +/** + * NextAuthConfig 팩토리. + * - DEV_BYPASS 면 providers=[] (로그인 불가; consuming app 이 mock 세션 사용). + * - JWT 전략(세션 테이블 없음, design §3 note 1). + * - jwt 콜백: Keycloak 토큰 보관 + 만료 임박 시 회전. + * - session 콜백: 최소·비민감 필드만(keycloakSub, role, error) 노출. + */ +export function createAuthConfig(options: AuthConfigOptions = {}): NextAuthConfig { + const { signInPath = "/login", overrides } = options; + const providers = DEV_BYPASS ? [] : keycloakProviders(); + + const config: NextAuthConfig = { + trustHost: true, + session: { strategy: "jwt" }, + providers, + pages: { signIn: signInPath }, + callbacks: { + // Keycloak 토큰을 암호화 JWT 에 보관(§4.4 kubeconfig 교환용); 만료 임박 시 회전. + async jwt({ token, account, profile }) { + const t = token as KeycloakToken; + if (account) { + t.access_token = account.access_token as string | undefined; + t.refresh_token = account.refresh_token as string | undefined; + t.id_token = account.id_token as string | undefined; + t.expires_at = account.expires_at as number | undefined; // epoch seconds + if (profile?.sub) t.sub = profile.sub; + const roles = + (profile as { realm_access?: { roles?: string[] } } | null)?.realm_access?.roles ?? []; + t.role = roles.includes("admin") ? "ADMIN" : "USER"; + return t; + } + if (t.expires_at && Date.now() < (Number(t.expires_at) - 60) * 1000) { + return t; + } + if (t.refresh_token) return refreshKeycloakToken(t); + return t; + }, + // 클라이언트 세션엔 최소·비민감 필드만(access_token/refresh_token 노출 금지 — §4.4 은 getToken 으로 서버에서만). + async session({ session, token }) { + const t = token as KeycloakToken; + if (session.user) { + session.user.keycloakSub = t.sub as string; + session.user.role = t.role ?? "USER"; + } + session.error = t.error; + return session; + }, + }, + ...overrides, + }; + return config; +} + +/** + * 기본 config 로 NextAuth 를 초기화한 결과. + * consuming app 은 이 재export(handlers/auth/signIn/signOut)를 그대로 쓰거나, + * createAuthConfig(...) 로 커스텀 config 를 만들어 직접 NextAuth() 를 호출해도 된다. + */ +const nextAuth: NextAuthResult = NextAuth(createAuthConfig()); + +export const handlers = nextAuth.handlers; +export const auth = nextAuth.auth; +export const signIn = nextAuth.signIn; +export const signOut = nextAuth.signOut; diff --git a/packages/auth-adapter/src/next-auth/keycloak.ts b/packages/auth-adapter/src/next-auth/keycloak.ts new file mode 100644 index 0000000..f46f6a4 --- /dev/null +++ b/packages/auth-adapter/src/next-auth/keycloak.ts @@ -0,0 +1,100 @@ +// @yakcloud/auth-adapter — next-auth/keycloak +// Keycloak provider 팩토리 + refresh 콜백. next-auth 는 peerDependency. +// 이 하위 디렉터리(src/next-auth/*)에만 next-auth 의존을 격리한다. + +import Keycloak from "next-auth/providers/keycloak"; +import type { Provider } from "next-auth/providers"; + +// 프론트채널(브라우저: authorize·iss) vs 백채널(서버: discovery·token·userinfo·jwks). +// 도커 등에서 두 경로의 호스트가 다를 때 KEYCLOAK_INTERNAL_ISSUER 로 백채널을 분리한다. +// 프로덕션 단일 호스트면 미설정 → 프론트=백=KEYCLOAK_ISSUER (일반 동작). +const KC_FRONT = process.env.KEYCLOAK_ISSUER; +const KC_BACK = process.env.KEYCLOAK_INTERNAL_ISSUER ?? KC_FRONT; + +/** JWT 콜백이 다루는 최소 토큰 형태. */ +export interface KeycloakToken { + sub?: string; + role?: "USER" | "ADMIN"; + access_token?: string; + refresh_token?: string; + id_token?: string; + expires_at?: number; + error?: string; + [key: string]: unknown; +} + +/** Keycloak refresh_token 회전(백채널 사용). */ +export async function refreshKeycloakToken(token: KeycloakToken): Promise { + try { + const secret = process.env.KEYCLOAK_CLIENT_SECRET; + if (!secret) throw new Error("KEYCLOAK_CLIENT_SECRET not set"); + const res = await fetch(`${KC_BACK}/protocol/openid-connect/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: process.env.KEYCLOAK_CLIENT_ID ?? "yakconsole", + client_secret: secret, + refresh_token: String(token.refresh_token), + }), + }); + const t = (await res.json()) as { + access_token?: string; + expires_in?: number; + refresh_token?: string; + id_token?: string; + }; + if (!res.ok) throw t; + return { + ...token, + access_token: t.access_token, + expires_at: Math.floor(Date.now() / 1000) + Number(t.expires_in ?? 0), + refresh_token: t.refresh_token ?? token.refresh_token, + id_token: t.id_token ?? token.id_token, + error: undefined, + }; + } catch (e) { + console.error("[auth] refresh failed", e); + return { ...token, error: "RefreshAccessTokenError" }; + } +} + +/** + * authorizePath 만 다른 provider 를 만든다: + * - "keycloak": 로그인(/auth) + * - "keycloak-register": 회원가입(/registrations) — /signup 이 등록 폼으로 직행 + */ +export function makeKeycloak(id: string, name: string, authorizePath: string): Provider { + const base = { + id, + name, + clientId: process.env.KEYCLOAK_CLIENT_ID ?? "yakconsole", + clientSecret: process.env.KEYCLOAK_CLIENT_SECRET, + issuer: KC_FRONT, // iss 검증·기본 discovery + }; + // 백채널이 프론트와 같으면 authorize 경로만 지정, 나머지는 discovery. + if (KC_BACK === KC_FRONT) { + return Keycloak({ ...base, authorization: `${KC_FRONT}${authorizePath}` }); + } + // 분리 시: discovery·token·userinfo·jwks 는 백채널, authorize 만 프론트(브라우저). + return Keycloak({ + ...base, + wellKnown: `${KC_BACK}/.well-known/openid-configuration`, + authorization: `${KC_FRONT}${authorizePath}`, + token: `${KC_BACK}/protocol/openid-connect/token`, + userinfo: `${KC_BACK}/protocol/openid-connect/userinfo`, + jwks_endpoint: `${KC_BACK}/protocol/openid-connect/certs`, + }); +} + +/** 로그인 + 회원가입 두 provider. dev-bypass 시 빈 배열을 쓰라. */ +export function keycloakProviders(): Provider[] { + return [ + makeKeycloak("keycloak", "YakCloud", "/protocol/openid-connect/auth"), + makeKeycloak( + "keycloak-register", + "YakCloud 회원가입", + "/protocol/openid-connect/registrations", + ), + ]; +} diff --git a/packages/auth-adapter/src/next-auth/middleware.ts b/packages/auth-adapter/src/next-auth/middleware.ts new file mode 100644 index 0000000..6a9d473 --- /dev/null +++ b/packages/auth-adapter/src/next-auth/middleware.ts @@ -0,0 +1,48 @@ +// @yakcloud/auth-adapter — next-auth/middleware +// 라우트 보호 미들웨어 팩토리. AuthAdapter 를 주입받아 Fetch Request 로 세션을 판정한다. +// next-auth 정적 import 없음(어댑터 DI). next/server 는 응답 헬퍼용 peer. + +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { CODE_STATUS } from "@yakcloud/ds-sdk"; +import type { AuthAdapter, Session } from "../core/types"; + +/** (console) 라우트 + /api/v1 매칭. /api/v1/internal/* 는 미들웨어 내부에서 우회. */ +export const matcher: string[] = ["/api/v1/:path*", "/((?!_next|login|signup|manual).*)"]; + +/** + * 미들웨어 팩토리. AuthAdapter 를 주입받는다. + * - /api/v1/internal/* → 세션 검사 우회(서비스 토큰은 핸들러에서 검증). + * - /api/v1/* → 미인증이면 401 JSON. + * - 그 외(console) → 미인증이면 /login 리다이렉트. + */ +export function createAuthMiddleware( + adapter: AuthAdapter, + loginPath = "/login", +) { + return async function middleware(req: NextRequest): Promise { + const { pathname } = req.nextUrl; + + // /internal/* 는 서비스 토큰(세션 아님)으로 게이트 — 미들웨어 통과. + if (pathname.startsWith("/api/v1/internal/")) { + return NextResponse.next(); + } + + const session = await adapter.resolveSession(req); + if (session) return NextResponse.next(); + + // /api/v1 → 401 JSON. + if (pathname.startsWith("/api/v1")) { + return NextResponse.json( + { ok: false, error: { code: "UNAUTHORIZED" } }, + { status: CODE_STATUS.UNAUTHORIZED }, + ); + } + + // (console) → /login 리다이렉트(원래 경로 callbackUrl 보존). + const url = req.nextUrl.clone(); + url.pathname = loginPath; + url.searchParams.set("callbackUrl", pathname); + return NextResponse.redirect(url); + }; +} diff --git a/packages/bff-kit/package.json b/packages/bff-kit/package.json index 895dfe5..8696eb3 100644 --- a/packages/bff-kit/package.json +++ b/packages/bff-kit/package.json @@ -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:*" } } diff --git a/packages/bff-kit/src/handlers/binding.ts b/packages/bff-kit/src/handlers/binding.ts new file mode 100644 index 0000000..b425e64 --- /dev/null +++ b/packages/bff-kit/src/handlers/binding.ts @@ -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 { + 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 }); +} diff --git a/packages/bff-kit/src/handlers/index.ts b/packages/bff-kit/src/handlers/index.ts new file mode 100644 index 0000000..f40b59b --- /dev/null +++ b/packages/bff-kit/src/handlers/index.ts @@ -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"; diff --git a/packages/bff-kit/src/handlers/service.ts b/packages/bff-kit/src/handlers/service.ts new file mode 100644 index 0000000..ccfa492 --- /dev/null +++ b/packages/bff-kit/src/handlers/service.ts @@ -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 = { 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 { + 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 | 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)); +} diff --git a/packages/bff-kit/src/handlers/types.ts b/packages/bff-kit/src/handlers/types.ts new file mode 100644 index 0000000..b773126 --- /dev/null +++ b/packages/bff-kit/src/handlers/types.ts @@ -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; + getMe(userId: string): Promise<{ quota: QuotaRow | null } | null>; + countServicesByCluster(clusterId: string): Promise; + isServiceNameTaken(clusterId: string, name: string): Promise; + createService(input: { + ownerId: string; + clusterId: string; + type: ServiceTypeDTO; + mode?: "LOCAL" | "REMOTE"; + name: string; + size: string; + storageGb: number; + persist: boolean; + }): Promise; + updateService( + id: string, + patch: { status?: string; connSecretRef?: string | null; connInfo?: unknown }, + ): Promise; + enqueueJob(input: { kind: string; payload: unknown }): Promise; + addAudit(input: { + userId?: string | null; + action: string; + targetType: string; + targetId?: string | null; + meta?: unknown; + }): Promise; +} + +export interface BindingRepo { + getService(id: string): Promise; + getCluster(id: string): Promise; + getDeployment(id: string): Promise; + listBindingsByDeployment(deploymentId: string): Promise; + createBinding(input: { + serviceId: string; + deploymentId: string; + alias: string; + injectedKeys: string[]; + }): Promise<{ id: string }>; + updateDeployment( + id: string, + patch: { status?: string; statusMessage?: string | null }, + ): Promise; + enqueueJob(input: { kind: string; payload: unknown }): Promise; + addAudit(input: { + userId?: string | null; + action: string; + targetType: string; + targetId?: string | null; + meta?: unknown; + }): Promise; +} + +// ── 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 | null; +} + +export interface YakcloudClient { + configured(): boolean; + createService(cluster: string, spec: YakServiceSpecInput): Promise; +} + +// 핸들러 공통 의존성 번들. +export interface HandlerDeps { + readonly user: SessionUser; +} diff --git a/packages/bff-kit/src/index.ts b/packages/bff-kit/src/index.ts index 999c7bf..bb73325 100644 --- a/packages/bff-kit/src/index.ts +++ b/packages/bff-kit/src/index.ts @@ -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"; diff --git a/packages/bff-kit/src/ratelimit.ts b/packages/bff-kit/src/ratelimit.ts new file mode 100644 index 0000000..3e2d699 --- /dev/null +++ b/packages/bff-kit/src/ratelimit.ts @@ -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; +} + +// in-memory 슬라이딩 윈도우 구현. 모듈 레벨 Map 은 재시작 시 초기화·인스턴스 간 미공유 → prod 는 Redis. +export class MemoryRatelimiter implements Ratelimiter { + private readonly hits = new Map(); // 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 도 허용. +} diff --git a/packages/bff-kit/src/respond.ts b/packages/bff-kit/src/respond.ts new file mode 100644 index 0000000..ba94161 --- /dev/null +++ b/packages/bff-kit/src/respond.ts @@ -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 = { + 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(data: T, init?: ResponseInit): Response { + return Response.json({ data }, init); +} + +// 202 Accepted + (선택) Location 헤더 — 비동기 프로비저닝 응답. +export function accepted(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( + fn: (...args: A) => Promise | Response, +): (...args: A) => Promise { + return async (...args: A): Promise => { + 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( + req: Request, + schema: S, +): Promise> { + let raw: unknown; + try { + raw = await req.json(); + } catch { + throw new ApiError("VALIDATION_FAILED", "요청 본문이 JSON이 아닙니다."); + } + return schema.parse(raw) as z.infer; +} diff --git a/packages/bff-kit/src/serialize.ts b/packages/bff-kit/src/serialize.ts new file mode 100644 index 0000000..eb00828 --- /dev/null +++ b/packages/bff-kit/src/serialize.ts @@ -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(dep: D): Omit & { 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 | null) ?? null, + createdAt: svc.createdAt, + updatedAt: svc.updatedAt, + ...(extra?.clusterName !== undefined ? { clusterName: extra.clusterName } : {}), + ...(extra?.bindings !== undefined ? { bindings: extra.bindings } : {}), + }; +} diff --git a/packages/bff-kit/src/session.ts b/packages/bff-kit/src/session.ts new file mode 100644 index 0000000..2aa87c9 --- /dev/null +++ b/packages/bff-kit/src/session.ts @@ -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; + +// /api/v1 핸들러용: 유저 또는 UNAUTHORIZED throw. SUSPENDED 는 FORBIDDEN. +export async function requireUser( + req: Request, + getSessionUser: SessionUserProvider, +): Promise { + 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( + 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( + 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 { + 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"); +} diff --git a/packages/client-shell/package.json b/packages/client-shell/package.json index 3e5af72..c7058fc 100644 --- a/packages/client-shell/package.json +++ b/packages/client-shell/package.json @@ -31,5 +31,9 @@ }, "peerDependencies": { "react": ">=18" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0" } } diff --git a/packages/client-shell/src/ServiceClientShell.tsx b/packages/client-shell/src/ServiceClientShell.tsx new file mode 100644 index 0000000..2a078af --- /dev/null +++ b/packages/client-shell/src/ServiceClientShell.tsx @@ -0,0 +1,301 @@ +"use client"; + +// @yakcloud/client-shell — 9종 데이터소스 공용 셸 크롬(메인 오케스트레이터). +// 헤더(컨텍스트/뒤로/상태) + 고정 세그먼트 탭(.sheet-seg) + 2-pane(독립 스크롤) + 로딩/미준비 가드. +// v1 범위 = 셸 크롬 + 데모. FileBrowser 는 Phase 1(미포함). 소스별 Data 패널은 SourcePanelRegistry 로 주입. +// vh 금지(px/rem, 900/620). 패널 전환 애니메이션은 usePanelSheets(280ms) 보존. +// ui/api-client 런타임, ds-sdk type-only. ⛔ 서버/prisma/next 금지. +import { useMemo, useState } from "react"; +import type { CSSProperties, ReactNode } from "react"; +import { SheetHelpButton } from "@yakcloud/ui"; +import type { ServiceDTO, ServiceTypeDTO } from "@yakcloud/ds-sdk"; +import { ServiceClientProvider, useServiceClientContext } from "./context/ServiceClientContext"; +import { ServiceClientHeader } from "./components/ServiceClientHeader"; +import type { TabDef } from "./components/TabNavigation"; +import { TwoPaneLayout, LeftPane, MainPane } from "./layout/TwoPaneLayout"; +import { LoadingGuard } from "./states/LoadingGuard"; +import { useServiceClient } from "./hooks/useServiceClient"; +import type { LoadingGuardState } from "./hooks/useServiceClient"; +import { OverviewPanel } from "./panels/OverviewPanel"; +import { BackupPanel } from "./panels/BackupPanel"; +import type { BackupEntry } from "./panels/BackupPanel"; +import { PlaceholderPanel } from "./panels/PlaceholderPanel"; +import { ConnectionHelpSheet } from "./sheets/ConnectionHelpSheet"; +import { resolvePanel } from "./panels"; +import type { SourcePanelRegistry } from "./panels"; + +// 셸 컨테이너 고정 높이 캡(px — vh 금지). 데스크톱 900, 컴팩트 620 참조값. +const SHELL_HEIGHT_MAX = 900; +const SHELL_HEIGHT_COMPACT = 620; + +export interface ServiceClientShellProps { + serviceId: string; + // 폴링 경로(예: `/services/${serviceId}`). api-client apiGet 기준(BASE 상대). + servicePath: string; + clusterId: string; + // 앱 바인딩 별칭(env prefix 도출용). 없으면 서비스명 기반 기본 처리. + alias?: string; + // 소스별 Data/추가 패널 레지스트리(앱 주입). 미지정 시 Data 탭은 PlaceholderPanel. + registry?: SourcePanelRegistry; + logoBase?: string; + onBack?: () => void; + // 백업 데이터/핸들러(UI 전용 — 실제 job 은 yakcloud-api). 미지정 시 표시만. + backups?: BackupEntry[]; + backupProgressPct?: number | null; + onBackupNow?: () => void; + onRestore?: (backupId: string) => void; + backupLogLines?: string[]; + // 좌 사이드바 콘텐츠(선택) — 앱이 소스 트리/네비를 채움. 미지정 시 최소 요약. + sidebar?: ReactNode; + // 셸 컨테이너 높이(px). 기본 SHELL_HEIGHT_MAX. + heightPx?: number; + compact?: boolean; + docsHref?: string; +} + +// v1 기본 탭. 'data' 는 registry 에 소스별 패널이 있으면 그 컴포넌트, 없으면 PlaceholderPanel. +function buildTabs(): TabDef[] { + return [ + { key: "overview", label: "개요" }, + { key: "data", label: "데이터" }, + { key: "backup", label: "백업" }, + ]; +} + +export function ServiceClientShell(props: ServiceClientShellProps) { + const { servicePath, compact } = props; + const client = useServiceClient({ path: servicePath }); + const heightPx = props.heightPx ?? (compact ? SHELL_HEIGHT_COMPACT : SHELL_HEIGHT_MAX); + + const containerStyle: CSSProperties = { + display: "flex", + flexDirection: "column", + height: `${heightPx}px`, + minHeight: 0, + background: "var(--surface-2, #f6f7f9)", + borderRadius: 12, + overflow: "hidden", + border: "1px solid var(--line, rgba(0,0,0,.08))", + }; + + // 서비스 타입 미확정(로딩/오류/미존재) — 헤더 없이 가드만(컨텍스트는 타입 필수라 준비 후 제공). + if (!client.service || !client.sourceType) { + return ( +
+ + {null} + +
+ ); + } + + return ( +
+ +
+ ); +} + +// 서비스 로드 완료 후의 셸 — 컨텍스트 프로바이더로 감싸고 내부 크롬 렌더. +function ShellReady({ + props, + service, + sourceType, + clusterName, + guardState, + onRetry, + coldStartHint, + error, +}: { + props: ServiceClientShellProps; + service: ServiceDTO; + sourceType: ServiceTypeDTO; + clusterName: string; + guardState: LoadingGuardState; + onRetry: () => void; + coldStartHint: string | null; + error: string | null; +}) { + const [activeTab, setActiveTab] = useState("overview"); + const tabs = useMemo(() => buildTabs(), []); + const effectiveAlias = props.alias ?? service.name; + + return ( + t.key), + scrollContainer: null, + logoBase: props.logoBase ?? "/logos", + }} + > + + + ); +} + +// 컨텍스트 내부 — 시트 컨트롤·활성 탭에 접근하는 실제 크롬. +function ShellInner({ + props, + service, + sourceType, + alias, + tabs, + guardState, + onRetry, + coldStartHint, + error, +}: { + props: ServiceClientShellProps; + service: ServiceDTO; + sourceType: ServiceTypeDTO; + alias: string; + tabs: TabDef[]; + guardState: LoadingGuardState; + onRetry: () => void; + coldStartHint: string | null; + error: string | null; +}) { + const ctx = useServiceClientContext(); + const { registry = {}, logoBase = "/logos", onBack, docsHref, sidebar } = props; + + const HELP_SHEET = "connection-help"; + const helpOpen = ctx.sheets.current?.id === "shell" && ctx.sheets.current.mode === HELP_SHEET; + const openHelp = () => ctx.sheets.open("shell", HELP_SHEET); + + // registry 의 소스별 Data 패널 — 없으면 PlaceholderPanel(FileBrowser Phase 1 defer). + const dataSlot = resolvePanel(registry, sourceType, "data"); + + let panel: ReactNode; + if (ctx.activeTab === "overview") { + panel = ( + + ); + } else if (ctx.activeTab === "backup") { + panel = ( + + ); + } else if (ctx.activeTab === "data") { + panel = dataSlot ? ( + + ) : ( + + ); + } else { + // 앱이 등록한 커스텀 탭 슬롯. + const custom = resolvePanel(registry, sourceType, ctx.activeTab); + panel = custom ? ( + + ) : null; + } + + return ( + <> + } + /> + {sidebar ?? }} + main={ + // sheet-host: position:relative 컨텍스트 — CardSheet 가 헤더↔본문 사이로 슬라이드(280ms). + // usePanelSheets(ctx.sheets) 가 열림/토글/전환을 오케스트레이션(같은 것 재클릭=닫기). + +
+ + {panel} + +
+ {/* 연결 도움말 시트(자체 CardSheet). ctx.sheets 상태로 열림 제어(280ms 전환·Esc 닫기). */} + +
+ } + /> + + ); +} + +// 좌 사이드바 기본 콘텐츠 — 앱이 sidebar prop 을 안 주면 최소 컨텍스트 요약. +function DefaultSidebar() { + const ctx = useServiceClientContext(); + return ( + + ); +} diff --git a/packages/client-shell/src/components/ServiceClientHeader.tsx b/packages/client-shell/src/components/ServiceClientHeader.tsx new file mode 100644 index 0000000..afc0bf1 --- /dev/null +++ b/packages/client-shell/src/components/ServiceClientHeader.tsx @@ -0,0 +1,70 @@ +"use client"; + +// @yakcloud/client-shell — 고정 헤더(스크롤 없음). +// 로고/인스턴스명 배지 + 상태 배지(serviceStatusMeta) + 소스 타입/버전 + 클러스터 컨텍스트 칩 + 뒤로. +// getServiceMeta(type, logoBase) 로 브랜딩. 긴 인스턴스명 responsive truncation. +// ui(Badge, serviceStatusMeta, getServiceMeta, icons) 런타임 소비, ds-sdk type-only. +import type { CSSProperties, ReactNode } from "react"; +import { Badge, serviceStatusMeta, getServiceMeta, ChevronLeft, K8s } from "@yakcloud/ui"; +import type { TabDef } from "./TabNavigation"; +import { TabNavigation } from "./TabNavigation"; +import { useServiceClientContext } from "../context/ServiceClientContext"; + +const truncate: CSSProperties = { + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + maxWidth: 280, +}; + +export function ServiceClientHeader({ + tabs, + onBack, + actions, +}: { + tabs: readonly TabDef[]; + onBack?: () => void; + actions?: ReactNode; // 헤더 우측 슬롯(도움말 버튼 등) +}) { + const { name, sourceType, clusterName, status, logoBase } = useServiceClientContext(); + const meta = getServiceMeta(sourceType, logoBase); + const st = status ? serviceStatusMeta(status) : null; + + return ( +
+
+ {onBack && ( + + )} + + + + + {name} + + + {meta.label} + + {st && {st.label}} + + {/* 클러스터 컨텍스트 칩 — 사용자가 어느 클러스터의 인스턴스인지 반드시 인지(설계 §4.1). */} + + + {clusterName || "—"} + + + {actions && {actions}} +
+ + +
+ ); +} diff --git a/packages/client-shell/src/components/TabNavigation.tsx b/packages/client-shell/src/components/TabNavigation.tsx new file mode 100644 index 0000000..1fe398e --- /dev/null +++ b/packages/client-shell/src/components/TabNavigation.tsx @@ -0,0 +1,96 @@ +"use client"; + +// @yakcloud/client-shell — 세그먼트 탭 바(.sheet-seg 관례). +// 등록된 탭 렌더 + activeTab 상태(ServiceClientContext) 관리 + 키보드(방향키·Enter/Space). +// ARIA: tablist/tab, aria-selected, roving tabindex. +// ⛔ 서버/prisma/next 금지. +import { useCallback, useRef } from "react"; +import type { KeyboardEvent } from "react"; +import { useServiceClientContext } from "../context/ServiceClientContext"; + +export interface TabDef { + key: string; + label: string; + // v1 에서 소스별로 특정 탭 숨김 지원(예: 'Data' 미지원 소스). + hidden?: boolean; +} + +export function TabNavigation({ tabs }: { tabs: readonly TabDef[] }) { + const { activeTab, setActiveTab } = useServiceClientContext(); + const visible = tabs.filter((t) => !t.hidden); + const btnRefs = useRef>({}); + + const focusTab = useCallback((key: string) => { + const el = btnRefs.current[key]; + if (el) el.focus(); + }, []); + + const onKeyDown = useCallback( + (e: KeyboardEvent, idx: number) => { + const count = visible.length; + if (count === 0) return; + let nextIdx: number | null = null; + switch (e.key) { + case "ArrowRight": + case "ArrowDown": + nextIdx = (idx + 1) % count; + break; + case "ArrowLeft": + case "ArrowUp": + nextIdx = (idx - 1 + count) % count; + break; + case "Home": + nextIdx = 0; + break; + case "End": + nextIdx = count - 1; + break; + case "Enter": + case " ": { + const cur = visible[idx]; + if (cur) setActiveTab(cur.key); + e.preventDefault(); + return; + } + default: + return; + } + if (nextIdx !== null) { + e.preventDefault(); + const target = visible[nextIdx]; + if (target) { + setActiveTab(target.key); + focusTab(target.key); + } + } + }, + [visible, setActiveTab, focusTab], + ); + + return ( +
+ {visible.map((t, i) => { + const on = t.key === activeTab; + return ( + + ); + })} +
+ ); +} diff --git a/packages/client-shell/src/context/ServiceClientContext.tsx b/packages/client-shell/src/context/ServiceClientContext.tsx new file mode 100644 index 0000000..8643d33 --- /dev/null +++ b/packages/client-shell/src/context/ServiceClientContext.tsx @@ -0,0 +1,60 @@ +"use client"; + +// @yakcloud/client-shell — 서비스 클라이언트 셸 컨텍스트. +// 서비스 메타(id/type/cluster/status) + 탭 상태(activeTab) + 패널 시트 컨트롤(usePanelSheets) 공유. +// ⛔ DOM 가정 금지(main.main querySelector 폴백 없음) — scrollContainer 는 명시 주입만. +// ds-sdk 는 type-only, ui(usePanelSheets)는 런타임 소비. +import { createContext, useContext } from "react"; +import type { ReactNode } from "react"; +import { usePanelSheets } from "@yakcloud/ui"; +import type { PanelSheetsCtl } from "@yakcloud/ui"; +import type { ServiceStatusDTO, ServiceTypeDTO } from "@yakcloud/ds-sdk"; +import type { LoadingGuardState } from "../hooks/useServiceClient"; + +export interface ServiceClientContextValue { + // 서비스 메타 + serviceId: string; + name: string; + sourceType: ServiceTypeDTO; + clusterId: string; + clusterName: string; + status: ServiceStatusDTO | undefined; + guardState: LoadingGuardState; + + // 탭 상태 + activeTab: string; + setActiveTab: (tab: string) => void; + tabs: readonly string[]; + + // 패널 시트 컨트롤(헤더↔본문 슬라이드 시트) + sheets: PanelSheetsCtl; + + // 스크롤 잠금 대상 컨테이너(선택) — 시트 열림 동안 잠금. 미지정 시 no-op. + scrollContainer: HTMLElement | null; + + // 로고 URL 조합 베이스(getServiceMeta logoBase). + logoBase: string; +} + +const Ctx = createContext(null); + +export function ServiceClientProvider({ + value, + children, +}: { + value: Omit; + children: ReactNode; +}) { + const sheets = usePanelSheets(); + return {children}; +} + +export function useServiceClientContext(): ServiceClientContextValue { + const v = useContext(Ctx); + if (!v) { + throw new Error( + "useServiceClientContext must be used within (ServiceClientShell).", + ); + } + return v; +} diff --git a/packages/client-shell/src/hooks/useServiceClient.ts b/packages/client-shell/src/hooks/useServiceClient.ts new file mode 100644 index 0000000..f24f891 --- /dev/null +++ b/packages/client-shell/src/hooks/useServiceClient.ts @@ -0,0 +1,99 @@ +"use client"; + +// @yakcloud/client-shell — 서비스 로딩/준비 상태 훅. +// READY 폴링 + LoadingGuard 상태 산출 + scale-to-zero 콜드스타트(첫 요청 지연) 배경 메시지. +// api-client(usePoll) 런타임 소비, ds-sdk 는 type-only. +// ⛔ next/prisma/서버 금지. scale-to-zero 오케스트레이션은 yakcloud-api 책임(여기는 상태 표시만). +import { useCallback, useMemo, useRef } from "react"; +import { usePoll } from "@yakcloud/api-client"; +import type { ServiceDTO, ServiceStatusDTO, ServiceTypeDTO } from "@yakcloud/ds-sdk"; + +// LoadingGuard 가 렌더 분기에 쓰는 4상태 유니온. +export type LoadingGuardState = "ready" | "loading" | "error" | "not-found"; + +export interface UseServiceClientOptions { + // 폴링 주소(예: `/services/${id}`). null 이면 폴링 비활성. + path: string | null; + // 준비 폴링 간격(ms). READY 도달 후에도 상태 변화를 잡기 위해 저빈도 유지. + pollMs?: number; + // 콜드스타트(scale-to-zero) 안내를 시작하기까지의 로딩 지속 임계(ms). + coldStartHintMs?: number; +} + +export interface UseServiceClientResult { + status: LoadingGuardState; + serviceStatus: ServiceStatusDTO | undefined; + service: ServiceDTO | undefined; + isReady: boolean; + clusterName: string; + sourceType: ServiceTypeDTO | undefined; + error: string | null; + // 콜드스타트 배경 안내 문구(예: scale-to-zero 웨이크업). 없으면 null. + coldStartHint: string | null; + retry: () => void; +} + +const DEFAULT_POLL_MS = 4000; + +// 서비스 status → LoadingGuard 상태 매핑. +function toGuardState(s: ServiceStatusDTO): LoadingGuardState { + switch (s) { + case "READY": + return "ready"; + case "ERROR": + return "error"; + case "DELETED": + return "not-found"; + default: + // REQUESTED / PROVISIONING / DELETING → 아직 클라이언트 사용 불가. + return "loading"; + } +} + +export function useServiceClient(opts: UseServiceClientOptions): UseServiceClientResult { + const { path, pollMs = DEFAULT_POLL_MS } = opts; + const poll = usePoll(path, pollMs); + const { data, error, loading, refresh } = poll; + + // 로딩 시작 시각 — 콜드스타트 안내 임계 비교용(마운트/경로 변화에 안정적으로 갱신). + const loadStartRef = useRef(Date.now()); + if (!loading && data) loadStartRef.current = Date.now(); + + const status: LoadingGuardState = useMemo(() => { + if (error) { + // 404 계열은 not-found, 그 외는 error. + return error.status === 404 ? "not-found" : "error"; + } + if (loading || !data) return "loading"; + return toGuardState(data.status); + }, [error, loading, data]); + + const coldStartHint = useMemo(() => { + if (status !== "loading") return null; + // PROVISIONING/REQUESTED 는 프로비저닝 안내, 그 외 로딩 지속은 웨이크업 안내. + const svcStatus = data?.status; + if (svcStatus === "PROVISIONING" || svcStatus === "REQUESTED") { + return "인스턴스를 준비하고 있습니다. 잠시만 기다려 주세요…"; + } + return "유휴 상태에서 깨우는 중입니다(최초 요청은 몇 초 걸릴 수 있어요)…"; + }, [status, data?.status]); + + const retry = useCallback(() => { + loadStartRef.current = Date.now(); + refresh(); + }, [refresh]); + + const errMsg = error ? error.message : status === "error" && data ? data.statusMessage : null; + + return { + status, + serviceStatus: data?.status, + service: data, + isReady: status === "ready", + clusterName: data?.clusterName ?? "", + sourceType: data?.type, + error: errMsg ?? null, + coldStartHint, + retry, + }; +} diff --git a/packages/client-shell/src/index.ts b/packages/client-shell/src/index.ts index 5727b4e..6c2fca4 100644 --- a/packages/client-shell/src/index.ts +++ b/packages/client-shell/src/index.ts @@ -1,4 +1,43 @@ -// @yakcloud/client-shell -// 9종 데이터소스 웹 클라 셸(신규): 헤더/탭/pane/가드. FileBrowser 는 Phase 1. -// B1 스켈레톤 — 실제 추출은 후속(scaffold-input.md 판정표 참조). -export const __package = "@yakcloud/client-shell"; +// @yakcloud/client-shell — 공개 배럴. +// 9종 데이터소스 웹 클라 '셸 크롬': 헤더(클러스터/소스 컨텍스트+뒤로) + 세그먼트 탭(.sheet-seg) + +// 2-pane 레이아웃 + 로딩/미준비 가드. 소스별 Data 패널은 SourcePanelRegistry 로 앱이 주입. +// 내부 전용 컴포넌트(ConnectionHelpSheet/PlaceholderPanel)는 재export 하지 않는다 — 앱은 registry 로 등록. +// react peer. ui/api-client 런타임 소비, ds-sdk type-only. ⛔ 서버/prisma/next 금지. + +// ── 메인 셸 ── +export { ServiceClientShell } from "./ServiceClientShell"; +export type { ServiceClientShellProps } from "./ServiceClientShell"; + +// ── 컨텍스트 ── +export { ServiceClientProvider, useServiceClientContext } from "./context/ServiceClientContext"; +export type { ServiceClientContextValue } from "./context/ServiceClientContext"; + +// ── 상태 훅 + LoadingGuard 유니온 ── +export { useServiceClient } from "./hooks/useServiceClient"; +export type { + LoadingGuardState, + UseServiceClientOptions, + UseServiceClientResult, +} from "./hooks/useServiceClient"; +export { LoadingGuard } from "./states/LoadingGuard"; + +// ── 레이아웃 프리미티브(앱 사이드바 구성용) ── +export { TwoPaneLayout, LeftPane, MainPane } from "./layout/TwoPaneLayout"; +export { TabNavigation } from "./components/TabNavigation"; +export type { TabDef } from "./components/TabNavigation"; +export { ServiceClientHeader } from "./components/ServiceClientHeader"; + +// ── 패널 레지스트리 타입 + 헬퍼(앱이 소스별 Data 패널 등록) ── +export type { + PanelSlot, + PanelSlotName, + PanelComponentProps, + SourcePanelRegistry, +} from "./panels"; +export { registerPanel, resolvePanel } from "./panels"; + +// ── 백업 패널 데이터 타입(앱이 백업 목록/로그 주입) ── +export type { BackupEntry } from "./panels/BackupPanel"; + +// ── 순수 포매터(앱 재사용) ── +export { fmtDate, fmtRelative, fmtBytes } from "./utils/formatters"; diff --git a/packages/client-shell/src/layout/TwoPaneLayout.tsx b/packages/client-shell/src/layout/TwoPaneLayout.tsx new file mode 100644 index 0000000..6c64451 --- /dev/null +++ b/packages/client-shell/src/layout/TwoPaneLayout.tsx @@ -0,0 +1,70 @@ +"use client"; + +// @yakcloud/client-shell — 2-pane 레이아웃(좌 사이드바 + 우 메인, 각각 독립 스크롤). +// vh 금지: 높이는 100%/px 만. 좁은 뷰포트(<900px)는 세로 스택(부모 컨테이너 클래스로 제어). +// ⛔ 서버/prisma/next 금지. 순수 프레젠테이션. +import type { CSSProperties, ReactNode } from "react"; + +const MIN_SIDEBAR = 200; +const MAX_SIDEBAR = 600; +const DEFAULT_SIDEBAR = 300; + +export function TwoPaneLayout({ + sidebar, + main, + sidebarWidth = DEFAULT_SIDEBAR, +}: { + sidebar: ReactNode; + main: ReactNode; + sidebarWidth?: number; +}) { + const w = Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, sidebarWidth)); + const gridStyle: CSSProperties = { + display: "grid", + gridTemplateColumns: `${w}px minmax(0, 1fr)`, + minHeight: 0, + height: "100%", + }; + return ( +
+ {sidebar} + {main} +
+ ); +} + +// 좌 사이드바 — 독립 스크롤(overflow:auto). vh 미사용. +export function LeftPane({ children }: { children: ReactNode }) { + const style: CSSProperties = { + overflow: "auto", + minHeight: 0, + height: "100%", + borderRight: "1px solid var(--line, rgba(0,0,0,.08))", + background: "var(--surface-1, #fff)", + }; + return ( + + ); +} + +// 우 메인 — 독립 스크롤(overflow:auto). sheet-host 관례(부모 relative)와 함께 쓰이도록 클래스 노출. +export function MainPane({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + const style: CSSProperties = { + overflow: "auto", + minHeight: 0, + height: "100%", + }; + return ( +
+ {children} +
+ ); +} diff --git a/packages/client-shell/src/panels/BackupPanel.tsx b/packages/client-shell/src/panels/BackupPanel.tsx new file mode 100644 index 0000000..8229129 --- /dev/null +++ b/packages/client-shell/src/panels/BackupPanel.tsx @@ -0,0 +1,196 @@ +"use client"; + +// @yakcloud/client-shell — 백업 탭(UI 전용; 실제 job/restore 로직은 backup-core·yakcloud-api). +// 3 세그먼트: BackupNow(온디맨드) · Schedule(cron 스텁) · Logs(ds-log tail). +// Gauge=진행률, 백업 목록+마지막 실행, restore=type-to-confirm 게이트(쓰기/삭제는 명시 토글 후). +// restore 엔드포인트는 M3 스텁(501). 소스별 백업 한계 경고 배지(RabbitMQ/Redis). +// role=backup egress allowlist 설명 표시. ui(Badge/Gauge/Timeline/icons) 런타임, ds-sdk type-only. +import { useState } from "react"; +import { Badge, Gauge, Timeline, Warn } from "@yakcloud/ui"; +import type { TimelineItem } from "@yakcloud/ui"; +import type { ServiceTypeDTO } from "@yakcloud/ds-sdk"; +import { fmtDate, fmtBytes, fmtRelative } from "../utils/formatters"; + +type Segment = "now" | "schedule" | "logs"; + +export interface BackupEntry { + id: string; + ts: string; // ISO + sizeBytes?: number; + status: "done" | "running" | "failed"; +} + +// 소스별 알려진 백업 한계 — 경고 배지 문구(설계 요구). +const BACKUP_WARNINGS: Partial> = { + RABBITMQ: "RabbitMQ 는 노드 간 복제 검증이 없습니다. 백업은 정의/메시지 스냅샷 기준입니다.", + REDIS: "Redis 는 scale-to-zero 시 RDB 저장을 보장하지 않습니다. 최신 쓰기가 누락될 수 있습니다.", +}; + +export function BackupPanel({ + sourceType, + backups = [], + progressPct, + onBackupNow, + onRestore, + logLines = [], +}: { + sourceType: ServiceTypeDTO; + backups?: BackupEntry[]; + progressPct?: number | null; // 진행 중 백업 진행률(0-100). null/undefined 면 미표시. + onBackupNow?: () => void; + // restore 는 M3 스텁(501). 게이트 통과 시에만 호출. + onRestore?: (backupId: string) => void; + logLines?: string[]; +}) { + const [seg, setSeg] = useState("now"); + const [restoreTarget, setRestoreTarget] = useState(null); + const [confirmText, setConfirmText] = useState(""); + + const warning = BACKUP_WARNINGS[sourceType]; + const CONFIRM_WORD = "RESTORE"; + + const timelineItems: TimelineItem[] = backups.map((b) => ({ + title: + b.status === "failed" + ? "백업 실패" + : b.status === "running" + ? "백업 진행 중" + : `백업 완료${b.sizeBytes !== undefined ? ` · ${fmtBytes(b.sizeBytes)}` : ""}`, + ts: `${fmtDate(b.ts)} (${fmtRelative(b.ts)})`, + state: b.status === "running" ? "now" : b.status === "failed" ? "todo" : "done", + })); + + return ( +
+ {warning && ( +
+ + + 주의 {warning} + +
+ )} + +
+ {(["now", "schedule", "logs"] as const).map((s) => ( + + ))} +
+ + {seg === "now" && ( +
+ {progressPct !== null && progressPct !== undefined && ( +
+ 진행률 {Math.round(progressPct)}% + +
+ )} + +

+ role=backup egress allowlist 덕분에 백업은 성공합니다(데이터 읽기는 필터링될 수 있어도 + 백업 대상 스토리지로의 송신은 허용됨). +

+ +

백업 목록

+ {backups.length === 0 ? ( +

아직 백업이 없습니다.

+ ) : ( + <> + +
    + {backups.map((b) => ( +
  • + {fmtDate(b.ts)} + {fmtBytes(b.sizeBytes)} + +
  • + ))} +
+ + )} + + {/* restore type-to-confirm 게이트 — 쓰기/삭제 액션은 명시 확인 후에만. onRestore 는 M3 스텁(501). */} + {restoreTarget && ( +
+

+ 복원은 현재 데이터를 덮어씁니다. 계속하려면{" "} + {CONFIRM_WORD} 를 입력하세요. +

+ setConfirmText(e.target.value)} + aria-label={`확인 문구 ${CONFIRM_WORD} 입력`} + placeholder={CONFIRM_WORD} + /> +
+ + +
+
+ )} +
+ )} + + {seg === "schedule" && ( +
+

백업 스케줄

+

+ cron 기반 자동 백업 UI 는 준비 중입니다. 스케줄 오케스트레이션은 플랫폼(yakcloud-api)에서 + 수행됩니다. +

+ +
+ )} + + {seg === "logs" && ( +
+

백업 로그

+ {logLines.length === 0 ? ( +

표시할 로그가 없습니다.

+ ) : ( +
+              {logLines.map((line, i) => (
+                
{line}
+ ))} +
+ )} +
+ )} +
+ ); +} diff --git a/packages/client-shell/src/panels/OverviewPanel.tsx b/packages/client-shell/src/panels/OverviewPanel.tsx new file mode 100644 index 0000000..d7c7892 --- /dev/null +++ b/packages/client-shell/src/panels/OverviewPanel.tsx @@ -0,0 +1,121 @@ +"use client"; + +// @yakcloud/client-shell — 개요 탭(읽기 전용). +// 인스턴스 메타(name/type/version/status) + 접속 메타(host/port/db, 비밀 제외) + _* env 힌트 + +// 연결 도움말 시트 토글. ⛔ connSecretRef·자격증명 절대 미노출 — connInfo(host/port/db/bucket)만. +// ui(Badge, serviceStatusMeta, getServiceMeta, SheetHelpButton) 런타임, ds-sdk(BIND_ENV_VARS) 값 import. +import { + Badge, + serviceStatusMeta, + getServiceMeta, + SheetHelpButton, +} from "@yakcloud/ui"; +import { BIND_ENV_VARS, bindEnvPrefix } from "@yakcloud/ds-sdk"; +import type { ServiceDTO } from "@yakcloud/ds-sdk"; +import { fmtDate } from "../utils/formatters"; + +export function OverviewPanel({ + service, + alias, + logoBase, + onOpenConnectionHelp, +}: { + service: ServiceDTO; + alias: string; + logoBase?: string; + onOpenConnectionHelp?: () => void; +}) { + const meta = getServiceMeta(service.type, logoBase); + const st = serviceStatusMeta(service.status); + const conn = service.connInfo; + const prefix = bindEnvPrefix(alias); + const envHints = BIND_ENV_VARS[service.type]; + + return ( +
+
+
+

인스턴스

+ {onOpenConnectionHelp && } +
+
+
이름
+
{service.name}
+
종류
+
{meta.label}
+
모드
+
{service.mode === "LOCAL" ? "관리형(클러스터 내)" : "외부 연결"}
+
상태
+
+ {st.label} +
+
크기
+
{service.size}
+
스토리지
+
{service.persist ? `${service.storageGb} GB` : "비영속"}
+
생성
+
{fmtDate(service.createdAt)}
+
+
+ +
+

접속 정보

+ {conn ? ( +
+ {conn.host !== undefined && ( + <> +
호스트
+
{conn.host}
+ + )} + {conn.port !== undefined && ( + <> +
포트
+
{conn.port}
+ + )} + {conn.db !== undefined && ( + <> +
DB
+
{String(conn.db)}
+ + )} + {conn.bucket !== undefined && ( + <> +
버킷
+
{conn.bucket}
+ + )} +
+ ) : ( +

접속 메타가 아직 준비되지 않았습니다.

+ )} +

+ 자격증명(비밀번호·키)은 표시되지 않습니다. 앱 실행 시 환경변수로만 주입됩니다. +

+
+ +
+

+ 환경변수 힌트 (prefix: {prefix}) +

+
    + {envHints.map((v) => ( +
  • + + {prefix}_{v.suffix} + + {v.desc} + 예: {v.example || "(빈값)"} +
  • + ))} +
+
+
+ ); +} diff --git a/packages/client-shell/src/panels/PlaceholderPanel.tsx b/packages/client-shell/src/panels/PlaceholderPanel.tsx new file mode 100644 index 0000000..5347d13 --- /dev/null +++ b/packages/client-shell/src/panels/PlaceholderPanel.tsx @@ -0,0 +1,47 @@ +"use client"; + +// @yakcloud/client-shell — Data 탭 스텁(Phase 1 대기, FileBrowser defer). +// "데이터 브라우저는 Phase 1 에 제공됩니다 — 이 소스는 연결되어 있고 백업이 활성입니다." +// 소스별 메시지 조정(sourceType) + 오프라인 문서 링크(동적 fetch 없음). 비차단. +// ui(EmptyState, getServiceMeta, icons) 런타임 소비, ds-sdk type-only. +import { EmptyState, getServiceMeta, Database, Book } from "@yakcloud/ui"; +import type { ServiceTypeDTO } from "@yakcloud/ds-sdk"; + +export function PlaceholderPanel({ + sourceType, + docsHref, + onOpenConnectionHelp, +}: { + sourceType: ServiceTypeDTO; + docsHref?: string; // 오프라인/정적 문서 링크(동적 fetch 아님) + onOpenConnectionHelp?: () => void; +}) { + const label = getServiceMeta(sourceType).label; + return ( +
+ ); +} diff --git a/packages/client-shell/src/panels/index.ts b/packages/client-shell/src/panels/index.ts new file mode 100644 index 0000000..d53e2bb --- /dev/null +++ b/packages/client-shell/src/panels/index.ts @@ -0,0 +1,48 @@ +// @yakcloud/client-shell — 소스 타입별 패널 슬롯 레지스트리. +// 셸(ServiceClientShell)이 9종 소스를 하드코딩하지 않고, 앱이 SourcePanelRegistry 로 자신의 +// Data/Backup 패널을 주입한다(v1 은 셸 크롬+데모; 소스별 Data 패널은 9개 앱 repo 책임). +// ⛔ 서버/prisma/next 금지. ds-sdk 는 type-only. +import type { ComponentType } from "react"; +import type { ServiceTypeDTO } from "@yakcloud/ds-sdk"; + +// 슬롯 이름 — 탭 네비게이션의 탭 키와 정렬. 'overview'/'backup' 은 셸 내장, 나머지는 앱 등록. +export type PanelSlotName = "overview" | "data" | "backup" | (string & {}); + +// 패널 컴포넌트에 셸이 주입하는 공통 props. 소스별 패널은 이 계약을 소비. +export interface PanelComponentProps { + serviceId: string; + sourceType: ServiceTypeDTO; + clusterId: string; + clusterName: string; +} + +// 하나의 패널 슬롯 정의 — 소스 타입 × 슬롯 이름 × 컴포넌트(+표시 라벨). +export interface PanelSlot

{ + sourceType: ServiceTypeDTO; + slotName: PanelSlotName; + label: string; + Component: ComponentType

; +} + +// 소스 타입 → 등록 패널 배열. 부분(Partial): 앱이 자기 타입만 채워도 됨. +export type SourcePanelRegistry = Partial>; + +// 앱이 동적으로 패널을 추가하는 팩토리(불변 병합 — 입력 레지스트리는 변형하지 않음). +export function registerPanel( + registry: SourcePanelRegistry, + slot: PanelSlot, +): SourcePanelRegistry { + const existing = registry[slot.sourceType] ?? []; + // 같은 slotName 은 교체(중복 방지), 아니면 추가. + const next = existing.filter((s) => s.slotName !== slot.slotName).concat(slot); + return { ...registry, [slot.sourceType]: next }; +} + +// 특정 소스 타입 + 슬롯의 패널 조회(없으면 undefined). +export function resolvePanel( + registry: SourcePanelRegistry, + sourceType: ServiceTypeDTO, + slotName: PanelSlotName, +): PanelSlot | undefined { + return registry[sourceType]?.find((s) => s.slotName === slotName); +} diff --git a/packages/client-shell/src/sheets/ConnectionHelpSheet.tsx b/packages/client-shell/src/sheets/ConnectionHelpSheet.tsx new file mode 100644 index 0000000..f45827d --- /dev/null +++ b/packages/client-shell/src/sheets/ConnectionHelpSheet.tsx @@ -0,0 +1,76 @@ +"use client"; + +// @yakcloud/client-shell — 연결 도움말 사이드 시트(usePanelSheets 로 열림). +// 자격증명 없이 접속 방법만: host/port(예시 placeholder), _* env 키 이름, 로컬 Docker 사용 안내. +// ⛔ /api/internal/credentials 등 비밀 fetch 금지(그런 엔드포인트는 존재하면 안 됨). 값은 전부 placeholder. +// ui(CardSheet, CodeBlock, C) 런타임 소비, ds-sdk(BIND_ENV_VARS, bindEnvKeys) type-only 아님 — 상수/헬퍼는 값 import. +import { CardSheet, CodeBlock, C } from "@yakcloud/ui"; +import { BIND_ENV_VARS, bindEnvKeys } from "@yakcloud/ds-sdk"; +import type { ServiceTypeDTO } from "@yakcloud/ds-sdk"; + +export function ConnectionHelpSheet({ + open, + onClose, + sourceType, + alias, + scrollContainer, +}: { + open: boolean; + onClose: () => void; + sourceType: ServiceTypeDTO; + alias: string; // 앱 바인딩 별칭(예: "mydb") → PREFIX 도출 + scrollContainer?: HTMLElement | null; +}) { + const vars = BIND_ENV_VARS[sourceType]; + const keys = bindEnvKeys(alias, sourceType); + + return ( + +

+

주입되는 환경변수

+
    + {vars.map((v, i) => { + const key = keys[i]; + return ( +
  • + {key ?? `${alias.toUpperCase()}_${v.suffix}`} + {v.desc} + + 예: {v.example || "(빈값)"} + +
  • + ); + })} +
+ +

로컬 Docker 예시 (모두 placeholder)

+ + # 실제 값이 아닌 예시입니다. 값은 서버에서만 조립됩니다. +
+ docker run \ +
+ {vars.slice(0, 4).map((v, i) => { + const key = keys[i] ?? `${alias.toUpperCase()}_${v.suffix}`; + return ( + + {" "}-e {key}=<{v.suffix.toLowerCase()}> \ +
+
+ ); + })} + {" "}your-app:latest +
+ +

+ 비밀번호·액세스 키는 이 화면에 표시되지 않으며, 앱 실행 시점에만 파드 환경으로 전달됩니다. +

+
+ + ); +} diff --git a/packages/client-shell/src/states/LoadingGuard.tsx b/packages/client-shell/src/states/LoadingGuard.tsx new file mode 100644 index 0000000..f29910e --- /dev/null +++ b/packages/client-shell/src/states/LoadingGuard.tsx @@ -0,0 +1,59 @@ +"use client"; + +// @yakcloud/client-shell — 로딩/오류/미존재 가드. +// useServiceClient() 상태 → 렌더 분기: loading→Skeleton+콜드스타트 안내(202/503 프레이밍), +// error→ErrorState(재시도), not-found→EmptyState. 로딩 중에도 상태 배지 노출(투명성). +// ui(Skeleton/ErrorState/EmptyState/Badge/serviceStatusMeta) 런타임 소비. +import type { ReactNode } from "react"; +import { Skeleton, ErrorState, EmptyState, Badge, serviceStatusMeta } from "@yakcloud/ui"; +import type { ServiceStatusDTO } from "@yakcloud/ds-sdk"; +import type { LoadingGuardState } from "../hooks/useServiceClient"; + +export function LoadingGuard({ + state, + serviceStatus, + error, + coldStartHint, + onRetry, + children, +}: { + state: LoadingGuardState; + serviceStatus?: ServiceStatusDTO; + error?: string | null; + coldStartHint?: string | null; + onRetry: () => void; + children: ReactNode; +}) { + if (state === "ready") return <>{children}; + + if (state === "error") { + return ; + } + + if (state === "not-found") { + return ( + + ); + } + + // loading — 콜드스타트/프로비저닝 안내 + 상태 배지 + 스켈레톤. + const st = serviceStatus ? serviceStatusMeta(serviceStatus) : null; + return ( +
+
+ 클라이언트를 준비하고 있습니다… + {st && {st.label}} +
+ {coldStartHint &&

{coldStartHint}

} +
+ + + + +
+
+ ); +} diff --git a/packages/client-shell/src/utils/formatters.ts b/packages/client-shell/src/utils/formatters.ts new file mode 100644 index 0000000..25a98e5 --- /dev/null +++ b/packages/client-shell/src/utils/formatters.ts @@ -0,0 +1,78 @@ +// @yakcloud/client-shell — 순수 포매터(프레임워크·DOM 무관, SSR 안전). +// window/querySelector 미사용. OverviewPanel·BackupPanel 이 소비. +// ⛔ next/prisma/서버 금지. ds-sdk 는 이 파일에서 불필요(순수 값 변환). + +// 로케일 기본값 — 소비자는 opts 로 override 가능. +const DEFAULT_DATE_OPTS: Intl.DateTimeFormatOptions = { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", +}; + +// 입력을 밀리초 timestamp 로 정규화. null/빈값/파싱불가 → null. +function toMs(ts: string | number | Date | null | undefined): number | null { + if (ts === null || ts === undefined) return null; + if (ts instanceof Date) { + const t = ts.getTime(); + return Number.isNaN(t) ? null : t; + } + if (typeof ts === "number") { + return Number.isFinite(ts) ? ts : null; + } + const parsed = Date.parse(ts); + return Number.isNaN(parsed) ? null : parsed; +} + +/** ISO/epoch/Date → 로케일 날짜·시각 문자열. 무효값 → "—". */ +export function fmtDate( + ts: string | number | Date | null | undefined, + opts?: Intl.DateTimeFormatOptions, + locale?: string, +): string { + const ms = toMs(ts); + if (ms === null) return "—"; + return new Intl.DateTimeFormat(locale, opts ?? DEFAULT_DATE_OPTS).format(new Date(ms)); +} + +/** 상대 시간("3분 전" 등). Intl.RelativeTimeFormat 사용, SSR 안전(now 주입 가능). */ +export function fmtRelative( + ts: string | number | Date | null | undefined, + nowMs: number = Date.now(), + locale?: string, +): string { + const ms = toMs(ts); + if (ms === null) return "—"; + const diffSec = Math.round((ms - nowMs) / 1000); + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); + const abs = Math.abs(diffSec); + const units: { unit: Intl.RelativeTimeFormatUnit; sec: number }[] = [ + { unit: "year", sec: 31536000 }, + { unit: "month", sec: 2592000 }, + { unit: "day", sec: 86400 }, + { unit: "hour", sec: 3600 }, + { unit: "minute", sec: 60 }, + { unit: "second", sec: 1 }, + ]; + for (const u of units) { + if (abs >= u.sec || u.unit === "second") { + return rtf.format(Math.round(diffSec / u.sec), u.unit); + } + } + return rtf.format(diffSec, "second"); +} + +/** 바이트 → 사람 읽는 크기(KB/MB/…). 음수·무효 → "—". */ +export function fmtBytes(sizeBytes: number | null | undefined, fractionDigits = 1): string { + if (sizeBytes === null || sizeBytes === undefined || !Number.isFinite(sizeBytes) || sizeBytes < 0) { + return "—"; + } + if (sizeBytes < 1) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB", "PB"]; + const exp = Math.min(Math.floor(Math.log(sizeBytes) / Math.log(1024)), units.length - 1); + const value = sizeBytes / Math.pow(1024, exp); + const unit = units[exp] ?? "B"; + const rounded = exp === 0 ? String(Math.round(value)) : value.toFixed(fractionDigits); + return `${rounded} ${unit}`; +} diff --git a/packages/client-shell/tsconfig.json b/packages/client-shell/tsconfig.json index 99c4c1e..4f9c3c3 100644 --- a/packages/client-shell/tsconfig.json +++ b/packages/client-shell/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "jsx": "react-jsx" }, "include": [ "src"