# FONUMA 웹 연동 확장 — Application 0.2 dev.4

2026-10-06. dev.3의 공유 데이터·고급 UI·타입 SDK 위에 파일, 변경 감지, 외부 JSON 조회, 서명 웹훅을 추가한 개발 프리뷰다. **결제사 연동·실제 청구·환불·WebSocket·대용량 파일 서비스의 완성판은 아니다.** 코어 문법·순수 함수·storage 효과·실행 한도는 변경하지 않았다. 외부 I/O는 공통 호스트에서 처리하고 업무 판단은 포누마 함수에서 처리한다.

## 처음 실행하기

```sh
fonuma-app new integration-demo --template integrations
fonuma-app check integration-demo
fonuma-app test integration-demo
fonuma-app serve integration-demo --data-dir integration-demo/.fonuma/data --port 3441
```

표시된 loopback 주소에서 가입·로그인하면 개인 파일을 올리고 내려받을 수 있다. 일반 회원은 자신의 청구 내역만 본다. 관리자는 기존 user-add 절차로 생성한다. 관리자 청구 등록은 사용자 ID와 KRW 정수 금액을 저장하며 돈을 청구하지 않는다. 결제 결과 시험은 아래 웹훅 프로토콜을 사용한다. 운영 결제사와 연결하려면 해당 결제사의 서명·이벤트·조회 검증을 담당하는 서버 어댑터가 별도로 필요하다.

## 명시적으로 켜는 확장

기존 프로젝트는 extensions를 생략하면 모두 꺼진다. `fonuma.app.json`에 다음 객체를 추가한다.

```json
{
  "extensions": {
    "files": true,
    "changes": true,
    "connectors": [],
    "webhooks": []
  }
}
```

위 객체는 기존 manifest에 병합하는 부분 예시다. 완전한 프로젝트는 integrations 템플릿을 사용한다. 앱 권한은 기존 로그인·세션·Origin·CSRF 검사 뒤 적용한다. 확장을 처음 켜면 SQLite에 버전 표시와 두 테이블을 트랜잭션으로 추가한다. **먼저 backup 하라. dev.3은 확장 테이블이 있는 DB를 열 수 없다.** 도구 rollback만으로 DB를 되돌릴 수 없으며, 서버를 중지하고 이전 버전 백업을 복구해야 한다. 확장을 끄더라도 파일은 자동 삭제하지 않는다. 기존 backup/restore에 파일과 변경 번호가 포함되며 restore는 기존 세션을 폐기한다.

## 개인 파일

`screen()`의 List<List<String>>에 `["files", "attachments", "내 파일"]`을 넣으면 선택·업로드·목록·다운로드·삭제 화면을 만든다. 여러 view를 쓰는 화면에서는 view 내부에 넣는다. files:true가 없거나 ID가 중복되면 check가 거부한다.

```javascript
const uploaded = await client.files.upload({
  name: 'memo.txt', mime: 'text/plain', bytes: new TextEncoder().encode('메모')
}, { id: crypto.randomUUID() });
const files = await client.files.list();
const downloaded = await client.files.download(uploaded.id); // bytes: Uint8Array
await client.files.remove(uploaded.id);
```

- 파일당 256 KiB, 사용자당 128개/8 MiB, 앱 전체 파일 32 MiB. SQLite 호스트의 전체 DB 한도도 별도로 적용한다.
- 공유 앱에서도 **파일은 업로더만 조회·삭제**한다. 관리자 자동 열람·게시글 첨부 공유·공개 CDN 주소는 없다.
- 파일명은 최대160 UTF-8바이트이며 경로 구분자·제어문자를 거부한다. 파일명을 디스크 경로로 사용하지 않고 SQLite BLOB으로 저장한다.
- MIME 표시는 text/plain, application/pdf, image/png, image/jpeg, application/octet-stream만 받는다. 내용의 진위·악성코드 검사를 뜻하지 않는다. 기본 UI는 octet-stream 다운로드만 제공하며 HTML/파일 미리보기를 실행하지 않는다.
- 업로드 ID는 1–64자의 ASCII 영숫자/밑줄/하이픈이다. 같은 사용자·ID·이름·MIME·내용으로 재전송하면 같은 파일을 반환하고 내용이 다르면409다. 삭제 후 같은 ID의 재사용은 가능하므로 새 업로드에 새 ID를 사용한다.
- API는 POST `/ext/files/list|put|get|delete`이고 JSON을 사용한다. put의 base64는 표준 패딩 형식이다. 전체 put 요청은352 KiB, 다른 요청은64 KiB 이내다. 파일 데이터·변경은 동일 DB 트랜잭션으로 처리한다.
- SDK 업로드는 네트워크 실패 시 자동 재시도하지 않는다. 결과가 불명확하면 **같은 ID와 내용**으로 재시도한다. 파일 ID와 업무 레코드 연결은 앱 정책으로 구현해야 하며 현재 템플릿은 독립 개인 파일함이다.

