[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6CBPC4M8MG6P55VCPQXCECJ":3,"skill-same:01M6CBPC4M8MG6P55VCPQXCECJ":45},{"skillNo":4,"name":5,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":7,"ownerLogin":8,"ownerAvatarUrl":9,"ownerAccountNo":10,"ownerDisplayName":11,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":15,"categories":16,"keywords":17,"publishedAt":20,"linkOnly":21,"markdownContent":22,"markdownBody":22,"frontMatter":23,"refName":24,"commitSha":25,"skillDirectory":26,"forksCount":13,"repositoryHtmlUrl":27,"repositoryHomepageUrl":6,"ownerHtmlUrl":28,"ownerBlogUrl":6,"ownerDescription":29,"updatedAt":30,"install":31,"resources":44},"01M6CBPC4M8MG6P55VCPQXCECJ","40-ad-ssv-firebase",null,"devwinsoft\u002Fdevian","devwinsoft","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F22570160?v=4","01M6C6RR8W05NH8NPKQNCE3Q72","maoshy","01M6C6RRDJ5TJJCZXRTY801YDH",0,"MIT","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian-unity\u002F50-mobile-package\u002F47-advertise-system\u002F40-ad-ssv-firebase\u002FSKILL.md",[],[18,19],"firebase","40 ad ssv firebase","2026-11-10 10:12:30",false,"# 40-ad-ssv-firebase — Rewarded SSV Firebase Server Implementation\n\n\nStatus: ACTIVE\nAppliesTo: v10\n\n> Root SSOT: `skills\u002Fdevian\u002F10-module\u002F03-ssot\u002FSKILL.md`\n\n> Advertise SSOT: `skills\u002Fdevian-unity\u002F50-mobile-package\u002F47-advertise-system\u002F03-ssot\u002FSKILL.md` (특히 H 섹션)\n\n\n## 문서 경계 (Scope)\n\n- 이 문서는 **AdMob Rewarded SSV Firebase 서버 구현 정본**이다.\n- 포함: HTTP endpoint 구조, ECDSA 서명 검증, Firestore 감사 로그 스키마, custom_data 파싱, 멱등 규칙\n- 비포함: 고정 결정값(endpoint 이름, Firestore path, custom_data 포맷) (→ `41`)\n- 비포함: 클라이언트 보상 지급 흐름 (→ `03-ssot §D`, `30-ads-manager`)\n- 비포함: 운영 체크리스트\u002F테스트 시나리오 (→ `09-ssot-operations`)\n\n\n## 목적\n\nAdMob Rewarded SSV 콜백을 수신하여 **서명 검증 + 감사 로그 기록**을 수행하는 Firebase Cloud Functions HTTP endpoint의 구현 정본이다.\n\n- 보상 지급 방식: **클라이언트 즉시 지급** (기존 흐름 유지). SSV는 감사\u002F사후 검증 용도.\n- AdMob이 광고 시청 완료 시 서버 콜백 URL로 GET 요청을 보내고, 서버는 서명 검증 후 Firestore에 기록한다.\n\n\n---\n\n\n## A) SSV 엔드포인트 구조 (정본)\n\n- 타입: **HTTP function** (`onRequest`). Callable이 아니다.\n  - AdMob이 외부 URL로 GET 요청을 보내므로, Firebase Callable(클라이언트 SDK 호출)이 아닌 HTTP endpoint를 사용한다.\n- 소스 위치: `{repoRoot}\u002Ffunctions\u002Fsrc\u002Fads\u002FverifyAdReward.ts`\n- export: `functions\u002Fsrc\u002Findex.ts`에서 export\n- HTTP 메서드: **GET** (AdMob SSV 콜백은 GET 요청)\n\n### A1) 엔드포인트 URL\n\n- 배포 후 URL 형태: `https:\u002F\u002F{region}-{projectId}.cloudfunctions.net\u002FverifyAdReward`\n- 이 URL을 AdMob 콘솔 > 앱 설정 > Rewarded SSV 콜백 URL에 등록한다.\n\n\n---\n\n\n## B) ECDSA 서명 검증 절차 (정본)\n\n### B1) 공개키 조회\n\n- URL: `https:\u002F\u002Fgstatic.com\u002Fadmob\u002Freward\u002Fverifier-keys.json`\n- 응답: `{ \"keys\": [{ \"keyId\": number, \"pem\": string, \"base64\": string }, ...] }`\n- 캐시: **최대 24시간**. 공개키는 정기적으로 회전된다.\n\n### B2) 검증 흐름\n\n1. 콜백 URL의 쿼리 파라미터에서 `signature`와 `key_id`를 추출한다.\n2. `signature`와 `key_id`를 제외한 나머지 쿼리 파라미터를 알파벳 순으로 정렬하여 `&`로 연결한 문자열을 생성한다.\n3. `key_id`에 해당하는 공개키를 조회한다.\n4. ECDSA with SHA-256으로 서명을 검증한다.\n5. 검증 성공 시 Firestore에 기록. 실패 시 에러 로깅만 수행.\n\n### B3) 구현 방식\n\n- Node.js `crypto` 모듈로 직접 ECDSA 검증을 구현한다. 외부 npm 패키지 불필요.\n- 결정 정본: `41-ad-ssv-decisions §F`\n\n\n---\n\n\n## C) Firestore 스키마 — 감사 로그 (정본)\n\n### C1) Ad Rewards Log\n\n- Path: `\u002Fusers\u002F{uid}\u002FadRewards\u002F{transactionId}`\n\n필드 (최소):\n- `transactionId: string` (doc id와 동일, AdMob SSV의 `transaction_id`)\n- `adUnit: string` (AdMob `ad_unit` 파라미터)\n- `adNetwork: string` (AdMob `ad_network` 파라미터)\n- `rewardAmount: number` (AdMob `reward_amount` 파라미터)\n- `rewardItem: string` (AdMob `reward_item` 파라미터)\n- `customData: string` (AdMob `custom_data` 파라미터, 원본 보존)\n- `advertise_id: string` (custom_data에서 파싱)\n- `reward_group_id: string` (custom_data에서 파싱)\n- `verified: boolean` (서명 검증 성공 여부)\n- `timestamp: number` (AdMob `timestamp` 파라미터, epoch ms)\n- `verifiedAt: Timestamp` (Firestore serverTimestamp, 기록 시각)\n\n\n---\n\n\n## D) custom_data 포맷 (정본)\n\n클라이언트가 `ServerSideVerificationOptions`에 설정하는 `customData` 문자열:\n\n- 포맷: `{uid}:{advertise_id}:{reward_group_id}`\n- 구분자: `:` (콜론)\n- 예시: `abc123:ad_rewarded_001:reward_chest_001`\n\n서버는 이 문자열을 파싱하여:\n- `uid`로 Firestore 문서 경로의 `{uid}` 결정\n- `advertise_id` \u002F `reward_group_id`로 감사 로그 필드 기록\n\n파싱 실패 시: 에러 로깅 + HTTP 200 응답 (재시도 방지)\n\n\n---\n\n\n## E) 멱등 처리 (정본)\n\n- `transaction_id`를 Firestore 문서 ID로 사용한다.\n- 동일 `transaction_id`로 재요청 시:\n  - 이미 문서가 존재하면 **덮어쓰지 않는다** (중복 기록 방지)\n  - HTTP 200 응답 (AdMob 재시도 중단)\n\n\n---\n\n\n## F) 에러 처리 (정본)\n\nAdMob은 HTTP 200이 아닌 응답을 받으면 최대 5회 1초 간격으로 재시도한다.\n\n규칙:\n- **서명 검증 실패**: HTTP 200 반환 + 에러 로깅 (재시도해도 결과 동일)\n- **custom_data 파싱 실패**: HTTP 200 반환 + 에러 로깅\n- **Firestore 기록 실패**: HTTP 500 반환 (재시도 허용)\n- **공개키 조회 실패**: HTTP 500 반환 (재시도 허용)\n\n\n---\n\n\n## 클라이언트 측 변경 (참고)\n\n클라이언트 코드 변경이 필요하다 (구현은 `31-ad-provider-google-mobile-ads` 범위):\n\n- Rewarded 광고 로드 후, Show 이전에 `ServerSideVerificationOptions` 설정:\n\n```\nvar options = new ServerSideVerificationOptions.Builder()\n    .SetUserId(uid)\n    .SetCustomData($\"{uid}:{advertise_id}:{reward_group_id}\")\n    .Build();\nrewardedAd.SetServerSideVerificationOptions(options);\n```\n\n\n---\n\n\n## DoD\n\nHard (must be 0)\n- [ ] HTTP endpoint 구조(onRequest, GET)가 문서에 고정돼 있다.\n- [ ] ECDSA 서명 검증 절차가 문서에 고정돼 있다.\n- [ ] Firestore 감사 로그 스키마(path + 필드)가 문서에 고정돼 있다.\n- [ ] custom_data 포맷이 문서에 고정돼 있다.\n- [ ] 멱등 규칙(transaction_id 기반)이 문서에 고정돼 있다.\n\nSoft\n- [ ] Firebase 에뮬레이터에서 SSV 콜백 시뮬레이션 가이드 추가\n",{},"main","8cb5bd877a3a7004867d2f55697cb9e4ff2f1734","skills\u002Fdevian-unity\u002F50-mobile-package\u002F47-advertise-system\u002F40-ad-ssv-firebase","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian","https:\u002F\u002Fgithub.com\u002Fdevwinsoft","Kim Hyong Joon","2026-04-12 18:44:10",{"source":7,"skillName":5,"commands":32,"prompt":42,"zipAvailable":43},[33,36,39],{"tool":34,"command":35},"npx","npx skills add devwinsoft\u002Fdevian --skill 40-ad-ssv-firebase",{"tool":37,"command":38},"bunx","bunx skills add devwinsoft\u002Fdevian --skill 40-ad-ssv-firebase",{"tool":40,"command":41},"pnpm","pnpm dlx skills add devwinsoft\u002Fdevian --skill 40-ad-ssv-firebase","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian-unity\u002F50-mobile-package\u002F47-advertise-system\u002F40-ad-ssv-firebase\u002FSKILL.md",true,[],[46,52,60,68,77,84],{"skillNo":47,"name":48,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":7,"ownerLogin":8,"ownerAvatarUrl":9,"ownerAccountNo":10,"ownerDisplayName":11,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":49,"categories":50,"keywords":51,"publishedAt":20},"01M6CBX3K2JJV38ANHVGCAT8A0","devian","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002FSKILL.md",[],[48],{"skillNo":53,"name":54,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":7,"ownerLogin":8,"ownerAvatarUrl":9,"ownerAccountNo":10,"ownerDisplayName":11,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":55,"categories":56,"keywords":57,"publishedAt":20},"01M6CBX210BFMM8AMN770J9WY8","90-project-archive","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002F80-tools\u002F90-project-archive\u002FSKILL.md",[],[58,59],"archive","90 project archive",{"skillNo":61,"name":62,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":7,"ownerLogin":8,"ownerAvatarUrl":9,"ownerAccountNo":10,"ownerDisplayName":11,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":63,"categories":64,"keywords":65,"publishedAt":20},"01M6CBWZSGRR9R3YJZB3177667","30-env-management","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002F80-tools\u002F30-env-management\u002FSKILL.md",[],[66,67],"management","30 env management",{"skillNo":69,"name":70,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":7,"ownerLogin":8,"ownerAvatarUrl":9,"ownerAccountNo":10,"ownerDisplayName":11,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":71,"categories":72,"keywords":73,"publishedAt":20},"01M6CBWYD3TGWBQV7V35551THF","19-page-push-send","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002F80-tools\u002F20-operation\u002F19-page-push-send\u002FSKILL.md",[],[74,75,76],"push","page","19 page push send",{"skillNo":78,"name":79,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":7,"ownerLogin":8,"ownerAvatarUrl":9,"ownerAccountNo":10,"ownerDisplayName":11,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":80,"categories":81,"keywords":82,"publishedAt":20},"01M6CBWWRQ05A7604GHJKTPJTF","18-page-savedata","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002F80-tools\u002F20-operation\u002F18-page-savedata\u002FSKILL.md",[],[75,83],"18 page savedata",{"skillNo":85,"name":86,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":7,"ownerLogin":8,"ownerAvatarUrl":9,"ownerAccountNo":10,"ownerDisplayName":11,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":87,"categories":88,"keywords":89,"publishedAt":20},"01M6CBWVH4S2VF1V7B2GBTESCT","17-page-deobfuscate","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002F80-tools\u002F20-operation\u002F17-page-deobfuscate\u002FSKILL.md",[],[75,90],"17 page deobfuscate"]