# 발급기관 온보딩

교육부·대학·협회 같은 **기관**이 SuperWallet의 발급기관으로 등록하면, 자기 기관 명의로 **증명서(VC)** 를
발급할 수 있습니다. 학생증·수료증처럼 기관이 원래 발급하던 증서를 지갑 안의 디지털 증명서로 발급하는 용도입니다.

점수 조회(FI) API와는 별개 표면이지만, 인증 헤더·응답 봉투·에러 규칙은 완전히 같습니다.
기본 URL도 동일합니다: `https://trendy.storydot.kr/superapp/api`

## 전체 절차

| 단계 | 누가 | 무엇을 |
|---|---|---|
| ① 신청 | 기관 | `POST /public/issuer-onboarding/apply` — 인증 없이 접수. 접수번호(`id`)를 돌려받음 |
| ② 심사 | 운영자(관리자) | 신청 내용 검토 후 승인 또는 반려. 반려 시 사유 기록 |
| ③ 승인 | 운영자(관리자) | 승인과 동시에 **API 키가 1회만** 발급 + **발급자 DID 등재**(수동 단계) |
| ④ 발급 | 기관 | `POST /issuer/credentials/issue` — 기관 명의로 증명서 발급 |

**③의 발급자 DID 등재는 자동이 아닙니다.** 승인만으로는 발급이 되지 않습니다 — 운영자가 기관의 발급자 DID를
신뢰 목록에 등재하고 신청 건에 연결해야 비로소 발급 API가 열립니다. 등재 전에 발급을 시도하면 거부됩니다(`SW1701`).

## ① 신청

### POST /public/issuer-onboarding/apply

**인증이 필요 없습니다.** 누구나 호출할 수 있는 공개 접수 창구이며, 다른 공개 엔드포인트와 같은
속도 제한(60 req/min, 호출 IP 기준)이 적용됩니다.

요청:

```json
{
  "orgName": "국립○○대학교",
  "orgType": "UNIVERSITY",
  "countryCode": "KR",
  "contactEmail": "did-admin@univ.example.ac.kr",
  "requestedCredentialTypes": ["StudentCard", "CompletionCertificate"],
  "purposeNote": "재학생 학생증과 수료증을 디지털 증명서로 발급하려 합니다."
}
```

응답 200:

```json
{
  "success": true,
  "data": { "id": 41 },
  "message": "Issuer onboarding application received"
}
```

`id`는 접수번호입니다. 심사 결과는 `contactEmail`로 연락하며, 승인 시 `clientId`와 API 키를 함께 전달합니다.

| 필드 | 필수 | 규칙 |
|---|---|---|
| `orgName` | 필수 | 기관명. 최대 200자. 앞뒤 공백 제거됨 |
| `orgType` | 필수 | `GOVERNMENT` · `UNIVERSITY` · `ASSOCIATION` · `COMPANY` 중 하나 |
| `countryCode` | 선택 | 영문 2자리(예: `KR`, `LA`, `VN`). 대문자로 저장 |
| `contactEmail` | 필수 | 이메일 형식, 최대 255자. 소문자로 저장 |
| `requestedCredentialTypes` | 필수 | **1~10개**. 각 값 최대 100자, `[A-Za-z][A-Za-z0-9_.:-]*`(영문자로 시작). **중복 금지**. 배열 전체 저장 길이 제한(JSON 500자)도 있음 |
| `purposeNote` | 선택 | 발급 목적. 최대 1000자 |

**여기에 적은 증명서 종류만 나중에 발급할 수 있습니다.** 승인된 목록에 없는 `credentialType`으로 발급을
시도하면 거부됩니다(`SW2304`). 종류 추가는 운영팀 문의.

검증 실패는 `SW1001`(HTTP 400) — 형식·길이·개수 위반, 중복, 이메일 형식 오류가 모두 해당합니다.

## ② 심사 · ③ 승인

심사는 운영자(관리자)가 진행하며, 기관이 호출하는 API는 없습니다.

| status | 의미 |
|---|---|
| `APPLIED` | 접수됨 — 심사 대기. **이 상태에서만** 승인·반려 가능 |
| `APPROVED` | 승인됨 — API 키 발급 완료. 발급자 DID까지 등재되면 발급 API 사용 가능 |
| `REJECTED` | 반려됨 — 반려 사유가 반드시 기록됨 |
| `SUSPENDED` | 정지됨 — 발급 API 사용 불가 |

### API 키는 승인 시 단 한 번만 전달됩니다

**평문 API 키는 승인 시점의 응답에 단 한 번만 나타납니다.** 서버는 키를 해시로만 보관하므로 **다시 조회할 수 없습니다**.
전달받은 즉시 안전한 비밀 저장소에 보관하세요. 분실 시 재발급(기존 키 폐기) 외에 방법이 없습니다.

