Compare commits

..

1 Commits

Author SHA1 Message Date
97668007b1 QA: SPRINT-REDESIGN review iteration 1 — PASSED 2026-04-04 13:12:56 +09:00
30 changed files with 101 additions and 1311 deletions

View File

@@ -1,52 +0,0 @@
# SPRINT-006 QA Review — Iteration 1
- **검증일시:** 2026-04-04 15:10 KST
- **검증자:** 다랑이 (Evaluator)
- **결과:** ❌ FAILED (blocking 5건)
## 검증 항목
| 항목 | 결과 |
|------|------|
| npm install | ✅ |
| prisma generate | ✅ |
| npm test (backend) | ✅ 26/26 pass |
| npm run build (backend) | ✅ |
| npm run build (frontend) | ✅ 16 routes |
## 🔴 Blocking 이슈
### B1: JWT_SECRET fallback 'changeme' — 3곳 (Critical)
- **파일:** auth.service.ts:85, auth.module.ts:22, events.gateway.ts:48
- **문제:** JWT_SECRET 미설정 시 'changeme'로 토큰 서명 → 공격자가 유효한 JWT 위조 가능
- **모순:** jwt.strategy.ts는 throw하는데 나머지는 fallback → 불일치
- **수정:** 3곳 모두 `?? 'changeme'` 제거, ConfigService.getOrThrow 또는 throw 처리
### B2: User.role 기본값 "admin" (Critical)
- **파일:** prisma/schema.prisma
- **문제:** 모든 가입자 자동 admin → 초대코드만 알면 SSH restart, 하네스 수정 등 전체 인프라 제어
- **수정:** `@default("viewer")` + admin 승격 별도 로직 + AdminController에 RoleGuard 추가
### B3: WebSocket JWT 실패 시 disconnect 안 함
- **파일:** events.gateway.ts:52-53
- **문제:** invalid token 시 연결 유지 → 미인증 실시간 데이터 수신
- **수정:** catch 블록에서 `client.disconnect()` 호출
### B4: `throw new Error` → HttpException
- **파일:** auth.controller.ts:32
- **문제:** refresh token 없을 때 `throw new Error` → 500 반환
- **수정:** `throw new BadRequestException('Refresh token required')`
### B5: register 엔드포인트 rate limit 없음
- **파일:** auth.controller.ts:18-20
- **문제:** 초대코드 brute-force 가능
- **수정:** `@UseGuards(ThrottlerGuard) @Throttle({ default: { limit: 3, ttl: 60000 } })` 추가
## Non-blocking 이슈 (7건)
- N1: refresh token localStorage 평문 (httpOnly cookie 전환 권장)
- N2: AuthProvider 토큰 만료 시 자동 refresh 안 함
- N3: register에서 refreshToken 미저장
- N4: login 후 userId: 0 하드코딩
- N5: DTO 서비스 파일 내 정의 (별도 파일 권장)
- N6: adminFetch 주석-코드 불일치
- N7: refresh secret suffix 방식 → 별도 env 권장

View File

@@ -1,23 +0,0 @@
# SPRINT-006 QA Review — Iteration 2
- **검증일시:** 2026-04-04 15:17 KST
- **검증자:** 다랑이 (Evaluator)
- **결과:** ✅ PASSED
## Blocking 수정 확인
| ID | 이슈 | 수정 확인 |
|----|------|----------|
| B1 (Critical) | JWT_SECRET 'changeme' 4곳 | ✅ 전부 제거, 0건 grep 확인 |
| B2 (Critical) | User.role "admin" | ✅ @default("viewer") + RoleGuard + @Roles('admin') on Admin/Costs |
| B3 | WS disconnect | ✅ catch 블록에서 client.disconnect() 호출 |
| B4 | throw Error | ✅ BadRequestException 변경 |
| B5 | register Throttle | ✅ @Throttle 3회/분 적용 |
## 검증 항목
| 항목 | 결과 |
|------|------|
| git fetch + reset | ✅ |
| npm test | ✅ 26/26 pass |
| npm run build | ✅ |

View File

@@ -0,0 +1,60 @@
# SPRINT-REDESIGN QA Review — Iteration 1
- **검증일시:** 2026-04-04 04:13 UTC
- **검증자:** 다랑이 (Evaluator)
- **결과:** ✅ PASSED
## 검증 항목
| 항목 | 결과 |
|------|------|
| npm install | ✅ |
| npm run build | ✅ 14 routes |
| 백엔드 변경 | 없음 (FE only) |
## DESIGN-SYSTEM.md 충실도
| 디자인 토큰 | 코드 일치 |
|-------------|-----------|
| 색상 (--bg-main, --text-primary, --border-color 등) | ✅ |
| 폰트 (--font-sans, --font-mono) | ✅ |
| 스페이싱 (--space-xs ~ --space-xxl) | ✅ |
| Sidebar 240px / 64px / 하단 탭바 | ✅ |
| 로고 ● + ◗ | ✅ |
| 네비 [브라켓] active 표기 | ✅ |
| Card 1px border + hover | ✅ |
| TechBar 2px | ✅ |
| Timeline 세로선 + 7px dot | ✅ |
| BracketValue 32px → 24px 모바일 | ✅ |
| LabelMeta 11px 700 uppercase | ✅ |
## 반응형 3단계
| Breakpoint | 확인 |
|------------|------|
| ≥1200px Desktop | ✅ 기본 레이아웃 |
| 768-1199px Tablet | ✅ Sidebar 64px 아이콘, 카드 2열 |
| ≤767px Mobile | ✅ 하단 탭바 56px, 카드 1열(2→1), padding-bottom 72px |
## 새 페이지/컴포넌트
| 항목 | 확인 |
|------|------|
| /activities 페이지 | ✅ |
| /projects 목록 페이지 | ✅ |
| components/ui/base.tsx 공통 컴포넌트 | ✅ |
| CSS vars 전면 적용 (theme.ts 하드코딩 대체) | ✅ |
## Non-blocking 이슈
| ID | 이슈 |
|----|------|
| N1 | 일부 admin 컴포넌트(BarChart, CodeEditor 등)에서 old theme import 잔존 가능 — 빌드 통과했으므로 미사용 import만 정리하면 됨 |
| N2 | SidebarContext가 아직 존재하나 LayoutShell에서 Provider 감싸지 않음 — 사용 안 되면 삭제 권장 |
## 판정 근거
- FE 빌드 14 routes 전부 통과
- DESIGN-SYSTEM.md 디자인 토큰 전부 충실히 구현
- 반응형 3단계 breakpoint 코드 확인
- glassmorphism → 미니멀 터미널 UI 전환 완료
- 백엔드 변경 없어 기능 회귀 위험 없음

