Durable Authority (R1) — inbox dedup, usage counters, reconciliation, outbox scope
be-cloudflare에 추가된 결제/권한 durable 저장소 계층. 전부 additive — 기존 마이그레이션(001–014)과 엔드포인트 계약은 변경하지 않는다.
저장소 (migrations 015–017)
| 테이블 | 마이그레이션 | 역할 |
|---|---|---|
inbox_events | 015 | 인바운드 프로바이더 이벤트 dedup. event_id PK (provider 명시 id 또는 provider:sha256(payload)), status: received|processing|processed|failed |
usage_counters | 016 | (tenant_id, lic_id, feature, period) 단위 카운터. period='all'+'YYYY-MM' |
execution_capabilities.consumed_by | 016 | additive 컬럼 — 소비 승자 마커 (usage 귀속 가드) |
reconciliation_runs / reconciliation_findings | 017 | 정합성 검사 실행/결과 영속 |
inbox dedup (webhook_toss)
POST /api/v1/webhooks/toss의 흐름: 서명검증 → JSON 파싱 → inbox claim → 권위 재조회(GET) → 부수효과 → processed 마킹.
status='processed'만 dedup을 확정한다 — 동일 재전송은{"deduplicated":true}.received/processing/failed는 미완료로 간주해 재처리한다(하류 효과는finalize_paid/apply_provider_cancel이 결정적 id +INSERT OR IGNORE로 멱등).- 처리 도중 실패(
fetch_failed,finalize_failed)는processed로 마킹하지 않아 다음 재전송이 회복하고, stale 행은 reconciliation이 표면화한다. - inbox 쓰기 자체가 실패하면 Fresh로 진행한다 — dedup은 추가 강화층이며 D1 오류로 합법 이벤트를 버리지 않는다(기존 payment_key dedup + 멱등성이 방어선).
durable usage counters (licenses/execute)
POST /api/v1/licenses/execute의 소비는 단일 db.batch로 원자화한다:
UPDATE execution_capabilities SET consumed=1, consumed_by=<use_id> WHERE consumed=0 AND exp>now AND 귀속 일치INSERT INTO license_usage ... WHERE (SELECT consumed_by ...)=?— 승자만 기록 3–4.usage_countersupsert ('all','YYYY-MM') — 같은consumed_by가드
동일 cap_id 경합에서 진 요청은 UPDATE changes=0이고, consumed_by 가드가 남의 소비를 자기 usage로 잘못 기록하는 것도 막는다. 카운터는 인메모리가 아닌 D1 행이므로 isolate 재시작/수평확장과 무관하다. reserved 컬럼은 quota/예약 정책(R3) 대비용 — 이번 wave는 used만 기록한다.
reconciliation (admin 전용)
POST /api/v1/admin/reconciliation/run— 7종 검사 실행, 결과를 즉시 반환GET /api/v1/admin/reconciliation/reports— 실행 이력GET /api/v1/admin/reconciliation/report/:id— findings 상세
검사: paid_no_entitlement, unpaid_but_entitled, refunded_but_valid_entitlement, subscription_expired_but_renewed, duplicate_issuance_per_payment, unprocessed_inbox_events, failed_or_stuck_outbox.
finding 스키마: {type, ref_ids, expected, actual, causal_events, recoverable, recommended_action, state}. 자동수정은 상태 표면화만 하는 되돌리기로 제한한다 — stale outbox claim → pending, stale inbox processing → failed. 금전/권한 부여·회수는 자동으로 하지 않고 state='needs_review'로 남긴다. 권한은 resolve_identity의 role='admin'만 통과(metrics와 동일 패턴).
outbox honest scope
deliver_event(checkout_impl)의 범위는 명시적으로 좁다:
OUTBOX_SINK_URL미설정(기본) → consumer: none. outbox는 durable 감사 큐 — delivery 실패가last_error='consumer:none'으로 기록되고 지수백오프 후 dead-letter된다. 실제 외부 소비자는 없다는 사실을 숨기지 않는다.OUTBOX_SINK_URL설정 시{id, topic, payload, attempts}JSON POST; 2xx만 성공. sink는 배포 설정(env)이며 요청 입력이 아니다(SSRF 표면 없음).POST /api/v1/admin/outbox/:id/redrive(admin) — dead-letter/stuck 이벤트를pending+attempts=0으로 재큐.consumed는 재큐 불가(이중 전달 방지).
읽기 API (seller 콘솔용, R3 연결)
GET /api/v1/licenses/:id/bindings— PoP 기기 바인딩(jkt+created_at, jwk 미노출)GET /api/v1/licenses/:id/usage— usage 로그 + 카운터GET /api/v1/security-events(?lic_id=) — 보안 이벤트 (테넌트 스코프)GET /api/v1/usage-counters(?lic_id=) — 카운터 (테넌트 스코프)
스코프는 endpoints_impl::scope와 동일 — admin 전체, seller 본인 (products.seller_id 조인), buyer 403, anonymous 401. 비소유는 404.
검증
be-cloudflare/tests/run-durable.sh — 로컬 전용 오케스트레이터(worker-build → .dev.vars 보강 → 로컬 D1 마이그레이션+픽스처 → wrangler dev → durable.test.mjs). durable.test.mjs는 실제 로컬 D1/miniflare에서 PAY-01/02/04/05, LIC-01/02/03, USE-01/02, REC-03 + reconciliation/read-API/redrive 계약을 검증한다. Toss 권위 재조회는 로컬 스텁(127.0.0.1:8799)이 응답한다 — 실 PG 키/호출 없음.