# insuniverse-server 작업 규칙

## 관리자 API 추가 · 변경

새 `/admin/` 경로는 아래 다섯 가지를 같은 커밋에 넣는다.

1. **가드**: 컨트롤러에 `@UseGuards(AdminJwtAuthGuard, RolesGuard)` 와 `@Roles(...)` 를 건다.
   `@Roles` 를 빼면 관리자 역할 전원이 통과한다. 최고관리자 전용이면 `SuperAdminGuard` 를 셋째에 붙인다.
   순서는 인증, 역할, 최고관리자다.
2. **역할**: `@Roles` 에는 실제로 부를 역할만 적는다. 기업 최고관리자 · 조직관리자만 받으려면 신원 종류 낱말(`corp-super` · `org-manager`)을 쓴다 — 핸들러 본문에서 `corpScope` 로 거부하지 않는다. 영업·기업 역할을 넣으면 담당 범위 검사를 핸들러 안에 둔다.
   범위 상수가 꺼져 있다고 검사 호출을 빼지 않는다.
   분석 건을 읽는 경로는 예외다. 핸들러 안이 아니라 클래스 `AnalysisRowGuard`(판정 함수 `canViewAnalysis` 하나)가 본다.
   모든 가드를 통과한 관리자 읽기는 감사 줄 한 줄(`AuditService.recordAnalysisRead`)을 남긴다. 컨트롤러는 `@UseInterceptors(AnalysisReadInterceptor('analysis' | 'b2c-analysis' | 'client-analysis'))` 로, 보조 조회는 마지막 가드인 `AnalysisTargetGuard` 가 남긴다. 이 인터셉터를 걸지 않은 새 관리자 분석 읽기 경로는 기록되지 않는다.
3. **맵 행**: `apps/api/src/modules/auth/permission/route-permission-map.ts` 에 `['METHOD /admin/...', '코드']` 를 넣는다.
   이 파일 머리 주석의 「직접 고치지 않는다」는 낡았다. 생성기 입력이 동결돼 있어 행은 손으로 넣는다.
   코드는 `permission-catalog.ts` 에 있어야 한다.
4. **칸 표**: 새 권한 코드는 `apps/api/src/modules/menu/menu-permission-map.ts` 의 칸 표(`MENU_PERMISSION_TABLE`, 키는 tn_menu 메뉴 코드)에
   보기 · 변경 코드로 건다. 어느 칸에도 안 걸리는 코드는 `MENU_OUTSIDE_CODES` 에 사유와 함께 적는다 (사유 빈칸은 붉다).
   표시 이름은 두지 않는다 — 칸 이름은 실행 중 tn_menu 가 준다 (E5 · 14 결정 문서).
5. **예외**: 맵 행을 둘 수 없는 경로(공개 로그인, 본인 조회, 다른 역할 전용 등)는
   `apps/api/src/modules/auth/permission/route-permission-exceptions.ts` 에 `{ method, path, reason }` 로 적는다.
   관리자 쿠키의 신원(admId 있음)이 맵 밖 `/admin/` 요청을 보내면 이 원장에 없을 때 403 이다 (전역 가드, `ENFORCE_UNMAPPED_ADMIN_DENY`).
   영업 · 기업 신원과 판정 준비가 실패한 요청은 이 거부를 받지 않는다.

강제 스위치(`access-control.constants.ts` 의 `ENFORCE_*`)를 켜는 변경은 이 규칙이 아니라 운영 절차를 따른다.

## 관리자 화면 쪽 규칙

관리자 화면(`../insuniverse-admin`)은 이 맵을 그대로 읽는다. 새 `/admin/` 경로를 넣으면 화면 쪽도 같은 커밋 흐름에서 맞춘다.

- 새 관리자 라우트를 화면에 올리면 라우트 메타 `handle.api` 를 달거나 화면 쪽 베이스라인에 사유를 적는다 (`../insuniverse-admin/CLAUDE.md` 「권한 판정」 절).
- 화면의 쓰기 버튼은 공용 판정(`useWriteGate`)을 거친다. 권한 코드 목록을 화면에 복사하지 않는다 (판정 지도 `GET /admin/permission-map` 이 원천이다).

## 노출 키

메뉴 · 기능을 숨김 · 내부 검수자만 · 전체 공개로 여닫는 키. 정본은 `apps/api/src/modules/release/release.registry.ts`, 값은 `tn_release_flag`(`drizzle/0042_release_flag.sql`).