View File

@@ -12,16 +12,13 @@
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3", "@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-socket.io": "^11.1.18", "@nestjs/platform-socket.io": "^11.1.18",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.18", "@nestjs/websockets": "^11.1.18",
"@prisma/adapter-mariadb": "^7.6.0", "@prisma/adapter-mariadb": "^7.6.0",
"@prisma/client": "^7.6.0", "@prisma/client": "^7.6.0",
"axios": "^1.14.0", "axios": "^1.14.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"dotenv": "^17.4.0", "dotenv": "^17.4.0",
@@ -30,7 +27,6 @@
"node-ssh": "^13.2.1", "node-ssh": "^13.2.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-http-bearer": "^1.0.1", "passport-http-bearer": "^1.0.1",
"passport-jwt": "^4.0.1",
"prisma": "^7.6.0", "prisma": "^7.6.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
@@ -42,12 +38,10 @@
"@nestjs/cli": "^11.0.17", "@nestjs/cli": "^11.0.17",
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/passport-http-bearer": "^1.0.42", "@types/passport-http-bearer": "^1.0.42",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^7.0.0", "@types/supertest": "^7.0.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
@@ -2294,19 +2288,6 @@
} }
} }
}, },
"node_modules/@nestjs/jwt": {
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz",
"integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==",
"license": "MIT",
"dependencies": {
"@types/jsonwebtoken": "9.0.10",
"jsonwebtoken": "9.0.3"
},
"peerDependencies": {
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
}
},
"node_modules/@nestjs/passport": { "node_modules/@nestjs/passport": {
"version": "11.0.5", "version": "11.0.5",
"resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz",
@@ -2496,17 +2477,6 @@
} }
} }
}, },
"node_modules/@nestjs/throttler": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz",
"integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"reflect-metadata": "^0.1.13 || ^0.2.0"
}
},
"node_modules/@nestjs/websockets": { "node_modules/@nestjs/websockets": {
"version": "11.1.18", "version": "11.1.18",
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.18.tgz", "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.18.tgz",
@@ -3140,16 +3110,6 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -3326,16 +3286,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/keygrip": { "node_modules/@types/keygrip": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz",
@@ -3377,12 +3327,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.12.2", "version": "24.12.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
@@ -3414,28 +3358,6 @@
"@types/passport": "*" "@types/passport": "*"
} }
}, },
"node_modules/@types/passport-jwt": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz",
"integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/jsonwebtoken": "*",
"@types/passport-strategy": "*"
}
},
"node_modules/@types/passport-strategy": {
"version": "0.2.38",
"resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz",
"integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/passport": "*"
}
},
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
@@ -4715,20 +4637,6 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/bcrypt-pbkdf": { "node_modules/bcrypt-pbkdf": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
@@ -4892,12 +4800,6 @@
"ieee754": "^1.1.13" "ieee754": "^1.1.13"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -5720,15 +5622,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -8319,49 +8212,6 @@
"graceful-fs": "^4.1.6" "graceful-fs": "^4.1.6"
} }
}, },
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -8465,42 +8315,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.memoize": { "node_modules/lodash.memoize": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@@ -8515,12 +8329,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/log-symbols": { "node_modules/log-symbols": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
@@ -8965,15 +8773,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-addon-api": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-emoji": { "node_modules/node-emoji": {
"version": "1.11.0", "version": "1.11.0",
"resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz",
@@ -8990,17 +8789,6 @@
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-int64": { "node_modules/node-int64": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -9336,16 +9124,6 @@
"node": ">= 0.4.0" "node": ">= 0.4.0"
} }
}, },
"node_modules/passport-jwt": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
"integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
"passport-strategy": "^1.0.0"
}
},
"node_modules/passport-strategy": { "node_modules/passport-strategy": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
@@ -10050,6 +9828,7 @@
"version": "7.7.4", "version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"

View File

@@ -24,16 +24,13 @@
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3", "@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-socket.io": "^11.1.18", "@nestjs/platform-socket.io": "^11.1.18",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.18", "@nestjs/websockets": "^11.1.18",
"@prisma/adapter-mariadb": "^7.6.0", "@prisma/adapter-mariadb": "^7.6.0",
"@prisma/client": "^7.6.0", "@prisma/client": "^7.6.0",
"axios": "^1.14.0", "axios": "^1.14.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"dotenv": "^17.4.0", "dotenv": "^17.4.0",
@@ -42,7 +39,6 @@
"node-ssh": "^13.2.1", "node-ssh": "^13.2.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-http-bearer": "^1.0.1", "passport-http-bearer": "^1.0.1",
"passport-jwt": "^4.0.1",
"prisma": "^7.6.0", "prisma": "^7.6.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
@@ -57,12 +53,10 @@
"@nestjs/cli": "^11.0.17", "@nestjs/cli": "^11.0.17",
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/passport-http-bearer": "^1.0.42", "@types/passport-http-bearer": "^1.0.42",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^7.0.0", "@types/supertest": "^7.0.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",

View File

@@ -93,12 +93,3 @@ model CostLog {
@@index([sisterName, recordedAt]) @@index([sisterName, recordedAt])
@@index([recordedAt]) @@index([recordedAt])
} }
model User {
id Int @id @default(autoincrement())
username String @unique
password String // bcrypt hash
role String @default("viewer")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@@ -9,8 +9,7 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { CompositeGuard } from '../auth/jwt.guard'; import { ApiKeyGuard } from '../auth/api-key.guard';
import { RoleGuard, Roles } from '../auth/role.guard';
import { SisterNamePipe } from '../common/sister-name.pipe'; import { SisterNamePipe } from '../common/sister-name.pipe';
import type { SisterName } from '../common/sister-name.pipe'; import type { SisterName } from '../common/sister-name.pipe';
@@ -24,8 +23,7 @@ class UpdateHarnessDto {
} }
@Controller('api/admin') @Controller('api/admin')
@UseGuards(CompositeGuard, RoleGuard) @UseGuards(ApiKeyGuard)
@Roles('admin')
export class AdminController { export class AdminController {
constructor(private readonly adminService: AdminService) {} constructor(private readonly adminService: AdminService) {}

View File

@@ -1,6 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
@@ -19,7 +18,6 @@ import { CostsModule } from './costs/costs.module';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true }),
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]),
PrismaModule, PrismaModule,
SistersModule, SistersModule,
HealthModule, HealthModule,

