{"id":49115758,"url":"https://github.com/jeonghwanko/onesub","last_synced_at":"2026-07-13T08:01:02.720Z","repository":{"id":349634341,"uuid":"1203133250","full_name":"jeonghwanko/onesub","owner":"jeonghwanko","description":"Server-side receipt validation for react-native-iap. Apple StoreKit 2 + Google Play Billing. One line: app.use(createOneSubMiddleware(config))","archived":false,"fork":false,"pushed_at":"2026-07-12T18:09:11.000Z","size":1949,"stargazers_count":5,"open_issues_count":2,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-07-12T20:06:24.255Z","etag":null,"topics":["android","express","google-play","in-app-purchase","indie-developer","ios","mcp","middleware","monetization","nodejs","open-source","paywall","react-native","react-native-iap","receipt-validation","storekit","subscription","typescript"],"latest_commit_sha":null,"homepage":"https://www.npmjs.com/package/@onesub/core","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/jeonghwanko.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"docs/SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-06T18:51:44.000Z","updated_at":"2026-07-12T18:09:12.000Z","dependencies_parsed_at":"2026-04-26T12:01:43.819Z","dependency_job_id":null,"html_url":"https://github.com/jeonghwanko/onesub","commit_stats":null,"previous_names":["jeonghwanko/onesub"],"tags_count":114,"template":false,"template_full_name":null,"purl":"pkg:github/jeonghwanko/onesub","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jeonghwanko%2Fonesub","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jeonghwanko%2Fonesub/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jeonghwanko%2Fonesub/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jeonghwanko%2Fonesub/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/jeonghwanko","download_url":"https://codeload.github.com/jeonghwanko/onesub/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jeonghwanko%2Fonesub/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35414732,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-13T02:00:06.543Z","response_time":119,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["android","express","google-play","in-app-purchase","indie-developer","ios","mcp","middleware","monetization","nodejs","open-source","paywall","react-native","react-native-iap","receipt-validation","storekit","subscription","typescript"],"created_at":"2026-04-21T07:07:43.320Z","updated_at":"2026-07-13T08:01:02.699Z","avatar_url":"https://github.com/jeonghwanko.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cp align=\"center\"\u003e\n  \u003ca href=\"https://www.npmjs.com/package/@onesub/server\"\u003e\u003cimg src=\"https://img.shields.io/npm/v/@onesub/server.svg?label=%40onesub%2Fserver\" alt=\"@onesub/server\" /\u003e\u003c/a\u003e\n  \u003ca href=\"https://www.npmjs.com/package/@jeonghwanko/onesub-sdk\"\u003e\u003cimg src=\"https://img.shields.io/npm/v/@jeonghwanko/onesub-sdk.svg?label=%40jeonghwanko%2Fonesub-sdk\" alt=\"@jeonghwanko/onesub-sdk\" /\u003e\u003c/a\u003e\n  \u003ca href=\"https://www.npmjs.com/package/@onesub/mcp-server\"\u003e\u003cimg src=\"https://img.shields.io/npm/v/@onesub/mcp-server.svg?label=%40onesub%2Fmcp-server\" alt=\"@onesub/mcp-server\" /\u003e\u003c/a\u003e\n  \u003cbr/\u003e\n  \u003cimg src=\"https://img.shields.io/badge/license-MIT-blue.svg\" alt=\"MIT License\" /\u003e\n  \u003cimg src=\"https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg\" alt=\"Node \u003e= 20\" /\u003e\n  \u003cimg src=\"https://img.shields.io/badge/platform-iOS%20%7C%20Android-lightgrey.svg\" alt=\"Platform\" /\u003e\n\u003c/p\u003e\n\n\u003ch1 align=\"center\"\u003eonesub\u003c/h1\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003cstrong\u003eServer-side receipt validation for react-native-iap. One line.\u003c/strong\u003e\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  react-native-iap handles the purchase.\u003cbr/\u003e\n  onesub handles everything after — validation, webhooks, subscription state.\u003cbr/\u003e\n  Open source. Self-hosted. Zero revenue share.\n\u003c/p\u003e\n\n---\n\n## The Problem\n\nYou use `react-native-iap` to handle purchases. But then you need a server to:\n\n- Verify Apple StoreKit 2 receipts (JWS signature validation)\n- Verify Google Play receipts (Play Developer API v3)\n- Handle webhooks (renewals, cancellations, refunds)\n- Track subscription state in a database\n- Track one-time purchases (consumables + non-consumables)\n- Expose \"is this user subscribed?\" and \"what did this user buy?\" endpoints\n\n**That's 2-3 weeks of work.** Or one line:\n\n```ts\napp.use(createOneSubMiddleware(config));\n```\n\n---\n\n## How It Works\n\n```\nreact-native-iap (client)            @onesub/server (your backend)\n                                      ┌─────────────────────────────────┐\nSubscriptions:                        │                                 │\n  requestSubscription() ──receipt───▶ │ POST /onesub/validate           │\n  fetch('/onesub/status') ──────────▶ │ GET  /onesub/status             │\n                                      │                                 │\nOne-time purchases:                   │                                 │\n  requestPurchase() ────receipt───▶   │ POST /onesub/purchase/validate  │\n  fetch('/onesub/purchase/status') ──▶│ GET  /onesub/purchase/status    │\n                                      │                                 │\nWebhooks (auto):                      │ POST /onesub/webhook/apple      │\n                                      │ POST /onesub/webhook/google     │\n                                      └─────────────────────────────────┘\n```\n\n---\n\n## Quick Start\n\n### 1. Install\n\n```bash\nnpm install @onesub/server\n```\n\n### 2. Add to your Express app\n\n```ts\nimport { createOneSubMiddleware, PostgresSubscriptionStore, PostgresPurchaseStore } from '@onesub/server';\n\napp.use(createOneSubMiddleware({\n  apple: {\n    bundleId: 'com.yourapp.id',\n    sharedSecret: process.env.APPLE_SHARED_SECRET,\n  },\n  google: {\n    packageName: 'com.yourapp.id',\n    serviceAccountKey: process.env.GOOGLE_SERVICE_ACCOUNT_KEY,\n  },\n  database: { url: process.env.DATABASE_URL },\n  store: new PostgresSubscriptionStore(process.env.DATABASE_URL),\n  purchaseStore: new PostgresPurchaseStore(process.env.DATABASE_URL),\n}));\n```\n\n### 3. Check subscription from your app\n\n```ts\nconst res = await fetch('https://api.yourapp.com/onesub/status?userId=user123');\nconst { active } = await res.json();\n// active: true → subscribed, false → not subscribed\n```\n\n**Done.** Apple/Google receipt validation, webhooks, and subscription tracking — all handled.\n\n---\n\n## What You Get\n\n### Subscriptions (auto-renewable)\n\n| Endpoint | What it does |\n|----------|-------------|\n| `POST /onesub/validate` | Verify receipt, save subscription |\n| `GET /onesub/status?userId=` | Check if user has active subscription |\n| `POST /onesub/webhook/apple` | Handle App Store Server Notifications V2 |\n| `POST /onesub/webhook/google` | Handle Google Real-Time Developer Notifications |\n\n#### Lifecycle states\n\n`SubscriptionInfo.status` carries the full lifecycle. The `/onesub/status` route's `active: boolean` collapses it for simple gating; the raw status lets you render accurate UX.\n\n```mermaid\nstateDiagram-v2\n    [*] --\u003e active : 구매 / SUBSCRIBED / PURCHASED\n    active --\u003e grace_period : Apple DID_FAIL_TO_RENEW + GRACE_PERIOD\\nGoogle IN_GRACE_PERIOD\n    grace_period --\u003e active : DID_RENEW / RENEWED\n    grace_period --\u003e on_hold : Apple GRACE_PERIOD_EXPIRED\\nGoogle ON_HOLD\n    on_hold --\u003e active : DID_RENEW / RECOVERED\n    active --\u003e paused : Google SUBSCRIPTION_PAUSED\n    paused --\u003e active : SUBSCRIPTION_RESTARTED\n    active --\u003e canceled : REFUND / REVOKE\n    grace_period --\u003e canceled : REFUND\n    on_hold --\u003e canceled : REFUND\n    active --\u003e expired : 자연 만료 / EXPIRED\n```\n\n| State | `active` | When | Host UX hint |\n|-------|---------|------|-------------|\n| `active` | ✅ | paid period | normal |\n| `grace_period` | ✅ | payment failed but store still grants access | \"결제 정보 확인 필요 (계속 사용 가능)\" |\n| `on_hold` | ❌ | grace ended, billing retry continues | \"결제 정보를 업데이트하세요\" |\n| `paused` | ❌ | user-voluntary pause (Google only) | \"재개 예정: \\{autoResumeTime\\}\" |\n| `expired` | ❌ | natural end without renewal | re-purchase |\n| `canceled` | ❌ | refunded or revoked | re-purchase / restore |\n\n`active` is computed as `(active || grace_period) \u0026\u0026 expiresAt \u003e now` — the `expiresAt` check is a backstop for missed `EXPIRED` webhooks and for the `'until_expiry'` refund policy.\n\n### One-time Purchases (consumable + non-consumable)\n\n| Endpoint | What it does |\n|----------|-------------|\n| `POST /onesub/purchase/validate` | Verify receipt, save purchase. Response includes `action: 'new' \\| 'restored'` so the client can distinguish a first-time purchase from an idempotent replay or reinstall-triggered reassignment. |\n| `GET /onesub/purchase/status?userId=` | List user's purchases |\n\n### Admin (opt-in — requires `config.adminSecret`)\n\nMounted only when `config.adminSecret` is set. All requests must include the `X-Admin-Secret` header.\n\n**Purchase admin** (testing + migration):\n\n| Endpoint | What it does |\n|----------|-------------|\n| `DELETE /onesub/purchase/admin/:userId/:productId` | Wipe a non-consumable so the user can re-test the purchase flow |\n| `POST /onesub/purchase/admin/grant` | Manually insert a purchase record (bypasses store verification) |\n| `POST /onesub/purchase/admin/transfer` | Reassign a `transactionId` to a new `userId` (legitimate device/account migration) |\n\n**Dashboard / operational**:\n\n| Endpoint | What it does |\n|----------|-------------|\n| `GET /onesub/admin/subscriptions?userId=\u0026status=\u0026productId=\u0026platform=\u0026limit=\u0026offset=` | Filtered + paginated subscription list (max 200 per page) |\n| `GET /onesub/admin/subscriptions/:transactionId` | Single subscription record by `originalTransactionId` |\n| `GET /onesub/admin/customers/:userId` | Full per-user profile: subscriptions + purchases + entitlements (when configured) |\n| `GET /onesub/admin/webhook-deadletters` | List failed webhook jobs (requires BullMQ queue) |\n| `POST /onesub/admin/webhook-replay/:id` | Replay a failed webhook job (requires BullMQ queue) |\n\n### Entitlements (opt-in — requires `config.entitlements`)\n\nMap abstract feature flags to one or more product IDs. A user is entitled when they have an **active subscription** or a **non-consumable purchase** for any product in the list. Consumables are excluded — they grant a one-time resource, not an ongoing right.\n\n```ts\napp.use(createOneSubMiddleware({\n  ...config,\n  entitlements: {\n    premium: { productIds: ['pro_monthly', 'pro_yearly', 'premium_unlock'] },\n    adFree:  { productIds: ['remove_ads'] },\n  },\n}));\n```\n\n| Endpoint | What it does |\n|----------|-------------|\n| `GET /onesub/entitlement?userId=\u0026id=premium` | Single check: `{ id, active, source, productId?, expiresAt? }` |\n| `GET /onesub/entitlements?userId=` | All entitlements in one round-trip — use on app launch / login |\n\nSDK: `const { entitlements, hasEntitlement } = useOneSub();` — fetches the full map from the server and re-evaluates on subscription change.\n\n### Metrics (opt-in — same `config.adminSecret`, same `X-Admin-Secret` header)\n\nRead-only aggregate counts. Revenue metrics (MRR, ARR) require per-product price configuration and are not yet implemented.\n\n| Endpoint | What it does |\n|----------|-------------|\n| `GET /onesub/metrics/active` | Current active count: `{ total, activeSubscriptions, gracePeriodSubscriptions, nonConsumablePurchases, byProduct, byPlatform }` |\n| `GET /onesub/metrics/started?from=\u0026to=\u0026groupBy=` | Subscriptions started in a date range |\n| `GET /onesub/metrics/expired?from=\u0026to=\u0026groupBy=` | Subscriptions expired or canceled in a date range |\n| `GET /onesub/metrics/purchases/started?from=\u0026to=\u0026groupBy=` | Non-consumable purchases started in a date range |\n\n`from` and `to` are ISO 8601 timestamps. Add `groupBy=day` to get a UTC-bucketed time series in the `buckets` field.\n\n**Consumables** (coins, credits): Can be purchased multiple times. Each purchase is tracked.\n\n**Non-consumables** (unlock premium, remove ads): Purchased once. Duplicate purchases are rejected with `409 NON_CONSUMABLE_ALREADY_OWNED`.\n\n```ts\n// Validate a consumable purchase\nconst res = await fetch('https://api.yourapp.com/onesub/purchase/validate', {\n  method: 'POST',\n  headers: { 'Content-Type': 'application/json' },\n  body: JSON.stringify({\n    platform: 'apple',\n    receipt: transactionReceipt,\n    userId: 'user123',\n    productId: 'credits_100',\n    type: 'consumable',    // or 'non_consumable'\n  }),\n});\nconst { valid, purchase } = await res.json();\n```\n\n## What's Under the Hood\n\n- **Apple**: StoreKit 2 JWS verified against Apple Root CA G3 (full x5c chain), App Store Server API for status fetch fallback + CONSUMPTION_REQUEST response, JWT minting cached + Promise-deduped\n- **Google**: OAuth2 service account → Play Developer API **v3** (`subscriptionsv2.get`) — `subscriptionState` enum directly mapped to lifecycle states (no expiry/cancelReason inference)\n- **Webhooks**: Lifecycle classification for grace_period / on_hold / paused (Apple subtype + Google notification types), `acknowledgePurchase` auto-called for Google subs+IAP, `voidedPurchasesNotification` routed to right store, `linkedPurchaseToken` chain tracking for plan changes\n- **Refund policy**: Choose `'immediate'` (default — flip status=canceled) or `'until_expiry'` (keep entitlement until original expiry)\n- **Outbound calls**: All upstream fetches (Apple/Google APIs) wrapped with `AbortController` timeout (default 10s) so a hung upstream doesn't pile up webhook handlers\n- **Storage**: Pluggable `SubscriptionStore` + `PurchaseStore` — built-in PostgreSQL (auto-backfilled columns via `ALTER TABLE IF NOT EXISTS`) + in-memory\n- **Validation**: zod input validation, 50KB body limit, userId length checks\n- **Tests**: 296+ tests (single-notification units + multi-notification e2e lifecycle scenarios)\n- **Security**: [Full details →](docs/SECURITY.md)\n- **Troubleshooting**: [errorCode → cause → fix](docs/RECEIPT-ERRORS.md)\n- **Migration**: [Per-version upgrade notes](docs/MIGRATION.md)\n\n---\n\n## Optional: React Native SDK\n\nIf you want a drop-in React hook + paywall component (built on react-native-iap):\n\n```bash\nnpm install @jeonghwanko/onesub-sdk react-native-iap\n```\n\n```tsx\nimport { OneSubProvider, useOneSub } from '@jeonghwanko/onesub-sdk';\n\n// Wrap your app\n\u003cOneSubProvider config={{ serverUrl, productId }} userId={userId}\u003e\n  \u003cApp /\u003e\n\u003c/OneSubProvider\u003e\n\n// Subscriptions\nconst { isActive, subscribe, restore } = useOneSub();\n\n// One-time products (consumable / non-consumable)\nconst { purchaseProduct, restoreProduct } = useOneSub();\n\n// Purchase a consumable (e.g. coins)\nconst purchase = await purchaseProduct('credits_100', 'consumable');\n// purchase is null if user cancelled, PurchaseInfo (+ action) on success\n\n// Purchase a non-consumable (e.g. premium unlock)\nconst purchase = await purchaseProduct('premium_unlock', 'non_consumable');\nif (purchase?.action === 'restored') {\n  // already owned — show \"복원 완료\" instead of \"구매 완료\"\n}\n\n// Restore a non-consumable from the store's purchase history\nconst restored = await restoreProduct('premium_unlock', 'non_consumable');\n```\n\n**Mock mode** — set `config.mockMode: true` to return synthetic success from `subscribe` / `restore` / `purchaseProduct` / `restoreProduct` without calling `react-native-iap` or the onesub server. Useful for running UI flows in Expo Go / the simulator. Never enable in production.\n\n**Peer dependency:** SDK requires `react-native-iap` **v15+** (event-based purchase flow).\n\nThe SDK is optional. You can use `@onesub/server` with any client — React Native, Flutter, or plain HTTP calls.\n\n---\n\n## Optional: MCP Server (AI Integration)\n\nFor Claude Code / Cursor users — AI helps set up your subscription:\n\n```json\n{ \"mcpServers\": { \"onesub\": { \"command\": \"npx\", \"args\": [\"@onesub/mcp-server\"] } } }\n```\n\n\u003e \"Add a monthly subscription to my Expo app\"\n\n### Skill document for AI agents\n\nA single-file integration guide optimized for LLM ingestion lives at [`SKILL.md`](SKILL.md). Point Claude / Cursor / any agent at it for complete onesub context:\n\n\u003e Read `https://raw.githubusercontent.com/jeonghwanko/onesub/master/SKILL.md` then integrate onesub into this project.\n\n---\n\n## Packages\n\n| Package | Version | What | Install |\n|---------|---------|------|---------|\n| [`@onesub/server`](https://www.npmjs.com/package/@onesub/server) | ![npm](https://img.shields.io/npm/v/@onesub/server.svg) | Express middleware — receipt validation + webhooks | `npm i @onesub/server` |\n| [`@jeonghwanko/onesub-sdk`](https://www.npmjs.com/package/@jeonghwanko/onesub-sdk) | ![npm](https://img.shields.io/npm/v/@jeonghwanko/onesub-sdk.svg) | React Native SDK — `useOneSub()` + `\u003cPaywall /\u003e` | `npm i @jeonghwanko/onesub-sdk` |\n| [`@onesub/mcp-server`](https://www.npmjs.com/package/@onesub/mcp-server) | ![npm](https://img.shields.io/npm/v/@onesub/mcp-server.svg) | MCP tools — AI creates products + paywalls | `npx @onesub/mcp-server` |\n| [`@onesub/providers`](https://www.npmjs.com/package/@onesub/providers) | ![npm](https://img.shields.io/npm/v/@onesub/providers.svg) | App Store Connect + Google Play API wrappers (standalone) | `npm i @onesub/providers` |\n| [`@onesub/cli`](https://www.npmjs.com/package/@onesub/cli) | ![npm](https://img.shields.io/npm/v/@onesub/cli.svg) | Scaffolds a starter server project | `npx @onesub/cli init` |\n| [`@onesub/shared`](https://www.npmjs.com/package/@onesub/shared) | ![npm](https://img.shields.io/npm/v/@onesub/shared.svg) | Shared TypeScript types | Auto-installed |\n| `onesub-dashboard` | [![ghcr](https://img.shields.io/badge/ghcr.io-latest-blue)](https://github.com/jeonghwanko/onesub/pkgs/container/onesub-dashboard) | Self-hosted operations dashboard (Docker) | `docker run -p 4101:4101 -e ONESUB_SERVER_URL=... ghcr.io/jeonghwanko/onesub-dashboard:latest` |\n\n---\n\n## vs RevenueCat\n\n| | RevenueCat | onesub |\n|---|---|---|\n| Receipt validation | Their servers | **Your server** |\n| Revenue share | 1% after $2.5K | **0% forever** |\n| Data ownership | Their database | **Your database** |\n| Vendor lock-in | Yes | **No (MIT open source)** |\n| Dashboard | Yes | Not yet |\n| Setup time | 2-3 hours | **10 minutes** |\n\n**onesub is not a RevenueCat replacement.** RevenueCat offers analytics, experiments, and a dashboard. onesub is for developers who want to own their subscription infrastructure.\n\nAlready on RevenueCat and curious? See [`docs/MIGRATE-FROM-REVENUECAT.md`](docs/MIGRATE-FROM-REVENUECAT.md) — a step-by-step guide covering client code, historical data, webhook switchover, and rollback.\n\n---\n\n## Examples\n\nWorking examples to get you started in minutes:\n\n| Example | What | Run |\n|---------|------|-----|\n| [`examples/server`](examples/server) | Express server with receipt validation | `npm start` |\n| [`examples/expo-app`](examples/expo-app) | Expo Router app with paywall | `npx expo start` |\n\n```bash\n# 1. Start the server\ncd examples/server\ncp .env.example .env   # add your Apple/Google credentials\nnpm install \u0026\u0026 npm start\n\n# ── or, full stack (server + Postgres) in one command ──\ndocker compose up      # http://localhost:4100\n\n# 2. Start the app (in another terminal)\ncd examples/expo-app\nnpm install \u0026\u0026 npx expo start\n```\n\n---\n\n## Custom Store\n\nBuilt-in PostgreSQL store, or bring your own:\n\n```ts\nimport { SubscriptionStore } from '@onesub/server';\n\nclass RedisStore implements SubscriptionStore {\n  async save(sub) { /* ... */ }\n  async getByUserId(userId) { /* ... */ }\n  async getByTransactionId(txId) { /* ... */ }\n}\n\napp.use(createOneSubMiddleware({ ...config, store: new RedisStore() }));\n```\n\nThe canonical Postgres schema is shipped with the package at\n[`packages/server/sql/schema.sql`](packages/server/sql/schema.sql). Apply it\nwith `psql -f` if you manage migrations externally, or let `store.initSchema()`\nrun it for you on startup.\n\n---\n\n## Roadmap\n\n- [x] Apple StoreKit 2 receipt validation (JWKS verified, x5c chain → Apple Root CA G3)\n- [x] Google Play Billing v3 (`subscriptionsv2.get` resource)\n- [x] Webhook handlers (Apple V2 + Google RTDN, voided purchases included)\n- [x] Lifecycle states: `grace_period` / `on_hold` / `paused` (correct classification, not inferred)\n- [x] PostgreSQL subscription store + purchase store (auto-backfill via `ALTER TABLE IF NOT EXISTS`)\n- [x] React Native SDK + paywall components\n- [x] MCP server for AI-assisted setup\n- [x] Apple App Store Server API direct fetch — webhook miss recovery + Status sync\n- [x] Apple `CONSUMPTION_REQUEST` response hook (server-signed JWT to Apple)\n- [x] Google `acknowledgePurchase` auto-call (subs + non-consumable IAP) — no more 3-day auto-refunds\n- [x] Google `linkedPurchaseToken` continuity (plan upgrade userId inheritance)\n- [x] Refund policy (`immediate` | `until_expiry`)\n- [x] Outbound fetch hardening (Apple JWT cache + Promise dedup, `AbortController` timeout)\n- [x] Security hardening (zod validation, body limits, signature verification)\n- [x] e2e lifecycle scenario test suite (multi-notification sequences)\n- [x] CLI scaffolding (`npx @onesub/cli init`)\n- [ ] Apple Family Sharing (`FAMILY_SHARED`)\n- [ ] Apple Promotional Offer server-side signing\n- [ ] Google `oneTimeProductNotification` (currently only voided purchases handled for IAP)\n- [ ] Apple Transaction History API (replay past transactions)\n- [x] Analytics dashboard (self-hosted Docker — `ghcr.io/jeonghwanko/onesub-dashboard`)\n- [ ] Hosted service (no server needed)\n\n---\n\n## Contributing\n\n```bash\ngit clone https://github.com/jeonghwanko/onesub.git\ncd onesub \u0026\u0026 npm install \u0026\u0026 npm run build \u0026\u0026 npm test\n```\n\nSee [CLAUDE.md](CLAUDE.md) for architecture and conventions.\n\n---\n\n## License\n\n[MIT](LICENSE)\n\n---\n\n\u003cp align=\"center\"\u003e\n  \u003cstrong\u003ereact-native-iap\u003c/strong\u003e handles the purchase.\u003cbr/\u003e\n  \u003cstrong\u003eonesub\u003c/strong\u003e handles everything after.\n\u003c/p\u003e\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjeonghwanko%2Fonesub","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fjeonghwanko%2Fonesub","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjeonghwanko%2Fonesub/lists"}