- 새 회원 · 공개(신원 없음) 경로는 접근 계약 등록표 행에 `release` 칸(키 또는 `'public'`)을 단다. 도입 때 있던 행은 `auth/access-contract/release-golden.txt` 가 동결한다(다시 쓰기는 `RELEASE_GOLDEN_WRITE=1`, diff 를 리뷰한다).
- 공개 전 기능은 출시 키(`kind: 'launch'`, `owner` · `createdAt` · `removeBy` 필수)를 등록하고 서버 경로에 `@Release(key)` 를 단다. 행이 없거나 읽기에 실패하면 숨김(404)이다. 고객 링크처럼 요청자가 검수 대상이 아닌 경로(신원 가드 없음 · 등록표 신원 `없음`)만 `{ onlyHidden: true }`.
- `@Release` 는 신원 가드 `@UseGuards(...)` **위에** 적는다(아래 데코레이터가 먼저 적용되어 가드가 신원 가드 뒤에 붙는다).
- 새 `tn_menu` INSERT 는 그 메뉴 코드를 출시 키로 등록한다. 등록부에 없는 메뉴 코드는 운영 수준 목록(`PROD_LEVEL_MENU_CODES`, 지금 운영에 보이는 메뉴) 안이면 상시 키(행 없음 = 보임), 밖이면 행 없음 = 숨김이다. stg 에서 지금처럼 보이게 하려면 stg DB 에 `public` 행을 넣는다(부모 `.claude/meta/2026-11-09-release-control/stg-seed.mjs` 가 SQL 을 만든다). DB 에 직접 넣어 메뉴 표(`menu-permission-map.ts`)에 없는 메뉴 코드는 운영 알림 「메뉴 표에 없는 메뉴 코드」(`menu-code`)가 알린다.
- 사이트 키(`site.*`)는 이 등록부가 원천이고 사이트 `exposure.registry.json` 이 사본이다. 대조는 사이트 노출 검사 G8 이 옆 저장소의 이 파일을 읽어 한다 — CI 는 저장소 하나만 받아 건너뛰므로 아래 필수 검증에서 로컬로 돈다.
- 검수 회원(`memIds`)에는 팀이 가진 시험 회원 계정만 넣는다. 결제 · 머니 · 정산 · 알림톡 발송이 걸리는 메뉴는 `internal` 을 쓰지 않는다(숨김 ↔ 공개만).
- CI 가 붉히는 것: 등록부 밖 키(R1) · 지운 키 재사용(R2) · 키 메타와 `ReleaseGuard` 짝 없음(R3) · 신원 가드보다 앞선 `ReleaseGuard` · 신원 있는 경로의 `onlyHidden`(R4) · 등록표 `release` 칸 ≠ 실제 메타(R5) · 동결 목록 밖 회원 · 공개 행의 칸 누락(R6) · 핸들러 없는 키(R7, `noRoute` 사유가 있는 키는 면제 — 서버 경로 없는 화면의 출시 키도 `noRoute` 로 연다. 빈 사유는 면제가 아니다) — 접근 계약 실제 AppModule 시험. 키 형식 · 정리 기한(`removeBy` 가 오늘보다 앞) · SQL 이 넣는 메뉴 코드 = 동결 목록 ∪ 출시 키 · 메뉴 표 코드 − 출시 메뉴 키 = 운영 수준 목록(표에 새 코드는 출시 키로만) — `release.registry.spec.ts`.

### 메뉴 노출 — 필수 검증

노출 키 · `@Release` · 접근 계약 등록표 · 메뉴 표 · 운영 수준 목록 · drizzle `tn_menu` INSERT 를 건드린 커밋은 올리기 전에 로컬에서 녹색을 본다. GitHub 필수 검사로 걸지 않는다(2026-11-10 결정). 정본은 로컬 검증이고 `pr-check.yml` 은 같은 명령을 PR 에서 한 번 더 도는 보조다.

```bash
bash scripts/ci-specs.sh                                            # Redis 127.0.0.1:6380 필요
npx tsc --noEmit -p apps/api/tsconfig.app.json --incremental false  # 오류 0
(cd ../insuniverse-site/front && npm run guard)                     # 사이트 키를 건드렸으면: 서버 ↔ 사이트 키 대조(G8)
bash ../.claude/meta/2026-11-09-release-control/verify-local.sh     # 부모 release-control 체크아웃에만 있다: 세 저장소 한 번에(Redis 없으면 임시로 띄움)
```

| 바꾼 것 | 붉으면 고칠 곳 |
|---|---|
| 새 회원 · 공개 API | 접근 계약 등록표 행의 `release` 칸(키 또는 `'public'`) |
| 새 관리자 메뉴 | 출시 키(`kind: 'launch'`) + drizzle SQL 의 `tn_menu` INSERT |
| 새 사이트 라우트 · 메뉴 | 사이트 `exposure.registry.json`(`../insuniverse-site/CLAUDE.md` 「메뉴 노출 키」) |
| 키 추가 · 이름 변경 | 이 등록부와 사이트 등록부 둘 다(사이트 G8 이 대조) |