View File

@@ -18,13 +18,13 @@ export class ApiKeyStrategy extends PassportStrategy(Strategy, 'api-key') {
); );
} }
validate(token: string): { role: string; username: string } { validate(token: string): boolean {
if (!this.validKeys.size) { if (!this.validKeys.size) {
throw new UnauthorizedException('ADMIN_API_KEYS not configured'); throw new UnauthorizedException('ADMIN_API_KEYS not configured');
} }
if (!this.validKeys.has(token)) { if (!this.validKeys.has(token)) {
throw new UnauthorizedException('Invalid API key'); throw new UnauthorizedException('Invalid API key');
} }
return { role: "admin", username: "api-key" }; return true;
} }
} }

View File

@@ -1,44 +0,0 @@
import {
Controller,
Post,
Get,
Body,
Headers,
UseGuards,
Request,
BadRequestException,
} from '@nestjs/common';
import { AuthService, RegisterDto, LoginDto } from './auth.service';
import { JwtGuard } from './jwt.guard';
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
@Controller('api/auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@UseGuards(ThrottlerGuard)
@Throttle({ default: { limit: 3, ttl: 60000 } })
@Post('register')
register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@UseGuards(ThrottlerGuard)
@Throttle({ default: { limit: 5, ttl: 60000 } })
@Post('login')
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Post('refresh')
refresh(@Headers('x-refresh-token') token: string) {
if (!token) throw new BadRequestException('Refresh token required');
return this.authService.refresh(token);
}
@UseGuards(JwtGuard)
@Get('me')
getMe(@Request() req: any) {
return this.authService.getMe(req.user.userId);
}
}

View File

@@ -1,32 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt'; import { ConfigModule } from '@nestjs/config';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
import { ApiKeyStrategy } from './api-key.strategy'; import { ApiKeyStrategy } from './api-key.strategy';
import { JwtGuard, CompositeGuard } from './jwt.guard';
import { ApiKeyGuard } from './api-key.guard'; import { ApiKeyGuard } from './api-key.guard';
import { RoleGuard } from './role.guard';
import { PrismaModule } from '../prisma/prisma.module';
@Module({ @Module({
imports: [ imports: [PassportModule, ConfigModule],
PassportModule, providers: [ApiKeyStrategy, ApiKeyGuard],
ConfigModule, exports: [ApiKeyGuard],
PrismaModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: (() => { const s = config.get<string>('JWT_SECRET'); if (!s) throw new Error('JWT_SECRET is not configured'); return s; })(),
signOptions: { expiresIn: '15m' },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, ApiKeyStrategy, JwtGuard, CompositeGuard, ApiKeyGuard, RoleGuard],
exports: [JwtGuard, CompositeGuard, ApiKeyGuard, RoleGuard, AuthService],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -1,79 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import { BadRequestException, ConflictException, UnauthorizedException } from '@nestjs/common';
describe('AuthService', () => {
let service: AuthService;
const mockPrisma = {
user: {
findUnique: jest.fn(),
create: jest.fn(),
},
};
const mockJwt = {
sign: jest.fn().mockReturnValue('mock.jwt.token'),
verify: jest.fn(),
};
const mockConfig = {
get: jest.fn().mockImplementation((key: string) => {
if (key === 'JWT_SECRET') return 'test-secret';
if (key === 'INVITE_CODE') return 'VALID_CODE';
return undefined;
}),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: JwtService, useValue: mockJwt },
{ provide: ConfigService, useValue: mockConfig },
],
}).compile();
service = module.get<AuthService>(AuthService);
jest.clearAllMocks();
});
it('register: 잘못된 초대 코드 → BadRequestException', async () => {
await expect(
service.register({ username: 'test', password: 'password123', inviteCode: 'WRONG' }),
).rejects.toThrow(BadRequestException);
});
it('register: 중복 username → ConflictException', async () => {
mockPrisma.user.findUnique.mockResolvedValue({ id: 1, username: 'test' });
await expect(
service.register({ username: 'test', password: 'password123', inviteCode: 'VALID_CODE' }),
).rejects.toThrow(ConflictException);
});
it('register: 성공 시 토큰 반환', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null);
mockPrisma.user.create.mockResolvedValue({ id: 1, username: 'newuser', role: 'admin' });
const result = await service.register({
username: 'newuser',
password: 'password123',
inviteCode: 'VALID_CODE',
});
expect(result).toHaveProperty('accessToken');
expect(result).toHaveProperty('refreshToken');
expect(result.username).toBe('newuser');
});
it('login: 존재하지 않는 사용자 → UnauthorizedException', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null);
await expect(
service.login({ username: 'unknown', password: 'password123' }),
).rejects.toThrow(UnauthorizedException);
});
});