## 변경 감지와 화면 갱신

`changes:true`이면 저장 경로의 커밋과 파일 변경에 사용자/공유 공간의 변경 번호를 함께 기록한다. POST `/ext/changes`는 현재 계정과 프로젝트에 대응하는 opaque cursor만 반환한다. 레코드 내용·변경자·파일명은 포함하지 않는다. SDK는 기본1초 간격으로 변경을 확인하고 이후 cursor가 바뀌면 콜백을 호출한다.

```javascript
const stop = client.watchChanges(async () => {
  // 현재 권한으로 필요한 API를 다시 조회한다.
  console.log(await client.call('invoices', []));
}, { intervalMs: 1000, onError: error => console.error(error.message) });
// 화면/컴포넌트 해제 시
stop();
```

- 500–60,000ms 간격, 요청이 끝난 뒤 다음 요청을 예약한다. 최초 조회는 기준점 설정이며 콜백을 호출하지 않는다.
- 같은 client의 로그인/로그아웃 전환, 수동 stop 뒤 도착한 이전 응답은 콜백을 호출하지 않는다.401이면 구독이 멈춘다. 다른 오류는 onError 뒤 다음 간격에 재시도한다.
- `["changes", "updates"]` UI 행은 변경 감지 시 `새 변경사항 불러오기` 버튼을 표시한다. 사용자가 클릭하면 현재 화면을 다시 조회하며 폼 초안을 유지한다. 입력 중 화면을 자동 교체하지 않는다.
- **HTTP 주기 조회 기반의 변경 감지**다. SSE/WebSocket 푸시, 이벤트 본문/순서/유실 없는 전달, 과거 이벤트 재생은 없다. 공유 cursor는 앱 공간 변경 여부만 알리고 각 읽기 경로가 행 권한을 다시 검사한다. 백업 복구·재배포 후에는 전체 화면을 새로 조회한다.

## 외부 JSON API 조회

서버 관리자가 다음 connector를 manifest에 등록하고 화면 행 `["connector", "rates-panel", "조회", "rates"]` 또는 `client.connect('rates')`를 사용한다. URL은 예시이며 해당 서비스를 운영자가 실제 구성해야 한다.

```json
{
  "id": "rates",
  "url": "https://api.example.com/rates",
  "access": "admin",
  "bearer_env": "FONUMA_RATES_TOKEN"
}
```

- 최대16개, user/admin 접근 지정, 고정 HTTPS GET만 지원한다. 클라이언트는 URL·메서드·헤더·본문을 바꿀 수 없다. POST·파일 전달·결제 승인 요청·동적 경로/질의·OAuth 토큰 갱신은 아직 없다.
- 비밀값 대신 FONUMA_로 시작하는 환경 변수 이름만 manifest에 쓴다. 값은32–4096 visible ASCII바이트다. 응답 schema에는 URL과 환경 변수명도 노출하지 않는다. env를 소스·명령 인자·문서에 기록하지 않는다.
- TLS 인증서 검증을 켜고 프록시 환경 변수·리다이렉트를 사용하지 않는다. DNS가 실제 연결에 반환한 주소에서 사설/loopback/link-local 등 비공개 범위를 거부한다. IPv6은 제한된 공인 범위만 받는 보수적 정책이다.
- 전체3초, JSON 응답64 KiB, 상태2xx만 성공한다. 에러는 서버 내부 URL·토큰을 포함하지 않는 UPSTREAM 문구로 반환한다. 외부 응답은 비신뢰 데이터이며 업무 규칙·타입 변환은 앱에서 다시 검사한다.
- 테스트 전용 `loopback_test:true`는 정확한 http://127.0.0.1 주소와 무인증만 허용한다. `--public-origin`과 함께 실행하면 거부한다. 실제 테스트는 로컬 모의 서버만 호출하며 추가 결제·외부 API 사용료가 없다.

