> ## Documentation Index
> Fetch the complete documentation index at: https://api-docs.vpms.io/llms.txt
> Use this file to discover all available pages before exploring further.

# 마켓플레이스 UI Kit — App Manifest 가이드

> 앱이 VPMS 프론트엔드 안에 설정 화면을 선언적으로 렌더링하기 위한 manifest.surfaces (Level 1 UI Kit) 블록 레퍼런스와 데이터 흐름

앱은 서드파티 코드를 실행하지 않고 **선언적 블록 JSON** 으로 VPMS 안에 자기 설정 화면을
그린다. 서버가 소유한 컴포넌트 레지스트리로만 렌더링되므로 manifest 는 심사 대상이며,
알 수 없는 블록/필드는 거부된다. 이 문서는 `manifest.surfaces` (Level 1 UI Kit) 의 전체
레퍼런스다.

## 1. Manifest 최상위 구조

앱 버전을 생성할 때(`createAppVersion`) 제출하는 manifest 의 형태:

```json theme={null}
{
  "name": "V-Cloud Sync",
  "version": "1.0.0",
  "requestedScopes": ["reservations:write", "marketplace:datasets:write"],
  "callbackUrl": "https://your-app.example.com/webhooks/vpms",
  "eventSubscriptions": ["installation.activated", "mapping.updated", "action.invoked"],
  "surfaces": { "settings": { "blocks": [ /* … */ ] } }
}
```

