<!-- task=flutter_first_portal_migration/04_control_plane_ws_endpoint plan=1 tag=REVIEW_CP_WS -->

# Plan - REVIEW_CP_WS

## 이 파일을 읽는 구현 에이전트에게

구현 마지막 단계에서 `CODE_REVIEW-cloud-G08.md`의 구현 에이전트 소유 섹션을 반드시 실제 변경 내용과 검증 출력으로 채운다. 검증 명령을 실행하고 실제 stdout/stderr를 기록한 뒤 active 파일은 그대로 두고 리뷰 준비 상태만 보고한다. `complete.log` 작성, `.md` 파일 로그화, archive 이동은 code-review-skill 전용이다.

## 배경

첫 리뷰에서 Control Plane WS endpoint가 Go proto-socket client에는 응답하지만, 브라우저 Origin이 있는 Flutter Web 연결을 거부하는 문제가 확인됐다. Flutter Web 기본 wire URL은 `ws://localhost:19080/portal`이고 dev server는 다른 포트에서 뜨므로, 브라우저 handshake에는 cross-origin 처리가 필요하다. 이 후속 작업은 Origin 허용 경로와 regression test만 추가한다.

## 분석 결과

### 읽은 파일

- `agent-task/flutter_first_portal_migration/04_control_plane_ws_endpoint/plan_cloud_G08_0.log`
- `agent-task/flutter_first_portal_migration/04_control_plane_ws_endpoint/code_review_cloud_G08_0.log`
- `agent-ops/roadmap/milestones/flutter-first-portal-migration.md`
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/project/domain/platform-common/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `apps/control-plane/internal/wire/portal.go`
- `apps/control-plane/internal/wire/portal_test.go`
- `apps/control-plane/internal/wire/wire.go`
- `apps/control-plane/cmd/control-plane/main.go`
- `apps/control-plane/cmd/control-plane/main_test.go`
- `apps/portal/lib/portal_config.dart`
- `go.mod`
- `../proto-socket/go/ws_server.go`
- `../proto-socket/go/ws_client.go`
- `../proto-socket/go/communicator.go`
- `../proto-socket/go/test/ws_test.go`
- `../proto-socket/go/test/test_helpers_test.go`
- `../proto-socket/go/test/communicator_test.go`
- `/config/go/pkg/mod/nhooyr.io/websocket@v1.8.17/accept.go`
- `/config/go/pkg/mod/nhooyr.io/websocket@v1.8.17/dial.go`

### 테스트 커버리지 공백

- 기존 `TestPortalServerHandshake`는 `proto_socket.DialWsWithHeartbeat`를 사용해 Origin 헤더가 없는 Go client 경로만 검증한다.
- Flutter Web dev 경로처럼 `Origin: http://localhost:3000`이 붙은 handshake를 검증하는 테스트가 없다.
- `proto-socket` WS server가 기본 same-origin 정책을 유지하면서 명시 OriginPatterns만 허용하는지 검증하는 테스트가 없다.

### 심볼 참조

- renamed/removed symbol: 없음.
- `proto_socket.NewWsServer`는 유지한다. 새 Origin 설정 API를 추가하되 기존 call site와 기존 테스트가 깨지지 않아야 한다.
- 변경 대상 call site: `apps/control-plane/internal/wire/portal.go:48`.

### 분할 판단

분할 정책을 평가했다. 이 작업은 `../proto-socket/go`의 WS accept option 확장과 Control Plane의 단일 소비 call site 적용이 맞물린 하나의 Required issue다. API만 분리하면 Control Plane bug가 남고, 소비자만 분리하면 API가 없어 구현할 수 없으므로 같은 active task의 단일 follow-up plan으로 처리한다.

### 범위 결정 근거

- Portal 화면 구현, 인증, 권한, 운영 API 추가는 범위 밖이다.
- 전체 CORS/Origin configuration 설계는 범위 밖이다. 이번 작업은 Flutter Web local/dev 기본 경로를 안전하게 통과시키는 최소 OriginPatterns와 regression test만 다룬다.
- `PortalHelloRequest`/`PortalHelloResponse` proto 계약은 바꾸지 않는다.

### 빌드 등급

Build lane `cloud-G08`, review lane `cloud-G08`. 브라우저 WS handshake, sibling `proto-socket` API, Control Plane protocol endpoint를 함께 다루며 기존 테스트가 놓친 통합 동작을 보강한다.

## 구현 체크리스트