## CI 가 붉히는 것

`apps/api/src/modules/auth/access-contract/access-contract.spec.ts` 의 실제 AppModule 시험이 아래를 본다.
이 시험은 `scripts/ci-specs.sh` 의 SPECS 목록에 있다(배포 `api.yml` · PR 검사 `pr-check.yml` 공용). 목록은 브랜치마다 다르다 — 운영 이식 때 그 브랜치의 spec 으로 맞춘다.
`api.yml` 의 SPECS 블록에 spec 을 더하던 브랜치는 stg 를 병합할 때 그 줄을 `scripts/ci-specs.sh` 로 옮긴다. 충돌에서 `api.yml` 의 새 한 줄만 고르면 그 spec 이 배포 · PR 검사에서 조용히 빠진다.

- `/admin/` 런타임 경로가 맵에도 예외 원장에도 없음 (관리자 맵 누락)
- 맵 행이 런타임에 없고 예외 원장에도 없음 (맵 고아)
- 맵 코드가 권한 카탈로그에 없음 (맵 코드 카탈로그 밖)
- 인증 가드(`AdminJwtAuthGuard`)가 없는 `/admin/` 핸들러이고 예외 원장에도 없음
- `/admin/authority` 핸들러 · 관리자 계정 조회(`GET /admin/admin`, `GET /admin/admin/:admId`)에 `SuperAdminGuard` 가 없음
- 예외 원장 항목의 사유가 빈칸
- 레지스트리 `roles` 칸이 실제 `@Roles` 와 다름 (역할 칸 불일치)
- 인증 가드 핸들러에 `RolesGuard` + 비지 않은 `@Roles` 도 `SuperAdminGuard` 도 없음 (예외: `GET /admin/me` · `GET /admin/permission-map` · `GET /admin/menu/my`) · 공개 표지(`isPublic`)가 붙음
- `@Roles` 에 `corp` 또는 `org-manager` 가 있는데 `CorpReadonlyGuard` 가 없음
- 분석 수준 표지(`@CorpAnalysisView`)에 `CorpAnalysisViewGuard` 가 없음 · 표지가 있는 컨트롤러의 기업 핸들러가 수준을 선언하지 않음
- 인증 가드 핸들러에 비관리자 엔드포인트 표(`identity-endpoints.ts` 의 알려진 가드 표)가 모르는 가드가 붙음
- 영업 · 기업 최고 · 조직관리자가 부를 수 있는 엔드포인트 표가 골든(`identity-endpoints.golden.txt`)과 다름.
  역할 · 가드를 바꿔 표가 달라지면 `IDENTITY_GOLDEN_WRITE=1` 로 이 시험을 돌려 골든을 다시 쓰고 diff 를 커밋에 넣는다
  (관리자 화면은 이 표를 판정 지도 `GET /admin/permission-map` 로 받는다)
- 경로에 `:oddId` · `:asbId` · `:acbId` 가 있는 핸들러에 행 판정(`AnalysisRowGuard` · `OwnAnalysisGuard` · 내부 관리자 전용)이 없음 · 보조 조회 5개에 `AnalysisTargetGuard` 가 없음 · 분석 서비스 · 저장소를 받는 컨트롤러 목록이 고정 목록과 다름
- `/admin-view/analyze/*` 의 핸들러 · 가드 · 역할이 `/admin/analyze/*` 와 다름 · `/admin-view/*` 핸들러에 `AdminJwtAuthGuard` 나 맵 행이 없음
- `/admin-view-token` 경로가 다시 생김 · 비시험 소스가 옛 토큰 헤더(`x-admin-view-token`)를 읽음
- 카탈로그 코드가 칸 표 · 메뉴 밖 목록 어디에도 없음 · 칸 표 코드가 카탈로그 밖 · 메뉴 밖 사유 빈칸 · 메뉴 밖과 칸 표 겹침
  (`menu-permission-map.spec.ts` 가 음성을 고정한다. 접근 계약 시험도 같은 함수를 부른다)

## 시험이 보지 않는 것

- 역할 · 최고관리자 가드가 의도한 것인지 (리뷰가 본다)
- 맵 값이 맞는 권한 코드인지 (카탈로그에 있는지만 본다)
- 칸에 건 권한 묶음이 그 칸의 하위 기능과 맞는지 (리뷰가 본다)
- 분석을 `:oddId` · `:asbId` · `:acbId` 밖의 경로 칸이나 `AnalyzeService` · `AnalyzeRepository` 밖의 서비스 · 저장소(`DiseaseRepository` 등)로 읽는 핸들러 (리뷰가 본다)