View File

@@ -1,101 +0,0 @@
import { Injectable, UnauthorizedException, BadRequestException, ConflictException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import * as bcrypt from 'bcrypt';
import { IsString, IsNotEmpty, MinLength, MaxLength } from 'class-validator';
export class RegisterDto {
@IsString() @IsNotEmpty() @MaxLength(32)
username!: string;
@IsString() @MinLength(8) @MaxLength(64)
password!: string;
@IsString() @IsNotEmpty()
inviteCode!: string;
}
export class LoginDto {
@IsString() @IsNotEmpty()
username!: string;
@IsString() @IsNotEmpty()
password!: string;
}
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
) {}
async register(dto: RegisterDto) {
const inviteCode = this.config.get<string>('INVITE_CODE');
if (!inviteCode) throw new BadRequestException('Invite code not configured');
if (dto.inviteCode !== inviteCode) throw new BadRequestException('Invalid invite code');
const existing = await this.prisma.user.findUnique({ where: { username: dto.username } });
if (existing) throw new ConflictException('Username already taken');
const hash = await bcrypt.hash(dto.password, 12);
const user = await this.prisma.user.create({
data: { username: dto.username, password: hash },
});
return this.signTokens(user.id, user.username, user.role);
}
async login(dto: LoginDto) {
const user = await this.prisma.user.findUnique({ where: { username: dto.username } });
if (!user) throw new UnauthorizedException('Invalid credentials');
const ok = await bcrypt.compare(dto.password, user.password);
if (!ok) throw new UnauthorizedException('Invalid credentials');
return this.signTokens(user.id, user.username, user.role);
}
async refresh(refreshToken: string) {
try {
const payload = this.jwt.verify<{ sub: number; username: string; role: string }>(
refreshToken,
{ secret: this.config.get<string>('JWT_SECRET') + '_refresh' },
);
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
if (!user) throw new UnauthorizedException();
return this.signTokens(user.id, user.username, user.role);
} catch {
throw new UnauthorizedException('Invalid refresh token');
}
}
async getMe(userId: number) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, username: true, role: true, createdAt: true },
});
if (!user) throw new UnauthorizedException();
return user;
}
private signTokens(userId: number, username: string, role: string) {
const secret = this.config.get<string>('JWT_SECRET');
if (!secret) throw new Error('JWT_SECRET is not configured');
const payload = { sub: userId, username, role };
const accessToken = this.jwt.sign(payload, {
secret,
expiresIn: '15m',
});
const refreshToken = this.jwt.sign(payload, {
secret: secret + '_refresh',
expiresIn: '7d',
});
return { accessToken, refreshToken, username, role };
}
}

View File

@@ -1,22 +0,0 @@
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtGuard extends AuthGuard('jwt') {
handleRequest<T>(err: Error, user: T): T {
if (err || !user) throw new UnauthorizedException('JWT authentication required');
return user;
}
}
/**
* JWT 먼저 시도 → 실패 시 API Key fallback
* Sprint 008까지 하위호환 유지
*/
@Injectable()
export class CompositeGuard extends AuthGuard(['jwt', 'api-key']) {
handleRequest<T>(err: Error, user: T): T {
if (err || !user) throw new UnauthorizedException('Authentication required');
return user;
}
}

View File

@@ -1,28 +0,0 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
export interface JwtPayload {
sub: number;
username: string;
role: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(config: ConfigService) {
const secret = config.get<string>('JWT_SECRET');
if (!secret) throw new Error('JWT_SECRET not set');
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: secret,
});
}
validate(payload: JwtPayload) {
if (!payload?.sub) throw new UnauthorizedException();
return { userId: payload.sub, username: payload.username, role: payload.role };
}
}

View File

@@ -1,29 +0,0 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException, SetMetadata } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
@Injectable()
export class RoleGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) return true;
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) throw new ForbiddenException('Authentication required');
const hasRole = requiredRoles.includes(user.role);
if (!hasRole) throw new ForbiddenException(`Role '${user.role}' not authorized. Required: ${requiredRoles.join(', ')}`);
return true;
}
}

View File