- [ ] [REVIEW_CP_WS-1] 브라우저 Origin이 있는 Portal WS handshake를 허용하고 regression test를 추가한다.
- [ ] 중간 검증과 최종 검증 명령을 실행하고 실제 출력을 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [REVIEW_CP_WS-1] Browser-origin WS handshake

문제: `apps/control-plane/internal/wire/portal.go:48`은 `proto_socket.NewWsServer`를 사용하고, `../proto-socket/go/ws_server.go:130`은 `websocket.Accept(w, r, nil)`로 handshake를 받는다. `nhooyr.io/websocket`은 cross-origin 요청을 기본 거부하므로 `apps/portal/lib/portal_config.dart:7`의 기본 `ws://localhost:19080/portal`은 Flutter Web dev server의 `Origin: http://localhost:3000`에서 실패한다.

해결 방법: `proto-socket` WS server에 accept options를 주입하는 non-breaking API를 추가하고, Control Plane Portal server가 local Portal dev OriginPatterns를 전달하게 한다. 기존 `NewWsServer`/`NewWsServerTLS`는 nil options로 기존 동작을 유지한다.

Before:

```go
../proto-socket/go/ws_server.go:30
func NewWsServer(host string, port int, path string, newClient func(*websocket.Conn) *WsClient) *WsServer {
```

```go
../proto-socket/go/ws_server.go:130
conn, err := websocket.Accept(w, r, nil)
```

After:

```go
type WsServerOptions struct {
	AcceptOptions *websocket.AcceptOptions
}

func NewWsServerWithOptions(host string, port int, path string, options WsServerOptions, newClient func(*websocket.Conn) *WsClient) *WsServer {
	// existing initialization plus accept options
}
```

```go
conn, err := websocket.Accept(w, r, s.acceptOptions)
```

수정 파일 및 체크리스트:

- [ ] `../proto-socket/go/ws_server.go`: `WsServerOptions`와 `NewWsServerWithOptions`를 추가하고 `AcceptOptions`를 `websocket.Accept`에 전달한다. 기존 constructors는 기존 signature와 기본 동작을 유지한다.
- [ ] `../proto-socket/go/test/ws_test.go`: allowed OriginPatterns가 있는 server는 `Origin: http://localhost:3000` handshake를 허용하고, 기본 server는 cross-origin을 계속 거부하는 regression test를 추가한다.
- [ ] `apps/control-plane/internal/wire/portal.go`: `NewWsServerWithOptions`를 사용하고 `websocket.AcceptOptions{OriginPatterns: []string{"localhost", "localhost:*", "127.0.0.1", "127.0.0.1:*"}}`를 적용한다.
- [ ] `apps/control-plane/internal/wire/portal_test.go`: `websocket.Dial` + `DialOptions.HTTPHeader.Set("Origin", "http://localhost:3000")`로 연결한 뒤 `proto_socket.NewWsClient`를 감싸 `PortalHelloRequest`/`PortalHelloResponse`를 검증하는 test를 추가한다.
- [ ] `apps/control-plane/internal/wire/wire.go`: 현재 구현과 충돌하는 stale `Endpoint` TODO 주석을 최소 정리한다.

테스트 작성: 작성한다. `../proto-socket/go/test/ws_test.go`는 server option 동작을 검증하고, `apps/control-plane/internal/wire/portal_test.go`는 실제 Portal endpoint가 browser-origin handshake 후 hello response를 반환하는지 검증한다.

중간 검증:

```bash
cd ../proto-socket/go && go test ./...
cd /config/workspace/iop && go test -count=1 ./apps/control-plane/internal/wire
```

기대 결과: proto-socket WS tests와 control-plane wire tests가 통과한다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `../proto-socket/go/ws_server.go` | REVIEW_CP_WS-1 |
| `../proto-socket/go/test/ws_test.go` | REVIEW_CP_WS-1 |
| `apps/control-plane/internal/wire/portal.go` | REVIEW_CP_WS-1 |
| `apps/control-plane/internal/wire/portal_test.go` | REVIEW_CP_WS-1 |
| `apps/control-plane/internal/wire/wire.go` | REVIEW_CP_WS-1 |

## 최종 검증

```bash
cd ../proto-socket/go && go test ./...
cd /config/workspace/iop && go test -count=1 ./apps/control-plane/internal/wire
go test -count=1 ./apps/control-plane/...
go test ./...
git diff --check
```

기대 결과: proto-socket tests, control-plane tests, 전체 Go tests, diff whitespace 검증이 모두 통과한다. `go test ./...`는 최종 전체 회귀 확인으로 cache output을 허용한다.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