사용 라이브러리의 TLS/timeout/resolver 동작은 [ureq 공식 문서](https://docs.rs/ureq/3.4.2/ureq/)를 기준으로 구현하고 Cargo.lock으로 고정한다.

## 서명 웹훅과 결제 결과 반영

integrations 템플릿은 다음 설정을 포함한다.

```json
{ "id": "payments", "route": "settle", "secret_env": "FONUMA_PAYMENT_WEBHOOK_SECRET" }
```

서버 어댑터는 공급자 이벤트를 검증한 뒤 POST `/hooks/payments`로 전달한다. Content-Type은 application/json이다. Origin·브라우저 CSRF 대신 아래 HMAC을 검사한다. 외부 운영은 HTTPS reverse proxy와 public-origin이 필요하다. 웹훅별 secret은 별도32바이트 이상 난수 값을 환경으로 제공한다.

```json
{ "id": "unique-event-id", "args": ["1", "5000", "KRW", "provider-receipt-id"] }
```

- `X-Fonuma-Timestamp`: 현재 Unix 초의 정규 십진 표기, 서버 시간과 차이300초 이내.
- `X-Fonuma-Signature`: HMAC-SHA256(secret, `timestamp + "." + 실제 원본 JSON 바이트`)의 소문자64자리 hex. JSON을 서명한 뒤 다시 직렬화하면 바이트가 달라질 수 있다.
- 알고리즘은 [RustCrypto HMAC 문서](https://docs.rs/hmac/0.12.1/hmac/)의 검증 API를 사용한다. 비교 시 전달받은 서명을 상수 시간 검증 API에 넘긴다.
- webhook은 명시된 shared/admin/actor/write 경로에만 연결할 수 있다. 일반 로그인으로 만들 수 없는 잠긴 시스템 Actor `fonuma:hook:payments`가 주입된다. 실행 함수는 신뢰할 수 있는 이벤트 종류·금액·통화·대상·전이 규칙을 반드시 검사해야 한다.
- 호스트 영수증은 기존과 같이1시간·Actor당1024개 한도이며 같은 이벤트 ID/인자의 재전송은 중복 실행하지 않는다. ID를 다른 인자로 재사용하면409다. 영구 중복 방지는 업무 저장에 별도로 둬야 한다.
- 예제 settle은 저장된 청구액·통화 일치, 전용 webhook Actor, 기존 paid 상태와 영수증의 일치, 다른 청구에 이미 쓴 영수증을 검사한다. 따라서 호스트 영수증 보존 기간 밖에서도 같은 결제를 두 번 반영하지 않는다. 최대500개 청구의 제한된 예제다.
- Result.err는422를 반환한다. 거부된 이벤트 ID도 실행 영수증이 남으므로 수정한 이벤트를 같은 ID로 보내지 않는다. 런타임 실패는 DB 트랜잭션을 커밋하지 않는다.
- 이 프로토콜은 FONUMA 어댑터 프로토콜이다. Stripe/Toss/기타 결제사의 원본 웹훅과 직접 호환된다고 주장하지 않는다. 공급자별 이벤트 확인, checkout/승인/환불/취소/실패 전이·대사·서비스 운영 계약은 별도 구현·검증 대상이다. **실제 결제는 실행하지 않았다.**

## 검증과 사용 한계

정확한 플랫폼별 결과는 APPLICATION_EXTENSIONS_VALIDATION_2026-10-06.md와 해당 evidence JSON을 따른다. DOM 모의 시험은 실제 브라우저 렌더링·파일 선택창·OS 다운로드·접근성 검증을 대신하지 않는다. 전체 기존 Required87/Hub 평가, 실제 브라우저·사용자 독립 재현, 새 macOS, Windows 새 프로필, 정식 베타 판정은 별도다.
