원클릭으로
toss-iap-proxy-ops
Toss IAP 검증 스킬 — Edge Function 404 우회, Service Role 감지, mTLS mock 설정, 토큰 헬퍼 분리, subscriptions 활성화 E2E.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
Toss IAP 검증 스킬 — Edge Function 404 우회, Service Role 감지, mTLS mock 설정, 토큰 헬퍼 분리, subscriptions 활성화 E2E.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
Toss Mock 인증 스킬 — stable userKey 설정, survey 루프 근본 원인 진단, adb 기반 진단 순서를 표준화한 스킬.
Toss 수익화 운영 스킬 — Ads/IAP/TossPay 콘솔 설정, 샌드박스 시나리오, QA 증적 수집을 표준화.
AIT .ait 번들 빌드 → 콘솔 업로드 → 테스트 진입 성공 패턴. env var 인라인 실패(supabase url is required), babel/esbuild 충돌, __dirname 경로 문제 해결책 포함.
FastAPI + SQLAlchemy + Supabase 모델 작성 규칙 — DB 타입 불일치 방지, async cascade 패턴, Pydantic enum 검증. 2026-05-19 E2E 테스트(Wave 1~9)에서 발견한 BUG-01~05 재발 방지 지침.
TaillogToss 로컬 개발 서버(Metro + FastAPI) 시작/재시작 절차. "서버 켜줘", "서버 재시작", "앱 안됨", "번들 안됨" 등 서버 기동 관련 모든 상황에 사용한다.
TaillogToss Phase 13 게이트 운영 — AUTH/IAP/MSG/AD E2E 기준 점검, 증적 수집, 문서 상태 동기화.
| name | toss-iap-proxy-ops |
| description | Toss IAP 검증 스킬 — Edge Function 404 우회, Service Role 감지, mTLS mock 설정, 토큰 헬퍼 분리, subscriptions 활성화 E2E. |
Toss 미니앱의 IAP(인앱 결제) 검증이 Edge Function 경로 차단(404)으로 실패할 때 FastAPI proxy로 우회하는 패턴, Service Role Key 감지 방식, mTLS mock 강제 설정을 표준화한 스킬.
https://developers-apps-in-toss.toss.im/in-app-purchase/intro.htmlhttps://developers-apps-in-toss.toss.im/in-app-purchase/webhook.htmlhttps://supabase.com/docs/guides/functionshttps://supabase.com/docs/guides/functions/secretsintoss-private:// URL scheme)이 /functions/v1/ 경로 차단# FastAPI 서버 실행 (포트 8765)
cd /Users/family/jason/TaillogToss
python -m uvicorn Backend.main:app --reload --host 127.0.0.1 --port 8765
FastAPI 라우터:
Backend/app/features/subscription/router.pyPOST /api/v1/subscription/iap/verifybody.userId 사용# Device 포트 8000이 점유되어 있으면 8765 사용
adb reverse tcp:8765 tcp:8765
이유: 로컬 개발 시 device에서 http://127.0.0.1:8765/api/v1/...로 접근 가능
File: src/lib/api/iap.ts
Function: verifyAndGrant(receipt, userId)
Line: 269-284
// 기존 코드 (Edge Function 직접 호출)
try {
const response = await fetch(`${SUPABASE_URL}/functions/v1/verify-iap-order`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ receipt, userId }),
});
// 404 감지 → FastAPI proxy로 우회
if (response.status === 404) {
console.warn('[IAP] Edge Function 404, FastAPI proxy로 우회');
return await fallbackFastAPIVerify(receipt, userId);
}
return await response.json();
} catch (error) {
console.error('[IAP] 검증 실패:', error);
return await fallbackFastAPIVerify(receipt, userId);
}
// FastAPI proxy 호출
async function fallbackFastAPIVerify(receipt: string, userId: string) {
const response = await fetch('http://127.0.0.1:8765/api/v1/subscription/iap/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ receipt, userId }),
});
if (!response.ok) {
throw new Error(`FastAPI verify failed: ${response.status}`);
}
return await response.json();
}
:8765)adb reverse tcp:8765 tcp:8765 설정 완료src/lib/api/iap.ts:269-284 fallback 함수 추가SUPABASE_SERVICE_ROLE_KEY는 prefix SUPABASE_ 때문에 거부됨File: supabase/functions/verify-iap-order/main.ts
// JWT 토큰에서 role 클레임 추출
function parseTokenRole(token: string): string | null {
try {
// JWT 형식: header.payload.signature
const parts = token.split('.');
if (parts.length !== 3) {
console.log('[JWT] Invalid token format');
return null;
}
// payload 부분 base64 디코딩
const payload = parts[1];
// base64url -> base64 변환 (padding 처리)
const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
const claims = JSON.parse(decoded);
return claims.role || null;
} catch (error) {
console.error('[JWT] Parse failed:', error);
return null;
}
}
// 함수 사용 예시
async function handleVerifyRequest(req: Request) {
const authHeader = req.headers.get('authorization');
const token = authHeader?.replace('Bearer ', '') || '';
const role = parseTokenRole(token);
if (role === 'service_role') {
// FastAPI 등 서버에서 호출했다고 간주
// JWT 검증 스킵, body.userId 신뢰
const { userId, receipt } = await req.json();
return await processIAPWithoutJWTVerification(userId, receipt);
} else {
// 클라이언트 호출이므로 JWT + 본인 확인
return await processIAPWithFullValidation(token, req);
}
}
File: Backend/app/features/subscription/router.py
@router.post("/api/v1/subscription/iap/verify")
async def verify_iap_order(request: IAPVerifyRequest):
"""
FastAPI에서 Supabase Edge Function으로 요청.
Service Role로 호출되므로 JWT 검증 스킵.
"""
user_id = request.user_id # 클라이언트가 보낸 user_id 직접 사용
receipt = request.receipt
# 앱 서명 + 영수증 검증 (Toss 공식 API)
verification_result = await verify_with_toss_server(receipt)
if verification_result["status"] == "verified":
# 구독 권리 부여
await grant_subscription(user_id)
return {"success": True}
return {"success": False, "reason": "Invalid receipt"}
핵심 원칙:
role === 'service_role'이면 JWT 검증 스킵body.userId를 신뢰하고 직접 사용parseTokenRole(token) 함수 구현# Supabase 프로젝트 대시보드에서 또는 CLI로 설정
supabase secrets set TOSS_MTLS_MODE=mock
핵심:
TOSS_MTLS_MODE (Supabase 제약으로 SUPABASE_ prefix 불가)mock (개발 모드) 또는 production (실제 환경)File: supabase/functions/_shared/mtlsMode.ts
// mTLS 모드 감지
function getMTLSMode(): 'mock' | 'production' {
const mode = Deno.env.get('TOSS_MTLS_MODE');
return mode === 'mock' ? 'mock' : 'production';
}
// Mock mTLS Client 사용
class MockMTLSClient {
async verifyReceipt(receipt: string): Promise<VerificationResult> {
console.log('[mTLS] Mock mode enabled, skipping real certificate validation');
return {
status: 'success',
orderId: `mock_order_${Date.now()}`,
amount: 9900,
};
}
}
// 실제 호출 코드
async function verifyIAPReceipt(receipt: string) {
const mode = getMTLSMode();
if (mode === 'mock') {
const mockClient = new MockMTLSClient();
return await mockClient.verifyReceipt(receipt);
} else {
// 실제 cert/key로 mTLS 연결
return await realMTLSClient.verifyReceipt(receipt);
}
}
# 현재 Supabase 설정 확인
supabase secrets list
# 출력 예시:
# TOSS_MTLS_MODE=mock
# TOSS_CERT_PEM=<...> (프로덕션에만 설정)
# TOSS_KEY_PEM=<...> (프로덕션에만 설정)
배포 순서:
TOSS_MTLS_MODE=mock 입력TOSS_MTLS_MODE=mock 설정 완료supabase secrets list로 확인TOSS_MTLS_MODE 제거 또는 production 변경src/lib/api/iap.ts에 토큰 헬퍼 함수 7개가 섞여 있음// 현재 iap.ts에 포함된 함수들
1. getAuthToken() // Supabase JWT 토큰 획득
2. buildIAPHeaders() // IAP 요청 헤더 생성
3. encodeReceiptPayload() // 영수증 payload 인코딩
4. decodeTokenClaims() // JWT 클레임 파싱
5. validateTokenExpiry() // 토큰 만료 검증
6. sanitizeReceipt() // 영수증 형식 검증
7. parseIAPResponse() // 검증 응답 파싱
File: src/lib/api/iap-invoke.ts
/**
* IAP 토큰/헬퍼 함수 전용 모듈.
* iap.ts에서만 import 가능 (외부 import 금지).
*/
import { JwtPayload, jwtDecode } from 'jwt-decode'; // 필요시만 추가
// ============ 토큰 함수 ============
export function getAuthToken(): string {
// Supabase 클라이언트에서 JWT 획득
// const supabase = useSupabaseClient();
// return supabase.auth.session?.access_token || '';
}
export function decodeTokenClaims(token: string): JwtPayload | null {
try {
return jwtDecode(token);
} catch (error) {
console.error('[IAP] Token decode failed:', error);
return null;
}
}
export function validateTokenExpiry(token: string): boolean {
const claims = decodeTokenClaims(token);
if (!claims?.exp) return false;
const now = Math.floor(Date.now() / 1000);
return claims.exp > now;
}
// ============ 요청 함수 ============
export function buildIAPHeaders(token: string): Record<string, string> {
return {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'X-IAP-Request-Id': `iap_${Date.now()}_${Math.random()}`,
};
}
export function encodeReceiptPayload(receipt: string): string {
return Buffer.from(receipt).toString('base64');
}
export function sanitizeReceipt(receipt: string): string {
return receipt.trim().replace(/\s+/g, '');
}
// ============ 응답 함수 ============
export function parseIAPResponse(response: any): {
success: boolean;
subscriptionId?: string;
expiryDate?: number;
error?: string;
} {
if (!response || typeof response !== 'object') {
return { success: false, error: 'Invalid response' };
}
if (response.status !== 'verified') {
return { success: false, error: response.statusMessage || 'Verification failed' };
}
return {
success: true,
subscriptionId: response.latestReceiptInfo?.[0]?.subscription_id,
expiryDate: response.latestReceiptInfo?.[0]?.expires_date_ms,
};
}
File: src/lib/api/iap.ts (수정)
// 기존: 함수들이 섞여 있음
// 변경: iap-invoke에서 import
import {
getAuthToken,
buildIAPHeaders,
encodeReceiptPayload,
decodeTokenClaims,
validateTokenExpiry,
sanitizeReceipt,
parseIAPResponse,
} from './iap-invoke';
export async function verifyAndGrant(receipt: string, userId: string) {
const token = getAuthToken();
if (!validateTokenExpiry(token)) {
throw new Error('Token expired');
}
const sanitized = sanitizeReceipt(receipt);
const headers = buildIAPHeaders(token);
try {
const response = await fetch(`${SUPABASE_URL}/functions/v1/verify-iap-order`, {
method: 'POST',
headers,
body: JSON.stringify({ receipt: sanitized, userId }),
});
if (response.status === 404) {
return await fallbackFastAPIVerify(sanitized, userId);
}
const data = await response.json();
return parseIAPResponse(data);
} catch (error) {
console.error('[IAP] Verify failed:', error);
return { success: false, error: String(error) };
}
}
// 나머지 함수들은 변경 없음
src/lib/api/iap-invoke.ts 생성src/lib/api/iap.ts에서 import 추가verify-iap-order Edge Function이 toss_orders.grant_status='granted'만 기록subscriptions 테이블 업데이트 없음 → is_active=false 그대로useIsPro() → false → PRO 기능 잠금 해제 안 됨verify-iap-order/main.ts에 toss_orders upsert 이후 subscriptions 업데이트 로직 누락.
subscriptions.user_id에 UNIQUE 제약도 없어서 upsert 자체가 불가능했음.
-- supabase/migrations/20260504000001_subscriptions_user_id_unique.sql
ALTER TABLE public.subscriptions
ADD CONSTRAINT subscriptions_user_id_key UNIQUE (user_id);
# DB에 직접 적용 (migration drift 시)
supabase db query "DO \$\$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='subscriptions_user_id_key') THEN ALTER TABLE public.subscriptions ADD CONSTRAINT subscriptions_user_id_key UNIQUE (user_id); END IF; END\$\$;" --linked
File: supabase/functions/verify-iap-order/main.ts
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');
// productId → 지급 유형 매핑 (src/types/subscription.ts IAP_PRODUCTS와 동기화)
const PRODUCT_GRANTS: Record<string, { planType?: string; aiTokens?: number; durationDays?: number }> = {
'ait.0000020829.09e69bf9.90a91624b0.7443236299': { planType: 'PRO_MONTHLY', durationDays: 30 },
'ait.0000020829.b0b00d71.17c5290dc1.7444362301': { aiTokens: 10 },
'ait.0000020829.32dc32cf.49e67a4cfa.7443541064': { aiTokens: 30 },
};
async function activateSubscription(userId: string, productId: string): Promise<void> {
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) return;
const grant = PRODUCT_GRANTS[productId];
if (!grant) return;
const now = new Date();
const svcHeaders = {
apikey: SUPABASE_SERVICE_ROLE_KEY,
Authorization: `Bearer ${SUPABASE_SERVICE_ROLE_KEY}`,
'Content-Type': 'application/json',
};
if (grant.planType) {
// PRO_MONTHLY: subscriptions upsert (on_conflict=user_id)
const nextBilling = new Date(now);
nextBilling.setDate(nextBilling.getDate() + (grant.durationDays ?? 30));
await fetch(`${SUPABASE_URL}/rest/v1/subscriptions?on_conflict=user_id`, {
method: 'POST',
headers: { ...svcHeaders, Prefer: 'resolution=merge-duplicates,return=minimal' },
body: JSON.stringify([{
user_id: userId,
plan_type: grant.planType,
is_active: true,
next_billing_date: nextBilling.toISOString().split('T')[0],
updated_at: now.toISOString(),
}]),
});
} else if (grant.aiTokens) {
// 토큰 상품: 기존 row PATCH or 신규 INSERT
const getResp = await fetch(
`${SUPABASE_URL}/rest/v1/subscriptions?user_id=eq.${userId}&select=id,ai_tokens_remaining,ai_tokens_total`,
{ headers: svcHeaders },
);
const rows = await getResp.json().catch(() => []) as Array<{
id: string; ai_tokens_remaining: number | null; ai_tokens_total: number | null;
}>;
if (rows.length > 0) {
await fetch(`${SUPABASE_URL}/rest/v1/subscriptions?user_id=eq.${userId}`, {
method: 'PATCH',
headers: { ...svcHeaders, Prefer: 'return=minimal' },
body: JSON.stringify({
ai_tokens_remaining: (rows[0].ai_tokens_remaining ?? 0) + grant.aiTokens,
ai_tokens_total: (rows[0].ai_tokens_total ?? 0) + grant.aiTokens,
updated_at: now.toISOString(),
}),
});
} else {
await fetch(`${SUPABASE_URL}/rest/v1/subscriptions?on_conflict=user_id`, {
method: 'POST',
headers: { ...svcHeaders, Prefer: 'resolution=merge-duplicates,return=minimal' },
body: JSON.stringify([{
user_id: userId, plan_type: 'FREE', is_active: false,
ai_tokens_remaining: grant.aiTokens, ai_tokens_total: grant.aiTokens,
}]),
});
}
}
}
// upsertTossOrder 성공 직후, response 반환 전에 삽입
if (grantStatus === 'granted') {
try {
await activateSubscription(resolvedUserId!, body.productId);
} catch (err) {
console.error('[verify-iap-order] subscription activation failed (non-fatal):', err);
// non-fatal: toss_orders grant는 이미 기록됨
}
}
supabase functions deploy verify-iap-order --no-verify-jwt
# 1. 앱에서 [DEV] IAP 바이패스 버튼 누름
# 2. DB 확인
supabase db query "SELECT user_id, plan_type, is_active, next_billing_date, updated_at FROM subscriptions WHERE user_id = '<userId>';" --linked
# 기대값:
# is_active: true
# plan_type: PRO_MONTHLY
# next_billing_date: <오늘+30일>
| 증상 | 원인 | 해결 |
|---|---|---|
toss_orders granted, subscriptions 없음 | activateSubscription 미호출 또는 SUPABASE_SERVICE_ROLE_KEY 없음 | Edge Function 재배포 + secrets 확인 |
| upsert 400 에러 | subscriptions.user_id UNIQUE 제약 없음 | 5-1 마이그레이션 재실행 |
plan_type enum 에러 | DB enum: FREE, PRO_MONTHLY, PRO_YEARLY 외 값 사용 | PRODUCT_GRANTS planType 값 확인 |
| 토큰 상품 증분 안 됨 | PATCH 경로에서 user row 없을 때 INSERT 미처리 | else 분기 확인 |
FastAPI 준비
:8765 실행 확인Backend/app/features/subscription/router.py 검토클라이언트 수정
src/lib/api/iap.ts:269-284 fallback 함수 추가Edge Function 업그레이드
supabase/functions/verify-iap-order/main.ts JWT 파싱 함수 추가mTLS 설정
supabase secrets set TOSS_MTLS_MODE=mock 적용코드 리팩토링
src/lib/api/iap-invoke.ts 생성src/lib/api/iap.ts import 수정검증 및 문서 동기화
docs/PROJECT-STATUS.md 업데이트docs/ref/AIT-IAP-MESSAGE-POINTS-REFERENCE.md 패턴 추가adb logcat -d | grep IAP 확인jwtDecode() 라이브러리 대체supabase functions list --verbose 확인iap-invoke.ts에서 iap.ts import 금지