승인 시 기관 전용 파트너 클라이언트가 만들어지고 발급 권한(`credential:issue` 스코프)이 부여됩니다.
이후 모든 발급 호출에 아래 두 헤더를 넣습니다([인증](auth.md)과 동일).

```
X-Client-Id: <승인 시 전달받은 clientId>
X-API-Key:   <승인 시 1회 전달받은 API 키>
```

### 발급자 DID 등재 — 수동 단계

승인 다음에 **운영자의 수동 작업**이 한 번 더 필요합니다. 기관의 **발급자 DID**와 공개키(JWK)를 발급자
신뢰 목록에 등재하고, 그 DID를 신청 건에 연결하는 작업입니다. 이 단계는 현재 **프로그램으로 자동화되어
있지 않습니다** — 승인 응답에도 이 수동 절차를 안내하는 `registryMessage` 문구가 함께 담깁니다.

| 기관 유형 | 등재 시 신뢰 등급 |
|---|---|
| `GOVERNMENT` | VERIFIED(검증됨) |
| 그 외(`UNIVERSITY` · `ASSOCIATION` · `COMPANY`) | PROVISIONAL(잠정) |

등재 완료 여부는 기관이 직접 확인할 수 있습니다 — `GET /issuer/whoami`의 `issuerDid`에 값이 채워져 있으면
등재·연결 완료입니다. `null`이면 아직 발급할 수 없습니다.

## 기본 차단(fail-closed)

발급 API는 **조건이 하나라도 갖춰지지 않으면 발급하지 않습니다.** 부분 발급이나 임시 발급은 없습니다.

| 차단 조건 | errorCode | HTTP |
|---|---|---|
| 인증 헤더 누락 또는 키 불일치 | `SW2102` | 401 |
| 클라이언트가 정지 상태 | `SW2101` | 403 |
| `credential:issue` 권한 없음 | `SW2304` | 403 |
| 클라이언트에 연결된 신청 건 없음 / 신청이 `APPROVED`가 아님 | `SW2304` | 403 |
| 승인 목록에 없는 `credentialType` | `SW2304` | 403 |
| **발급자 DID 미등재** 또는 **신뢰 목록에 없음** | `SW1701` | 500 |
| 보유자 DID(`holderDid`) 없음 | `SW1402` | 404 |
| 보유자 DID가 활성 상태가 아님(폐기 등) | `SW1403` | 403 |
| 보유자 DID에 연결된 사용자 기록 없음 | `SW1002` | 404 |
| 입력 검증 실패 | `SW1001` | 400 |
| 속도 제한(60 req/min) 초과 | `SW1004` | 429 |

`SW1701`은 **기관의 잘못이 아니라 등재가 아직 안 끝난 상태**를 뜻합니다. 같은 요청을 반복해도 결과는
같습니다 — 재시도하지 말고 `GET /issuer/whoami`로 `issuerDid`를 확인한 뒤 운영팀에 등재 완료를 요청하세요.

## ④ 기관 명의 발급 API

아래 3개 엔드포인트 모두 `X-Client-Id` + `X-API-Key` 헤더 인증이며, 응답은 `{ "success", "data", "message" }`
표준 봉투로 감쌉니다.

**시각 표기**: 이 표면의 `issuedAt`·`expiresAt`은 ISO-8601 형식(`2026-08-26T10:00:00`)입니다.
점수(FI) 표면의 `yyyy-MM-dd HH:mm:ss`와 표기가 다르니 파서를 분리하세요.

### POST /issuer/credentials/issue

요청:

```json
{
  "holderDid": "did:sw:0x8f2a41c9d27b6e5a1c0f3d94ab77e5c621d0a4b3",
  "credentialType": "StudentCard",
  "claims": {
    "studentNo": "2026-000412",
    "faculty": "Computer Engineering",
    "enrollmentStatus": "ENROLLED"
  },
  "validityDays": 365
}
```

| 필드 | 필수 | 규칙 |
|---|---|---|
| `holderDid` | 필수 | 증명서를 받을 사용자의 DID 주소(`did:sw:0x…`). 최대 255자. **활성** 상태여야 함 |
| `credentialType` | 필수 | 최대 100자, `[A-Za-z][A-Za-z0-9_.:-]*`. **승인된 목록 안의 값**이어야 함 |
| `claims` | 필수 | 문자열 → 문자열 맵. **최대 20개**, 키 최대 100자, 값 최대 500자. 값에 `null` 불가 |
| `validityDays` | 선택 | 1~3650. 생략 시 서버 기본값(365일) |

**예약된 claim 이름**: `credentialType`과 `delegatedIssuer`는 서버가 발급 시점에 직접 채웁니다.
같은 이름으로 값을 보내면 **덮어써집니다**. `delegatedIssuer`에는 기관의 발급자 DID가 들어가며,
이 값이 "누가 발급했는지"를 나타냅니다.

응답 200:

```json
{
  "success": true,
  "data": { "credentialId": 10427 },
  "message": "Credential issued"
}
```