@@ -1,13 +1,11 @@
import { Controller, Get, Post, Query, Param, UseGuards } from '@nestjs/common'; import { Controller, Get, Post, Query, Param, UseGuards } from '@nestjs/common';
import { CostsService, CostPeriod } from './costs.service'; import { CostsService, CostPeriod } from './costs.service';
import { CompositeGuard } from '../auth/jwt.guard'; import { ApiKeyGuard } from '../auth/api-key.guard';
import { RoleGuard, Roles } from '../auth/role.guard';
import { SisterNamePipe } from '../common/sister-name.pipe'; import { SisterNamePipe } from '../common/sister-name.pipe';
import type { SisterName } from '../common/sister-name.pipe'; import type { SisterName } from '../common/sister-name.pipe';
@Controller('api/admin/costs') @Controller('api/admin/costs')
@UseGuards(CompositeGuard, RoleGuard) @UseGuards(ApiKeyGuard)
@Roles('admin')
export class CostsController { export class CostsController {
constructor(private readonly costsService: CostsService) {} constructor(private readonly costsService: CostsService) {}

View File

@@ -49,20 +49,19 @@ export class CostsService {
if (!sister) return; if (!sister) return;
try { try {
// OpenClaw 세션에서 토큰 사용량 파싱 (python3 → node.js → jq fallback) // OpenClaw 세션에서 토큰 사용량 파싱
const result = await this.ssh.executeCommand( const result = await this.ssh.executeCommand(
sister.ip, sister.ip,
sister.user, sister.user,
keyPath, keyPath,
`SESSION_FILE=~/.openclaw/sessions/main.json; \ `cat ~/.openclaw/sessions/main.json 2>/dev/null | python3 -c "
if [ ! -f "$SESSION_FILE" ]; then echo "unknown 0 0"; \ import json,sys
elif command -v node >/dev/null 2>&1; then \ try:
node -e "try{const d=require('fs').readFileSync(process.env.HOME+'/.openclaw/sessions/main.json','utf8');const j=JSON.parse(d);const u=j.usage||{};console.log((j.model||'unknown')+' '+(u.input_tokens||0)+' '+(u.output_tokens||0))}catch(e){console.log('unknown 0 0')}" 2>/dev/null; \ d=json.load(sys.stdin)
elif command -v python3 >/dev/null 2>&1; then \ usage=d.get('usage',{})
cat $SESSION_FILE | python3 -c "import json,sys;d=json.load(sys.stdin);u=d.get('usage',{});print(d.get('model','unknown'),u.get('input_tokens',0),u.get('output_tokens',0))" 2>/dev/null; \ print(d.get('model','unknown'), usage.get('input_tokens',0), usage.get('output_tokens',0))
elif command -v jq >/dev/null 2>&1; then \ except: print('unknown 0 0')
echo "$(jq -r '.model // "unknown"' $SESSION_FILE) $(jq -r '.usage.input_tokens // 0' $SESSION_FILE) $(jq -r '.usage.output_tokens // 0' $SESSION_FILE)" 2>/dev/null; \ " 2>/dev/null || echo "unknown 0 0"`,
else echo "unknown 0 0"; fi`,
); );
const parts = result.stdout.trim().split(' '); const parts = result.stdout.trim().split(' ');

View File

@@ -8,8 +8,6 @@ import {
} from '@nestjs/websockets'; } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
@WebSocketGateway({ @WebSocketGateway({
cors: { cors: {
@@ -28,41 +26,12 @@ export class EventsGateway
private readonly logger = new Logger(EventsGateway.name); private readonly logger = new Logger(EventsGateway.name);
constructor(
private readonly jwtService: JwtService,
private readonly config: ConfigService,
) {}
afterInit() { afterInit() {
this.logger.log('WebSocket Gateway initialized'); this.logger.log('WebSocket Gateway initialized');
} }
handleConnection(client: Socket) { handleConnection(client: Socket) {
// JWT 인증 필수 — 토큰 없거나 유효하지 않으면 disconnect this.logger.debug(`Client connected: ${client.id}`);
const token =
(client.handshake.auth?.token as string) ??
(client.handshake.headers.authorization as string)?.replace('Bearer ', '');
if (!token) {
this.logger.debug(`WS rejected (no token): ${client.id}`);
client.disconnect();
return;
}
const secret = this.config.get<string>('JWT_SECRET');
if (!secret) {
client.disconnect();
return;
}
try {
const payload = this.jwtService.verify(token, { secret });
(client as any).user = payload;
this.logger.debug(`WS client connected: ${client.id} (${payload.username})`);
} catch {
this.logger.debug(`WS rejected (invalid token): ${client.id}`);
client.disconnect();
}
} }
handleDisconnect(client: Socket) { handleDisconnect(client: Socket) {

View File

@@ -1,22 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { EventsGateway } from './events.gateway'; import { EventsGateway } from './events.gateway';
import { EventsScheduler } from './events.scheduler'; import { EventsScheduler } from './events.scheduler';
import { SistersModule } from '../sisters/sisters.module'; import { SistersModule } from '../sisters/sisters.module';
@Module({ @Module({
imports: [ imports: [SistersModule],
SistersModule,
ConfigModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: (() => { const s = config.get<string>('JWT_SECRET'); if (!s) throw new Error('JWT_SECRET is not configured'); return s; })(),
}),
}),
],
providers: [EventsGateway, EventsScheduler], providers: [EventsGateway, EventsScheduler],
exports: [EventsGateway], exports: [EventsGateway],
}) })

View File

@@ -136,15 +136,6 @@ export default function HarnessPage() {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ content }), body: JSON.stringify({ content }),
}); });
if (res.status === 401) {
setResult({ success: false, msg: '인증 실패 — API Key 또는 JWT 토큰을 확인해' });
return;
}
if (!res.ok) {
const text = await res.text();
setResult({ success: false, msg: `서버 오류 ${res.status}: ${text.slice(0, 100)}` });
return;
}
const d = await res.json(); const d = await res.json();
if (d.success) { if (d.success) {
setSaved(content); setSaved(content);

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { useState, useCallback, useEffect } from 'react'; import React, { useState, useCallback } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import LogTerminal from '@/components/admin/LogTerminal'; import LogTerminal from '@/components/admin/LogTerminal';
import { adminFetch } from '@/lib/adminFetch'; import { adminFetch } from '@/lib/adminFetch';
@@ -82,11 +82,6 @@ export default function LogsPage() {
} }
}, [sister, lines]); }, [sister, lines]);
// 마운트 시 자동 로드
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
return ( return (
<> <>
<Controls> <Controls>

View File

@@ -1,7 +1,7 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import StyledComponentsRegistry from '@/lib/registry'; import StyledComponentsRegistry from '@/lib/registry';
import GlobalStyle from '@/styles/GlobalStyle'; import GlobalStyle from '@/styles/GlobalStyle';
import AppShell from '@/components/common/AppShell'; import LayoutShell from '@/components/common/LayoutShell';
export const metadata: Metadata = { export const metadata: Metadata = {
title: '하나랑 대시보드', title: '하나랑 대시보드',
@@ -18,7 +18,7 @@ export default function RootLayout({
<body> <body>
<StyledComponentsRegistry> <StyledComponentsRegistry>
<GlobalStyle /> <GlobalStyle />
<AppShell>{children}</AppShell> <LayoutShell>{children}</LayoutShell>
</StyledComponentsRegistry> </StyledComponentsRegistry>
</body> </body>
</html> </html>

View File

@@ -1,189 +0,0 @@
'use client';
import React, { useState } from 'react';
import styled from 'styled-components';
import Link from 'next/link';
import { useAuth } from '@/lib/AuthContext';
import { useRouter } from 'next/navigation';
const Page = styled.div`
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--bg-main);
`;
const Box = styled.div`
width: 100%;
max-width: 360px;
padding: var(--space-xl);
`;
const Logo = styled.div`
display: flex;
align-items: center;
gap: 4px;
margin-bottom: var(--space-xxl);
`;
const CircleFull = styled.div`
width: 20px; height: 20px;
background: var(--text-primary);
border-radius: 50%;
`;
const CircleHalf = styled.div`
width: 10px; height: 20px;
background: var(--text-primary);
border-radius: 0 20px 20px 0;
`;
const Title = styled.h1`
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-xs);
letter-spacing: -0.02em;
`;
const Subtitle = styled.p`
font-size: 12px;
color: var(--text-secondary);
font-family: var(--font-mono);
margin-bottom: var(--space-xl);
`;
const Form = styled.form`
display: flex;
flex-direction: column;
gap: var(--space-md);
`;
const FieldLabel = styled.label`
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin-bottom: var(--space-xs);
display: block;
`;
const Input = styled.input`
width: 100%;
background: var(--bg-input);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 10px 12px;
font-family: var(--font-mono);
font-size: 13px;
outline: none;
transition: border-color 0.15s;
&:focus { border-color: var(--border-hover); }
&::placeholder { color: var(--text-secondary); opacity: 0.5; }
`;
const SubmitBtn = styled.button`
background: var(--text-primary);
border: 1px solid var(--text-primary);
color: #000;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 12px;
cursor: pointer;
transition: all 0.15s;
margin-top: var(--space-sm);
&:hover { background: transparent; color: var(--text-primary); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
`;
const ErrorMsg = styled.div`
font-family: var(--font-mono);
font-size: 11px;
color: #ff5f5f;
padding: var(--space-sm);
border: 1px solid #ff5f5f44;
background: rgba(255,95,95,0.05);
`;
const FooterLink = styled.div`
margin-top: var(--space-lg);
font-size: 12px;
color: var(--text-secondary);
font-family: var(--font-mono);
text-align: center;
a { color: var(--text-primary); text-decoration: none;
&:hover { text-decoration: underline; } }
`;
export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(username, password);
router.push('/');
} catch (err) {
setError((err as Error).message ?? '로그인 실패');
} finally {
setLoading(false);
}
};
return (
<Page>
<Box>
<Logo><CircleFull /><CircleHalf /></Logo>
<Title> </Title>
<Subtitle>SYSTEM_ACCESS // AUTHENTICATE</Subtitle>
<Form onSubmit={handleSubmit}>
<div>
<FieldLabel>USERNAME</FieldLabel>
<Input
type="text"
placeholder="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
/>
</div>
<div>
<FieldLabel>PASSWORD</FieldLabel>
<Input
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
{error && <ErrorMsg>ERR: {error}</ErrorMsg>}
<SubmitBtn type="submit" disabled={loading}>
{loading ? 'AUTHENTICATING...' : 'LOGIN'}
</SubmitBtn>
</Form>
<FooterLink>
? <Link href="/register">REGISTER</Link>
</FooterLink>
</Box>
</Page>
);
}

View File

@@ -1,215 +0,0 @@
'use client';
import React, { useState } from 'react';
import styled from 'styled-components';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { API_URL } from '@/lib/config';
const Page = styled.div`
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--bg-main);
`;
const Box = styled.div`
width: 100%;
max-width: 360px;
padding: var(--space-xl);
`;
const Logo = styled.div`
display: flex;
align-items: center;
gap: 4px;
margin-bottom: var(--space-xxl);
`;
const CircleFull = styled.div`
width: 20px; height: 20px;
background: var(--text-primary);
border-radius: 50%;
`;
const CircleHalf = styled.div`
width: 10px; height: 20px;
background: var(--text-primary);
border-radius: 0 20px 20px 0;
`;
const Title = styled.h1`
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-xs);
letter-spacing: -0.02em;
`;
const Subtitle = styled.p`
font-size: 12px;
color: var(--text-secondary);
font-family: var(--font-mono);
margin-bottom: var(--space-xl);
`;
const Form = styled.form`
display: flex;
flex-direction: column;
gap: var(--space-md);
`;
const FieldLabel = styled.label`
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin-bottom: var(--space-xs);
display: block;
`;
const Input = styled.input`
width: 100%;
background: var(--bg-input);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 10px 12px;
font-family: var(--font-mono);
font-size: 13px;
outline: none;
transition: border-color 0.15s;
&:focus { border-color: var(--border-hover); }
&::placeholder { color: var(--text-secondary); opacity: 0.5; }
`;
const SubmitBtn = styled.button`
background: var(--text-primary);
border: 1px solid var(--text-primary);
color: #000;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 12px;
cursor: pointer;
transition: all 0.15s;
margin-top: var(--space-sm);
&:hover { background: transparent; color: var(--text-primary); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
`;
const ErrorMsg = styled.div`
font-family: var(--font-mono);
font-size: 11px;
color: #ff5f5f;
padding: var(--space-sm);
border: 1px solid #ff5f5f44;
background: rgba(255,95,95,0.05);
`;
const SuccessMsg = styled.div`
font-family: var(--font-mono);
font-size: 11px;
color: #00FF00;
padding: var(--space-sm);
border: 1px solid rgba(0,255,0,0.3);
background: rgba(0,255,0,0.03);
`;
const FooterLink = styled.div`
margin-top: var(--space-lg);
font-size: 12px;
color: var(--text-secondary);
font-family: var(--font-mono);
text-align: center;
a { color: var(--text-primary); text-decoration: none;
&:hover { text-decoration: underline; } }
`;
const InviteNote = styled.div`
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
opacity: 0.6;
margin-top: 4px;
`;
export default function RegisterPage() {
const [form, setForm] = useState({ username: '', password: '', inviteCode: '' });
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccess('');
setLoading(true);
try {
const res = await fetch(`${API_URL}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
const d = await res.json();
if (!res.ok) {
setError(Array.isArray(d.message) ? d.message[0] : (d.message ?? '등록 실패'));
return;
}
localStorage.setItem('hanarang_access_token', d.accessToken);
setSuccess('등록 완료. 대시보드로 이동...');
setTimeout(() => router.push('/'), 1000);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
const set = (key: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((p) => ({ ...p, [key]: e.target.value }));
return (
<Page>
<Box>
<Logo><CircleFull /><CircleHalf /></Logo>
<Title> </Title>
<Subtitle>SYSTEM_ACCESS // REGISTER_NEW_NODE</Subtitle>
<Form onSubmit={handleSubmit}>
<div>
<FieldLabel>USERNAME</FieldLabel>
<Input type="text" placeholder="username" value={form.username} onChange={set('username')} required />
</div>
<div>
<FieldLabel>PASSWORD</FieldLabel>
<Input type="password" placeholder="•••••••• (8자 이상)" value={form.password} onChange={set('password')} required minLength={8} />
</div>
<div>
<FieldLabel>INVITE CODE</FieldLabel>
<Input type="text" placeholder="초대 코드" value={form.inviteCode} onChange={set('inviteCode')} required />
<InviteNote> </InviteNote>
</div>
{error && <ErrorMsg>ERR: {error}</ErrorMsg>}
{success && <SuccessMsg> {success}</SuccessMsg>}
<SubmitBtn type="submit" disabled={loading}>
{loading ? 'REGISTERING...' : 'REGISTER'}
</SubmitBtn>
</Form>
<FooterLink>
? <Link href="/login">LOGIN</Link>
</FooterLink>
</Box>
</Page>
);
}

View File

@@ -1,50 +0,0 @@
'use client';
import React, { useEffect } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { AuthProvider, useAuth } from '@/lib/AuthContext';
import LayoutShell from './LayoutShell';
const PUBLIC_PATHS = ['/login', '/register'];
function AuthGate({ children }: { children: React.ReactNode }) {
const { isAuthenticated, loading } = useAuth();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (loading) return;
const isPublic = PUBLIC_PATHS.includes(pathname);
if (!isAuthenticated && !isPublic) {
router.replace('/login');
}
if (isAuthenticated && isPublic) {
router.replace('/');
}
}, [isAuthenticated, loading, pathname, router]);
if (loading) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '100vh', background: '#151515', color: '#8A8A8A',
fontFamily: 'monospace', fontSize: '12px',
}}>
AUTHENTICATING...
</div>
);
}
const isPublic = PUBLIC_PATHS.includes(pathname);
if (isPublic) return <>{children}</>;
return <LayoutShell>{children}</LayoutShell>;
}
export default function AppShell({ children }: { children: React.ReactNode }) {
return (
<AuthProvider>
<AuthGate>{children}</AuthGate>
</AuthProvider>
);
}

