# 앱 백엔드 붙이기

정적 사이트가 아니라 **네이티브 앱**(iOS·Android·Flutter·React Native)을 새로온 백엔드에 붙이는 흐름입니다.
회원·게시판·피드를 앱에서 바로 씁니다.

다른 문서들이 다루는 `data-saeroon-*` 속성은 브라우저 SDK가 DOM에 붙는 방식이라 앱에서는 쓸 수 없습니다.
앱은 아래의 REST를 직접 호출합니다.

## 키가 두 종류입니다

앱을 붙일 때 가장 많이 헷갈리는 지점입니다. **용도가 다르고, 서로 대체되지 않습니다.**

| 키 | 쓰는 곳 | 누가 들고 있나 |
|---|---|---|
| `sk_live_...` | 백엔드를 **운영**할 때 (CLI·MCP·관리 REST) | 개발자·CI. **앱에 넣지 마세요** |
| 세션 토큰 (7일) | 앱이 **런타임**에 API를 부를 때 | 앱. 소셜 로그인 응답의 body로 받습니다 |

`sk_live_`를 앱에 넣으면 그 키로 사이트 전체를 조작할 수 있게 됩니다. 앱에는 세션 토큰만 둡니다.

## 1. 키 발급

[developers.saeroon.com/keys](https://developers.saeroon.com/keys) 에서 `새 API Key 생성`.
생성 직후 모달에서만 전체 값을 볼 수 있습니다.

키는 **사용자당** 발급되며, 그 사용자가 소유한 사이트에 닿습니다. 사이트 소유자와 키 소유자가 다르면
`404`가 납니다 — `401`이 아니라 `404`라서 사이트 ID를 틀린 것처럼 보입니다. 이 점을 먼저 의심하세요.

```bash
export SAEROON_API_KEY=sk_live_...
```

## 2. 게시판 시드 — CLI

앱이 붙을 게시판을 만들고 `boardId`를 받습니다. 앱은 slug가 아니라 **boardId**로 배선합니다.

```bash
npx @saeroon/cli board create \
  --site <siteId> --name "피드" --slug feed \
  --type general --write-mode open --json
```

```json
{ "ok": true, "created": true, "board": { "id": "<boardId>", "slug": "feed", "writeMode": "open" } }
```

멱등합니다. 같은 slug로 다시 실행하면 `"created": false`와 함께 기존 board를 돌려줍니다 — 중복 생성이 없으니
스크립트에서 안심하고 반복할 수 있습니다.

`--allow-anonymous`를 **넣지 않으면** 익명 게시가 막히고 로그인한 회원만 글을 씁니다(기본값).
읽기는 로그인 없이 열려 있습니다.

## 3. 게시판 시드 — MCP

에이전트(Claude 등)로 붙일 때는 MCP가 편합니다.

```bash
claude mcp add --transport http saeroon https://hosting.saeroon.com/api/mcp/streamable
```

브라우저가 열리면 **사이트를 소유한 계정**으로 로그인합니다. 다른 계정으로 로그인하면 연결은 되지만
board 도구가 그 사이트에서 실패합니다.

| 도구 | 하는 일 |
|---|---|
| `saeroon.platform.board.list` | 사이트의 게시판 목록 — `site_id` |
| `saeroon.platform.board.create` | 게시판 생성(멱등) — `site_id`, `name`, `slug`, `type`, `write_mode` |

MCP는 `sk_live_`가 아니라 **OAuth 토큰**으로 인증합니다. MCP 설정에 `sk_live_`를 넣으면 401이 납니다.

## 4. 소셜 로그인 — 세션 토큰 받기

앱이 provider에서 받은 `idToken`을 넘기면 7일짜리 세션 토큰을 **body로** 돌려줍니다. 쿠키가 아닙니다.

```
POST https://api.saeroon.com/api/v1/hosting/public/sites/{siteId}/auth/social/{provider}/native
```

`provider` = `apple` | `google`

```json
{ "idToken": "<provider id_token>", "authorizationCode": null, "nonce": null }
```

응답:

```json
{
  "sessionToken": "...",
  "expiresAt": "2026-07-24T00:00:00Z",
  "member": {
    "id": "<uuid>",
    "nickname": "",
    "tier": "free",
    "entitlementActive": false,
    "entitlementSource": null
  }
}
```

`member` 는 `/me` 와 `/me/links` 도 같은 shape 로 반환합니다.

- **`entitlementActive`** — 등급이 있는지(대략 `tier ≠ free`).
- **`entitlementSource`** — 등급 **부여 출처**: `"iap"`(결제) · `"coupon"`(컴프 쿠폰) · `null`(무료/운영자 수동).
  결제 후원자와 쿠폰 회원은 둘 다 `entitlementActive: true` 라 tier·active 만으로는 구분되지 않습니다.
  이 필드로 구분하세요. **`active` 인데 `entitlementSource` 가 없으면(옛 응답) 결제로 간주**하는 게 안전합니다 —
  그래서 쿠폰 부여는 항상 `"coupon"` 을 싣습니다(새 기기·재설치 재로그인에서 오표기 방지).

이후 모든 회원 호출에 그대로 붙입니다.

```
Authorization: Bearer <sessionToken>
```

`member.id`는 **회원의 내부 UUID**입니다. provider의 subject id가 아닙니다. 글의 작성자와 상관되는 키가
이 UUID라, 차단·본인글 판별은 전부 이 값으로 합니다.

이 엔드포인트는 사이트별로 소셜 로그인이 등록돼 있어야 동작합니다. 등록 전에는 `404`로 닫혀 있습니다
(크래시가 아니라 fail-closed).

> **소셜 자격증명 등록은 아직 셀프서브가 아닙니다.** 보드 시드(§2·§3)는 CLI·MCP로 직접 하지만,
> Apple·Google 자격증명 등록은 현재 **플랫폼 운영자가 대신 넣어 드립니다** — 앱 번들 ID와 provider
> client ID를 [문의](https://developers.saeroon.com/keys)로 전달하면 등록해 드립니다. (자격증명 등록
> 전까지 `/native`는 `404`이므로, 앱은 이 응답을 "로그인 미구성" 상태로 다루면 됩니다.)

## 5. 피드 읽기

```
GET .../sites/{siteId}/community/boards/{boardId}/posts?sort=new&page=1&pageSize=20
```

경로에 **`/community/`** 세그먼트가 들어갑니다. 빠뜨리면 404입니다.

| 파라미터 | 값 |
|---|---|
| `sort` | `new` (최신순) · `many` (많이 담긴 순) · `today` (오늘, KST 기준) |
| `category` | 정확히 일치하는 문자열만 필터 |
| `page` / `pageSize` | 1부터. `size`도 별칭으로 받습니다 |

로그인 없이 읽힙니다. 응답은 `{ items, total, page, pageSize }`입니다.

> `sort`에 없는 값을 주면 400이 아니라 **기본 정렬로 조용히 폴백**합니다. 오타가 에러로 드러나지 않으니
> 클라이언트에서 값을 고정하세요.

## 6. 피드 쓰기

```
POST .../sites/{siteId}/community/boards/{boardId}/posts
Authorization: Bearer <sessionToken>
```

```json
{ "title": "...", "content": "...", "category": "인사" }
```

`title`과 `content`는 **필수**입니다. 빠뜨리면 `400`이 납니다.

```json
{ "errors": { "Title": ["The Title field is required."] } }
```

### 같은 내용은 한 장으로 — 중복 방지

`payload` 필드를 함께 보내면 그 값의 해시로 **게시판 안에서 중복을 걸러냅니다**. 이미 같은 내용이 있으면
새로 만들지 않고 **기존 글을 그대로 돌려줍니다**.

새로 만들었는지 기존 것을 받았는지는 **HTTP 상태 코드로만** 구분합니다. 응답 body에는 표시가 없습니다.

| 상태 | 뜻 |
|---|---|
| `201` | 새로 만들어짐 |
| `200` | 이미 있던 글을 돌려줌 |

중복 방지가 필요 없는 게시판(예: 짧은 응원처럼 같은 말이 여러 번 올라와도 되는 곳)에서는 `payload`를
**보내지 마세요**. 보내면 같은 문구가 한 장으로 합쳐집니다.

## 7. 담기(좋아요) 토글

```
POST .../sites/{siteId}/community/posts/{postId}/like
```

로그인 없이 됩니다. 클라이언트가 만든 익명 ID를 키로 중복 없이 셉니다.
응답은 `{ "isLiked": true, "likeCount": 12 }`입니다.

## 8. 신고

부적절한 글·댓글을 신고합니다. 로그인 없이도 됩니다(`reporterEmail`은 선택).

```
POST .../sites/{siteId}/community/reports
```

```json
{ "targetType": "Post", "targetId": "<글 또는 댓글 UUID>", "reason": "Inappropriate", "description": null, "reporterEmail": null }
```

| 필드 | 값 |
|---|---|
| `targetType` | `Post` · `Comment` |
| `reason` | `Spam` · `Harassment` · `Inappropriate` · `Copyright` · `Other` |
| `description` | 선택. 자유 서술 |

신고 누계가 임계(기본 3)에 도달하면 대상이 **자동으로 숨겨집니다**(App Store UGC 요건). 임계값은 사이트별 설정입니다.

## 9. 차단

한 회원이 다른 회원을 차단하면, 차단한 사람의 피드·댓글에서 차단당한 사람의 글이 사라집니다(단방향, 뷰어 기준).
**로그인 필수**입니다.

```
POST   .../sites/{siteId}/community/blocks           Authorization: Bearer <sessionToken>
DELETE .../sites/{siteId}/community/blocks/{blockedMemberId}
GET    .../sites/{siteId}/community/blocks
```

```json
{ "blockedMemberId": "<차단 대상 회원 UUID>" }
```

`blockedMemberId`는 §4에서 설명한 **회원 내부 UUID**(`authorId`로 오는 값)입니다. 자기 자신은 차단할 수 없습니다.
`POST`는 멱등입니다(이미 차단이면 그대로). 차단 해제는 `DELETE`.

## 10. 계정 삭제

회원 본인이 자기 계정을 삭제합니다. **로그인 필수**. Apple·Google 스토어의 계정삭제 요건입니다.

```
DELETE .../sites/{siteId}/me    Authorization: Bearer <sessionToken>
```

호출 즉시 **계정이 하드 삭제**되고, 그 회원이 쓴 글·댓글의 작성자명은 `"[탈퇴한 회원]"`으로 **익명화**됩니다
(글 자체는 남고, 작성자 연결만 끊깁니다). 세션은 무효화되어 기존 토큰은 이후 `401`이 납니다.
프로필·비밀번호·소셜 연결·차단 기록은 함께 삭제됩니다. **되돌릴 수 없습니다.**

응답: `{ "message": "탈퇴 처리되었습니다." }`

## 응답 필드 이름

읽기 응답의 이름이 직관과 다를 수 있어 미리 적어 둡니다.

| 받는 값 | 의미 |
|---|---|
| `title` | 목록 응답에는 `content`가 없습니다. 본문은 `title`로 옵니다 |
| `likeCount` | 담긴 수 |
| `authorName` | 작성자 표시 이름 |
| `authorId` | 작성자 회원 UUID (차단·본인글 판별 키) |
| `metadata` | 글마다 넣어 둔 JSON. 클라이언트가 넣은 값은 그대로 보관·반환됩니다 |

## 11. 쿠폰 redeem — 회원 등급을 코드로 영구 부여

로그인한 회원이 쿠폰 코드를 입력하면 회원 등급을 **영구** 부여받습니다(만료 없음). 세션 토큰 Bearer 필수.

```
POST https://api.saeroon.com/api/v1/hosting/public/sites/{siteId}/coupons/redeem
Authorization: Bearer <sessionToken>
Content-Type: application/json

{ "code": "MC-XXXX-XXXX" }
```

성공(200):

```json
{ "tier": "gold", "active": true, "grantedBy": "coupon" }
```

- 성공하면 회원의 `tier` 가 오르고, 이후 `/native`·`/me` 의 `entitlementSource` 가 `"coupon"` 이 됩니다.
- **단일사용·회수는 서버가 강제**합니다(온라인 관리형 — 오프라인 해시 쿠폰의 재사용 한계 해소).
- 잘못된/만료된/이미 쓰인 코드 = **비-auth 4xx**(사용 불가 쿠폰으로 처리). `401`/`403`·`5xx` = 진짜 실패(재시도/문의).
- 이미 결제(`iap`) 후원자면 `409` — 컴프 쿠폰이 결제 정체성을 덮지 않습니다.

> 쿠폰 발급/회수/사용내역은 개발자센터 도구(CLI `coupon *` · MCP `saeroon.platform.coupon.*`)로 하는 **운영자**
> 작업입니다 — 앱은 redeem 만 호출합니다. `members` capability 를 붙인 사이트면 어디나 쓸 수 있는 범용 기능입니다.

## 지금 되는 것 / 아직인 것

정직하게 적습니다. 아래 표에 없는 기능은 문서에 없는 게 아니라 **아직 없습니다**.

| 기능 | 상태 |
|---|---|
| 게시판 시드 (CLI·MCP) | 됩니다 (셀프서브) |
| 소셜 로그인 → 세션 토큰 | 사이트별 소셜 등록 후 됩니다 (등록은 현재 운영자 대행 — §4) |
| 피드 읽기 · 쓰기 · 중복 방지 | 됩니다 |
| 담기 토글 | 됩니다 |
| 신고 · 차단 | 됩니다 (§8·§9) |
| 계정 삭제 (`DELETE /me`) | 됩니다 (§10) |
| 회원 등급 쿠폰 redeem (`POST …/coupons/redeem`) | 됩니다 (§11) |
| 회원 shape 의 `entitlementSource` (iap/coupon) | 됩니다 (§2 응답) |
| 인앱결제 영수증 검증 | 준비 중 (redeem 과 같은 등급 부여 경로) |

## 다음

- [CLI 커맨드 전체](/docs/cli)
- [MCP 연결](/docs/mcp)
- [API 키 체계](/docs/api)