**주의**: 이 API에는 멱등키가 없습니다. 같은 요청을 두 번 보내면 **증명서가 두 건 발급됩니다**.
네트워크 오류로 응답을 받지 못했다면, 재시도 전에 `GET /issuer/credentials`로 이미 발급되었는지 확인하세요.

### GET /issuer/credentials?page=0

기관 명의로 발급된 증명서만 조회합니다(다른 기관 발급분은 보이지 않음). 페이지 크기는 **20건 고정**이며,
`page`는 0부터 시작합니다(음수는 `SW1001`).

```json
{
  "success": true,
  "data": {
    "page": 0,
    "size": 20,
    "totalElements": 137,
    "totalPages": 7,
    "content": [
      {
        "credentialId": 10427,
        "credentialUid": "vc_9f2ab1c4e5d6",
        "holderDid": "did:sw:0x8f2a41c9d27b6e5a1c0f3d94ab77e5c621d0a4b3",
        "credentialType": "StudentCard",
        "status": "ACTIVE",
        "issuedAt": "2026-08-26T10:00:00",
        "expiresAt": "2027-08-26T10:00:00"
      }
    ]
  },
  "message": "Issuer credentials retrieved"
}
```

| 필드 | 설명 |
|---|---|
| `credentialUid` | 증명서의 공개 식별자 |
| `status` | `ACTIVE` · `PENDING_ANCHOR` · `ANCHOR_DEFERRED` · `SUSPENDED` · `REVOKED` · `EXPIRED` 등 |
| `totalPages` | 발급분이 없으면 `0`. 범위를 넘는 `page`는 빈 `content` 반환 |

### GET /issuer/whoami

발급자 DID 등재가 끝났는지 확인하는 용도로도 씁니다. **등재 전에도 호출할 수 있습니다**(`issuerDid`가 `null`).

```json
{
  "success": true,
  "data": {
    "applicationId": 41,
    "clientId": "pc_7d1e4a9b",
    "orgName": "국립○○대학교",
    "orgType": "UNIVERSITY",
    "countryCode": "KR",
    "contactEmail": "did-admin@univ.example.ac.kr",
    "issuerDid": "did:sw:0x3c7b21e8a4d05f9b6c1e7a2d8f4b03c95e6a1d72",
    "status": "APPROVED",
    "credentialTypes": ["StudentCard", "CompletionCertificate"]
  },
  "message": "Issuer information retrieved"
}
```

`credentialTypes`가 발급 가능한 종류의 전부입니다 — 여기에 없는 값으로는 발급되지 않습니다.

## 사용량 집계(ISSUE)

발급 호출은 **`ISSUE` 버킷**으로 집계됩니다.

- **성공한 발급만** 집계됩니다. 거부·검증 실패 호출은 집계되지 않습니다.
- 가중치는 **1건당 1 유닛**입니다(조회 `QUERY`와 동일).
- 집계는 **발급 시점에 즉시** 기록되며, 야간 재집계에도 `ISSUE` 값은 보존됩니다.
- 집계 실패가 발급을 되돌리지는 않습니다 — 발급은 이미 성공한 것으로 확정됩니다.

기관은 같은 인증 헤더로 `GET /fi/usage?fromDate=&toDate=`를 호출해 자기 사용량을 확인할 수 있습니다(최대 92일 구간).

```json
{
  "success": true,
  "data": {
    "clientId": "pc_7d1e4a9b",
    "days": [
      { "date": "2026-08-26", "endpoint": "ISSUE", "callCount": 18, "unitCount": 18 }
    ]
  },
  "message": "Usage retrieved"
}
```

집계는 **사용량 확인용**이며 청구서가 아닙니다. 요금·정산 조건은 계약에 따릅니다.

## 자주 겪는 상황

| 증상 | 원인과 대응 |
|---|---|
| 승인받았는데 발급이 `SW1701`로 거부됨 | 발급자 DID 등재(수동 단계)가 아직 안 끝났습니다. `GET /issuer/whoami`의 `issuerDid`가 `null`인지 확인하고 운영팀에 문의. 재시도해도 결과는 같음 |
| 발급이 `SW2304`로 거부됨 | 승인 목록에 없는 `credentialType`이거나 클라이언트에 발급 권한이 없음. `whoami`의 `credentialTypes`와 대조 |
| API 키를 분실함 | 서버는 키를 해시로만 보관하므로 다시 알려줄 수 없음. 운영팀에 재발급(기존 키 폐기) 요청 |
| 같은 증명서가 두 건 발급됨 | 멱등키가 없어 재시도가 곧 중복 발급. 재시도 전 `GET /issuer/credentials`로 확인 |
| 보유자 DID로 발급이 `SW1402`/`SW1403`로 거부됨 | 해당 DID가 없거나 활성 상태가 아님. 사용자에게 앱에서 DID 상태를 확인하도록 안내 |