View File

@@ -4,7 +4,6 @@ import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useAuth } from '@/lib/AuthContext';
import { useSidebar } from '@/lib/SidebarContext'; import { useSidebar } from '@/lib/SidebarContext';
const NAV_ITEMS = [ const NAV_ITEMS = [
@@ -145,7 +144,6 @@ const TabItem = styled(Link)<{ $active: boolean }>`
export default function Sidebar() { export default function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const { user, logout } = useAuth();
const isActive = (href: string) => const isActive = (href: string) =>
href === '/' ? pathname === '/' : pathname.startsWith(href); href === '/' ? pathname === '/' : pathname.startsWith(href);
@@ -168,25 +166,6 @@ export default function Sidebar() {
))} ))}
</NavMenu> </NavMenu>
</nav> </nav>
{user && (
<div style={{ marginTop: 'auto', paddingTop: 'var(--space-xl)' }}>
<div style={{ fontSize: '11px', color: 'var(--text-secondary)', fontFamily: 'var(--font-mono)', marginBottom: 'var(--space-sm)' }}>
[{user.username}]
</div>
<button
onClick={logout}
style={{
background: 'transparent', border: 'none', color: 'var(--text-secondary)',
fontSize: '13px', cursor: 'pointer', padding: 0, textAlign: 'left',
transition: 'color 0.15s', fontFamily: 'inherit',
}}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--text-primary)')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-secondary)')}
>
</button>
</div>
)}
</SidebarWrapper> </SidebarWrapper>
<BottomTabBar> <BottomTabBar>

View File

@@ -1,91 +0,0 @@
'use client';
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { API_URL } from './config';
interface AuthUser {
userId: number;
username: string;
role: string;
}
interface AuthContextValue {
user: AuthUser | null;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
isAuthenticated: boolean;
}
const AuthContext = createContext<AuthContextValue>({
user: null,
loading: true,
login: async () => {},
logout: () => {},
isAuthenticated: false,
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
const loadUser = useCallback(async () => {
const token = localStorage.getItem('hanarang_access_token');
if (!token) { setLoading(false); return; }
try {
const res = await fetch(`${API_URL}/api/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setUser({ userId: data.id, username: data.username, role: data.role });
} else {
localStorage.removeItem('hanarang_access_token');
}
} catch {
// silent
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadUser(); }, [loadUser]);
const login = async (username: string, password: string) => {
const res = await fetch(`${API_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const d = await res.json();
throw new Error(d.message ?? '로그인 실패');
}
const d = await res.json();
localStorage.setItem('hanarang_access_token', d.accessToken);
if (d.refreshToken) {
localStorage.setItem('hanarang_refresh_token', d.refreshToken);
}
setUser({ userId: 0, username: d.username, role: d.role });
};
const logout = () => {
localStorage.removeItem('hanarang_access_token');
localStorage.removeItem('hanarang_refresh_token');
setUser(null);
};
return (
<AuthContext.Provider value={{ user, loading, login, logout, isAuthenticated: !!user }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}

View File

@@ -1,26 +1,22 @@
/** /**
* 관리자 API 호출 유틸. * 관리자 API 호출 유틸.
* - CSR: 상대경로 /api/... → Next.js rewrites → BE * API_URL이 빈 문자열이면 상대경로 → Next.js rewrites → BE 프록시.
* - JWT access token (우선) 또는 API Key fallback으로 Authorization 헤더 설정 * Authorization 헤더에 localStorage의 admin key 삽입.
*/ */
export function getAdminToken(): string {
if (typeof window === 'undefined') return '';
return (
localStorage.getItem('hanarang_access_token') ??
localStorage.getItem('hanarang_admin_key') ??
''
);
}
export function adminFetch(path: string, options?: RequestInit) { export function adminFetch(path: string, options?: RequestInit) {
const token = getAdminToken(); const apiKey =
typeof window !== 'undefined'
? (localStorage.getItem('hanarang_admin_key') ?? '')
: '';
// Authorization: options?.headers보다 우선 (덮어쓰기 방지) // CSR: path가 /api/... 이면 상대경로 그대로 사용
const headers: Record<string, string> = { // SSR에서는 adminFetch 미사용 (모든 admin 페이지가 'use client')
'Content-Type': 'application/json', return fetch(path, {
...(options?.headers as Record<string, string> ?? {}), ...options,
...(token ? { Authorization: `Bearer ${token}` } : {}), headers: {
}; 'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
return fetch(path, { ...options, headers }); ...(options?.headers ?? {}),
},
});
} }

View File

@@ -16,14 +16,12 @@ export function useSocket(options: UseSocketOptions = {}) {
// next.config.ts rewrites가 없는 경우를 위해 빈 origin 처리 // next.config.ts rewrites가 없는 경우를 위해 빈 origin 처리
// 브라우저에서 WebSocket은 rewrites 대상이 아니므로 직접 BE URL 필요 // 브라우저에서 WebSocket은 rewrites 대상이 아니므로 직접 BE URL 필요
const wsUrl = process.env.NEXT_PUBLIC_WS_URL ?? window.location.origin; const wsUrl = process.env.NEXT_PUBLIC_WS_URL ?? window.location.origin;
const token = localStorage.getItem('hanarang_access_token') ?? '';
const socket = io(`${wsUrl}/ws`, { const socket = io(`${wsUrl}/ws`, {
path: '/socket.io', path: '/socket.io',
transports: ['websocket', 'polling'], transports: ['websocket', 'polling'],
reconnectionAttempts: 5, reconnectionAttempts: 5,
reconnectionDelay: 3000, reconnectionDelay: 3000,
auth: { token },
}); });
socket.on('connect', () => { socket.on('connect', () => {