| 필드                   |  필수 | 설명                                                          |
| -------------------- | :-: | ----------------------------------------------------------- |
| `name`               |  ✓  | 앱 이름                                                        |
| `version`            |  ✓  | 버전 문자열 (semver 권장)                                          |
| `requestedScopes`    |  ✓  | 요청 권한 배열. 카탈로그 밖 scope 는 거부 ([§5](#5-requestedscopes-카탈로그)) |
| `callbackUrl`        |     | webhook 수신 엔드포인트 (설치 시 구독 자동 생성)                            |
| `eventSubscriptions` |     | 구독할 이벤트 타입. 비우면 전체 구독                                       |
| `configSchema`       |     | 설치 단위 설정 스키마 (`configForm` 블록이 렌더링)                         |
| `surfaces`           |     | UI Kit 표면 선언 (아래)                                           |

<Note>
  UI Kit 표면은 현재 `settings` 하나만 지원한다 — 앱별 설정 페이지이며, 설치 직후 최초
  방문이 온보딩을 겸한다. 블록은 최대 **32개**.
</Note>

## 2. surfaces.settings 블록 종류

`surfaces.settings.blocks` 는 아래 6종 블록의 배열이다. 렌더러는 선언 순서대로 그린다.

### markdown

정적 안내 텍스트 (GFM). 최대 20,000자.

```json theme={null}
{ "type": "markdown", "body": "## 연동 안내\n외부 채널 예약을 자동 동기화합니다." }
```

### image

```json theme={null}
{ "type": "image", "url": "https://cdn.example.com/guide.png", "alt": "연동 흐름" }
```

`url` 은 **https 만** 허용 (VPMS 프론트가 https 라 mixed-content 차단).

### divider

```json theme={null}
{ "type": "divider" }
```

### configForm

`manifest.configSchema` 를 폼으로 렌더링해 설치 단위 설정을 입력받는다. 값은
Installation.config 에 저장되고 앱은 `GET /api/v1/installation` 의 `config` 로 읽는다.
저장 시 `config.updated` webhook 이 발행된다.

```json theme={null}
{ "type": "configForm", "title": "동기화 설정" }
```

configSchema (manifest 최상위):

```json theme={null}
{
  "configSchema": { "fields": [
    { "key": "baseRatePlanId", "label": "기준 요금제", "type": "ratePlan",
      "required": true, "description": "동기화 예약이 분류될 요금제" }
  ] }
}
```

| 필드            |  필수 | 설명                                                      |
| ------------- | :-: | ------------------------------------------------------- |
| `key`         |  ✓  | 설정 key (`^[a-zA-Z][a-zA-Z0-9_]{0,63}$`, 표면 내 유일)        |
| `label`       |  ✓  | 표시 라벨                                                   |
| `type`        |  ✓  | `text` 또는 `ratePlan` (VPMS 요금제 선택 — 값은 요금제 id)          |
| `required`    |     | `true` 면 setupStatus 판정 대상 — 값이 없으면 CONFIGURED 가 되지 않는다 |
| `description` |     | 보조 설명                                                   |

### entityMapping

VPMS 엔티티 ↔ 앱이 푸시한 외부 항목(ExternalDataset)을 잇는 매핑 편집기.
가장 핵심 블록이다 — [§3](#3-entitymapping-상세) 참조.

```json theme={null}
{
  "type": "entityMapping",
  "title": "객실 타입 매핑",
  "vpmsEntity": "roomType",
  "externalDataset": "vcloud_room_types",
  "cardinality": "ONE_TO_ONE",
  "required": true
}
```

### action

사용자가 버튼 한 번으로 앱에 작업을 위임하는 트리거 — [§4](#4-action-상세) 참조.

```json theme={null}
{
  "type": "action",
  "key": "sync_reservations",
  "title": "외부 예약 동기화",
  "description": "기존 예약을 VPMS 로 가져옵니다.",
  "buttonLabel": "동기화",
  "completion": "event"
}
```

## 3. entityMapping 상세

호텔(테넌트)이 VPMS 엔티티와 앱의 외부 엔티티를 잇는다. 왼쪽은 우리 데이터(예: 객실
타입), 오른쪽은 앱이 Open API 로 푸시한 dataset 항목이다.

| 필드                |  필수 | 설명                                                      |
| ----------------- | :-: | ------------------------------------------------------- |
| `vpmsEntity`      |  ✓  | 매핑 대상 VPMS 엔티티 ([아래](#vpmsentity-종류))                   |
| `externalDataset` |  ✓  | 앱이 푸시한 dataset key. `^[a-z0-9][a-z0-9_-]{0,63}$`        |
| `title`           |     | 블록 제목                                                   |
| `cardinality`     |     | `ONE_TO_ONE`(기본) 또는 `ONE_TO_MANY`                       |
| `required`        |     | `true` 면 setupStatus 판정 대상 ([§6](#6-setupstatus-상태-머신)) |

#### vpmsEntity 종류

`roomType` · `room` · `package` · `ratePlan` · `accommodation` · `channel`

* `accommodation` — 설치 숙소 자신 1행. 외부 시스템 **계정 페어링(account linking)** 용.
* `channel` — 판매 채널(거래처 연결 단위). 외부 플랫폼 코드 매핑용.

#### cardinality 의미

* **ONE\_TO\_ONE** — VPMS 엔티티 ↔ 외부 항목이 양방향 1:1. 한 쪽을 재배정하면 반대쪽
  기존 매핑을 대체한다(steal).
* **ONE\_TO\_MANY** — 한 VPMS 엔티티가 여러 외부 항목을 가질 수 있다. 단, **외부 항목은
  항상 하나의 엔티티에만 속한다**(외부→엔티티 해석 유일성). 예: 한 채널(거래처)에 여러
  플랫폼을 묶되, 한 플랫폼은 한 채널에만 배정.

<Warning>
  매핑 가능한 `(vpmsEntity, externalDataset)` 조합은 manifest surface 에 선언된 것만이다.
  선언 밖 조합으로 매핑을 시도하면 `setResourceMapping` 이 `BAD_REQUEST` 로 거부한다.
</Warning>

## 4. action 상세

버튼 클릭 → `action.invoked` webhook 발행 → (선택) 앱이 완료 보고.

| 필드            |  필수 | 설명                                       |
| ------------- | :-: | ---------------------------------------- |
| `key`         |  ✓  | action 식별자. dataset key 와 동일 규칙. 표면 내 유일 |
| `title`       |  ✓  | 블록 제목 (≤200자)                            |
| `description` |     | 보조 설명 (≤200자)                            |
| `buttonLabel` |     | 버튼 텍스트 (기본 "실행")                         |
| `completion`  |     | `none`(기본) 또는 `event`                    |

* `completion: "none"` — 버튼을 누르면 webhook 발행 즉시 완료로 간주한다.
* `completion: "event"` — 앱이 작업을 마친 뒤
  `POST /api/v1/action-runs/{run_id}/complete` 로 결과를 보고할 때까지 프론트가 **진행 중**
  상태를 표시한다.

`action.invoked` payload: `{ "action": "<key>", "run_id": "<id>", "completion": "none|event" }`.

<Note>
  action 버튼은 `required` entityMapping 이 모두 채워지기 전(PROVISIONING·SETUP\_REQUIRED)에는
  비활성이다 — 매핑 없이 위임된 작업은 앱 쪽에서 실패/부분 처리되기 때문.
</Note>

## 5. requestedScopes 카탈로그

`resource:action` 표기. 카탈로그 밖 값은 manifest 검증에서 거부된다.

| scope                                      | 용도               |
| ------------------------------------------ | ---------------- |
| `reservations:read` / `reservations:write` | 예약 조회 / 생성·수정    |
| `folios:read` / `folios:write`             | folio·결제 조회 / 생성 |
| `rooms:read`                               | 객실·객실타입 조회       |
| `rates:read` / `rates:write`               | 요금제 조회 / 수정      |
| `inventory:read` / `inventory:write`       | 재고 조회 / 수정       |
| `profiles:read` / `profiles:write`         | 고객 프로필 조회 / 수정   |
| `marketplace:datasets:write`               | 매핑 후보 dataset 푸시 |
| `marketplace:mappings:read`                | 확정된 매핑 조회        |
| `marketplace:events:write`                 | 이벤트 제출           |
| `marketplace:usage:read`                   | 사용량 요약 조회        |

## 6. setupStatus 상태 머신

설치 후 초기 설정 진행 상태. `InstallationStatus`(토큰 발급 기준) 와 직교하며, API 를
게이팅하지 않는다 — 앱/프론트가 상태를 보고 스스로 판단한다.

| 상태               | 의미                                         |
| ---------------- | ------------------------------------------ |
| `NOT_REQUIRED`   | `required` 매핑/설정 없음 — 설정 단계 스킵             |
| `PROVISIONING`   | 앱의 dataset 푸시(및 explicit 시 setup-ready) 대기 |
| `SETUP_REQUIRED` | 데이터 준비됨 — 사용자 매핑/필수 설정 입력 필요               |
| `CONFIGURED`     | required 매핑 + required 설정 값 모두 저장됨 — 완전 동작 |

파생 계산이라 다운그레이드도 일어난다(매핑 삭제 → SETUP\_REQUIRED 복귀). 변경 시
`installation.setup_status_changed` webhook 이 발행된다.

`surfaces.settings.readiness` 로 PROVISIONING → SETUP\_REQUIRED 전환 조건을 정한다:

* `implicit`(기본) — required 매핑이 참조하는 dataset 이 **하나라도** 준비되면 전환.
* `explicit` — 추가로 앱이 `POST /api/v1/installation/setup-ready` 를 호출해야 전환.

## 7. 데이터 흐름 요약

```
[앱]     PUT /api/v1/datasets/{key}         (매핑 후보 항목 푸시; datasets:write)
  ↓
[VPMS]   surfaces.settings 렌더 → 호텔이 entityMapping 편집
  ↓      setResourceMapping 뮤테이션 → mapping.updated webhook
[앱]     GET /api/v1/mappings                (확정 매핑 조회; mappings:read)
  ↓
[VPMS]   action 버튼 → action.invoked webhook (+ run_id)
[앱]     POST /api/v1/action-runs/{id}/complete   (completion=event)
```

관련 Open API 엔드포인트:

| 메서드    | 경로                                      | scope                        |
| ------ | --------------------------------------- | ---------------------------- |
| `PUT`  | `/api/v1/datasets/{key}`                | `marketplace:datasets:write` |
| `GET`  | `/api/v1/datasets/{key}`                | `marketplace:datasets:write` |
| `GET`  | `/api/v1/mappings`                      | `marketplace:mappings:read`  |
| `POST` | `/api/v1/installation/setup-ready`      | `marketplace:datasets:write` |
| `POST` | `/api/v1/action-runs/{run_id}/complete` | bearer (자기 설치 run)           |

## 8. 전체 예시

객실 타입(필수) + 판매 채널(선택) 매핑 + 수동 동기화 action 을 갖춘 표면:

```json theme={null}
{
  "surfaces": {
    "settings": {
      "blocks": [
        { "type": "markdown", "body": "## 외부 채널 연동" },
        { "type": "entityMapping", "title": "숙소 연결", "vpmsEntity": "accommodation",
          "externalDataset": "ext_accounts", "cardinality": "ONE_TO_ONE", "required": true },
        { "type": "entityMapping", "title": "객실 타입 매핑", "vpmsEntity": "roomType",
          "externalDataset": "ext_room_types", "cardinality": "ONE_TO_ONE", "required": true },
        { "type": "entityMapping", "title": "판매 채널 매핑", "vpmsEntity": "channel",
          "externalDataset": "ext_platforms", "cardinality": "ONE_TO_MANY" },
        { "type": "action", "key": "sync_now", "title": "지금 동기화",
          "buttonLabel": "동기화", "completion": "event" }
      ],
      "readiness": "implicit"
    }
  }
}
```
