diff --git a/packages/docs/public/llms-full.txt b/packages/docs/public/llms-full.txt index 732ba56d2..c413a5d0f 100644 --- a/packages/docs/public/llms-full.txt +++ b/packages/docs/public/llms-full.txt @@ -3,7 +3,7 @@ > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Quick Reference: https://openiap.dev/llms.txt -> Generated: 2026-07-27T13:04:16.611Z +> Generated: 2026-07-27T21:51:57.841Z ## Table of Contents 1. Installation @@ -40,13 +40,13 @@ pod 'openiap', '~> 2.4.4' ### Kotlin (Android) ```kotlin // Gradle (build.gradle.kts) -implementation("io.github.hyochan.openiap:openiap-google:2.5.1") +implementation("io.github.hyochan.openiap:openiap-google:2.5.2") // For Meta Horizon OS -implementation("io.github.hyochan.openiap:openiap-google-horizon:2.5.1") +implementation("io.github.hyochan.openiap:openiap-google-horizon:2.5.2") // For Fire OS (Amazon Appstore) -implementation("io.github.hyochan.openiap:openiap-google-amazon:2.5.1") +implementation("io.github.hyochan.openiap:openiap-google-amazon:2.5.2") ``` ### Flutter @@ -55,13 +55,13 @@ flutter pub add flutter_inapp_purchase ``` ### Godot -Download `godot-iap-2.6.1.zip` from GitHub Releases, extract it to +Download `godot-iap-2.6.2.zip` from GitHub Releases, extract it to `addons/godot-iap/`, then enable the plugin in Project Settings. ### Kotlin Multiplatform ```kotlin dependencies { - implementation("io.github.hyochan:kmp-iap:2.7.1") + implementation("io.github.hyochan:kmp-iap:2.7.2") } ``` @@ -73,7 +73,7 @@ https://central.sonatype.com/artifact/io.github.hyochan/kmp-iap dotnet add package OpenIap.Maui ``` -Current NuGet package version: 1.4.1 +Current NuGet package version: 1.4.2 Requires .NET 9 or .NET 10, the MAUI workload, iOS 15.0+, and Android API 24+. @@ -1972,6 +1972,8 @@ Bearer header and out of URLs. - [GET /v1/products/{apiKey}/{productId}/client-payload?platform=IOS](https://kit.openiap.dev/docs/products) — fetch `{ clientPayload }`; returns 404 when the product is missing/Removed or no payload exists - [GET/PUT/DELETE /v1/products/client-payload/{productId}?platform=IOS](https://kit.openiap.dev/docs/products) — read durable editor state or create/update/remove a payload from CI or MCP with `Authorization: Bearer `; publishable keys receive 403 - [GET /v1/products and catalog/store-sync writes](https://openiap.dev/docs/kit-backend) — header-authenticated GET is client-safe; POST/DELETE and `/v1/products/sync/{ios|android}` require `Authorization: Bearer `; secret-key-in-path requests receive 410 +- [GET /v1/subscriptions/status?userId=...](https://kit.openiap.dev/docs/api) — publishable Bearer-key entitlement gate; save the weak `ETag`, resend it as `If-None-Match`, and reuse the persisted snapshot on body-free `304` +- [GET /v1/subscriptions/entitlements?userId=...](https://kit.openiap.dev/docs/api) — publishable Bearer-key active product snapshot with the same conditional contract; supports up to 200 indexed subscription rows and fails closed on overflow - [GET /v1/subscriptions/{list|metrics|revenue}](https://openiap.dev/docs/kit-backend) — administrative subscription data with `Authorization: Bearer ` - [POST /v1/webhooks/{apiKey}](https://openiap.dev/docs/webhooks#setup) — lifecycle webhook receiver. Paste this URL into App Store Connect (Production + Sandbox) AND Google Cloud Pub/Sub push subscription. Auto-detects ASN v2 vs Pub/Sub by payload shape. **POST-only**; opening in a browser returns 404 — that's expected. - [GET /v1/openapi](https://kit.openiap.dev/v1/openapi) — machine-readable OpenAPI spec @@ -1982,6 +1984,17 @@ Bearer header and out of URLs. Also mounted at `/api/v1/*` for backwards compatibility. `/v1/verify-purchase` is an alias of `/v1/purchase/verify`. Pick `/v1/purchase/verify` for new code. +Apple ASN v2 and Google RTDN update IAPKit's canonical subscription state. +IAPKit does not relay those events through SSE, WebSockets, push, or long +polling. Apps persist only the user-scoped fields they need and conditionally +refresh on cold start, stale foreground, or explicit user action. Each refresh +still performs one mutation-free indexed Convex query so an expiry with no new +webhook is detected. Respect `429 Retry-After`, coalesce concurrent refreshes, +and enforce an app-defined maximum stale age for offline fallback. Secret-key +responses are `private, no-store` and omit `ETag`. The current +`kitApi.status()` and `kitApi.entitlements()` helpers are unconditional, +body-only reads; use raw HTTP or an app wrapper to retain response headers. + ## Request shapes (discriminated on `store`) - Apple — `{ store: "apple", jws, expectedProductId?, includeClientPayload? }` where `jws` is a StoreKit 2 JWS (≤ 16 KB) diff --git a/packages/docs/public/llms.txt b/packages/docs/public/llms.txt index 2a2151b2b..2e9e9f113 100644 --- a/packages/docs/public/llms.txt +++ b/packages/docs/public/llms.txt @@ -3,7 +3,7 @@ > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Full Reference: https://openiap.dev/llms-full.txt -> Generated: 2026-07-27T13:04:16.611Z +> Generated: 2026-07-27T21:51:57.841Z ## Installation @@ -24,9 +24,9 @@ npm install react-native-iap ```kotlin // Gradle -implementation("io.github.hyochan.openiap:openiap-google:2.5.1") -implementation("io.github.hyochan.openiap:openiap-google-horizon:2.5.1") -implementation("io.github.hyochan.openiap:openiap-google-amazon:2.5.1") +implementation("io.github.hyochan.openiap:openiap-google:2.5.2") +implementation("io.github.hyochan.openiap:openiap-google-horizon:2.5.2") +implementation("io.github.hyochan.openiap:openiap-google-amazon:2.5.2") ``` ```bash @@ -36,20 +36,20 @@ flutter pub add flutter_inapp_purchase ```gdscript # Godot -# Install godot-iap 2.6.1 to addons/godot-iap and enable the plugin +# Install godot-iap 2.6.2 to addons/godot-iap and enable the plugin ``` ```kotlin // Kotlin Multiplatform -implementation("io.github.hyochan:kmp-iap:2.7.1") +implementation("io.github.hyochan:kmp-iap:2.7.2") ``` ```xml - + ``` -Current NuGet package version: 1.4.1 +Current NuGet package version: 1.4.2 ## Framework Libraries diff --git a/packages/docs/src/pages/docs/kit-backend.tsx b/packages/docs/src/pages/docs/kit-backend.tsx index 71ff1202a..dddf3c571 100644 --- a/packages/docs/src/pages/docs/kit-backend.tsx +++ b/packages/docs/src/pages/docs/kit-backend.tsx @@ -65,14 +65,13 @@ function KitBackend() { /google aliases remain supported for existing setups.
  • - GET /v1/subscriptions/status/{apiKey}?userId={' '} - — fast entitlement gate. + GET /v1/subscriptions/status?userId= — fast entitlement + gate with a publishable Bearer key. The key-in-path form remains a + compatibility alias.
  • - - GET /v1/subscriptions/entitlements/{apiKey}?userId= - {' '} - — every active productId for a user. + GET /v1/subscriptions/entitlements?userId= — every + active productId for a user, also with a publishable Bearer key.
  • GET /v1/subscriptions/list — secret @@ -83,8 +82,9 @@ function KitBackend() { Bearer-authenticated MRR, churn, and refund counts.
  • - POST /v1/subscriptions/bind-user/{apiKey} — - attach a userId to a tracked subscription by purchase token. + POST /v1/subscriptions/bind-user — attach a userId to a + tracked subscription by purchase token with a publishable Bearer + key. The key-in-path form remains a compatibility alias.
  • GET /v1/products/{publishableKey} — client @@ -547,6 +547,198 @@ if (status.Active) ), }} + + + Recommended SSE replacement: conditional snapshots + +

    + App Store Server Notifications v2 and Google RTDN update IAPKit's + canonical subscription rows. IAPKit deliberately does not relay those + events to apps, expose a raw event feed, or keep an SSE, WebSocket, or + long-poll connection open. The app reads only the latest snapshot for + its opaque userId. +

    +

    + Webhook event rows are bounded operational history for deduplication + and retention. Polling reads the canonical snapshot, not an event + cursor, so pruning old event rows does not erase the user's + latest state and apps do not have to consume every event in order. +

    +
      +
    1. + For a purchase started in this app session, update the UI from the + SDK purchase callback and verified result. Bind its stable purchase + token to the app-scoped user ID once. +
    2. +
    3. Render from a persisted status or entitlement snapshot.
    4. +
    5. + Revalidate on cold start, when a foreground snapshot is stale, or + after an explicit user refresh. Use one refresh coordinator so + concurrent screens share the same in-flight request; do not run a + continuous timer. Define a maximum stale age for offline or + rate-limited fallback and fail closed after that app-specific + window. +
    6. +
    7. + Save the response ETag and send it as{' '} + If-None-Match next time. Reuse the cached body on{' '} + 304; replace it on 200. +
    8. +
    9. + Respect 429 Retry-After and use jittered backoff after + network failures. +
    10. +
    11. + Key local storage by IAPKit project and opaque user ID, and clear it + on sign-out. Never render one user's cached snapshot for + another. +
    12. +
    +

    + The example below uses the raw HTTP contract so it can retain response + headers. The current kitApi.status() and{' '} + kitApi.entitlements() convenience methods still perform + unconditional reads and return only the decoded body; use raw{' '} + fetch or an app wrapper when you need conditional + revalidation. +

    + {`type CachedEntitlements = { + cacheScope: string; // App-defined IAPKit project/environment identifier. + etag: string | null; + checkedAt: number; + snapshot: { + userId: string; + productIds: string[]; + subscriptions: Array<{ + productId: string; + state: string; + expiresAt?: number; + renewsAt?: number; + willRenew?: boolean; + }>; + }; +}; + +async function refreshEntitlements( + iapkitCacheScope: string, + userId: string, + cached: CachedEntitlements | null, + maxStaleMs: number, + iapkitPublishableKey: string, +) { + const cachedForScope = + cached?.cacheScope === iapkitCacheScope && + cached.snapshot.userId === userId + ? cached + : null; + const canUseCachedSnapshot = () => { + if ( + cachedForScope === null || + !Number.isFinite(cachedForScope.checkedAt) || + !Number.isFinite(maxStaleMs) || + maxStaleMs < 0 + ) { + return false; + } + const cacheAgeMs = Date.now() - cachedForScope.checkedAt; + return cacheAgeMs >= 0 && cacheAgeMs <= maxStaleMs; + }; + let response: Response; + try { + response = await fetch( + \`https://kit.openiap.dev/v1/subscriptions/entitlements?userId=\${encodeURIComponent(userId)}\`, + { + headers: { + Authorization: \`Bearer \${iapkitPublishableKey}\`, + ...(cachedForScope?.etag + ? { 'If-None-Match': cachedForScope.etag } + : {}), + }, + }, + ); + } catch (error) { + if (cachedForScope && canUseCachedSnapshot()) { + return cachedForScope.snapshot; + } + throw error; + } + + if (response.status === 304 && cachedForScope) { + const refreshed = { ...cachedForScope, checkedAt: Date.now() }; + await persistEntitlements(refreshed); + return refreshed.snapshot; + } + if (response.status === 429) { + scheduleRetry(response.headers.get('Retry-After')); + if (cachedForScope && canUseCachedSnapshot()) { + return cachedForScope.snapshot; + } + throw new Error('Entitlement refresh is rate limited'); + } + if (!response.ok) throw new Error('Entitlement refresh failed'); + + const wireSnapshot = (await response.json()) as { + userId: string; + productIds: string[]; + subscriptions: Array<{ + productId: string; + state: string; + expiresAt?: number; + renewsAt?: number; + willRenew?: boolean; + }>; + }; + const snapshot = { + userId: wireSnapshot.userId, + productIds: wireSnapshot.productIds, + subscriptions: wireSnapshot.subscriptions.map( + ({ productId, state, expiresAt, renewsAt, willRenew }) => ({ + productId, + state, + expiresAt, + renewsAt, + willRenew, + }), + ), + }; + await persistEntitlements({ + cacheScope: iapkitCacheScope, + snapshot, + etag: response.headers.get('ETag'), + checkedAt: Date.now(), + }); + return snapshot; +}`} +
    +

    + A 304 still makes one Convex query invocation.{' '} + Convex returns a time-independent row snapshot and invalidates its + cached result when a dependent row changes. Fly then evaluates + expiry against its own current clock on every HTTP request. Cached + query results and caller-controlled timestamps therefore cannot + preserve expired access, and a time-only transition is detected on + the next refresh. The conditional request avoids response-body + transfer and unnecessary app state replacement; it does not make + aggressive polling free. +

    +

    + A user snapshot supports up to 200 subscription rows. IAPKit reads + one additional indexed row only to detect overflow and returns{' '} + 400 ENTITLEMENT_SNAPSHOT_TOO_LARGE instead of exposing + a partial entitlement set. +

    +

    + Mobile snapshots are suitable for UI and local feature gating. If a + developer-owned backend protects paid content, that backend must + authenticate the user and make the entitlement decision. It also + owns any optional APNs or FCM delivery. +

    +

    + Persist only the fields the UI needs. In particular, avoid retaining + purchase tokens in general-purpose local storage when product IDs, + states, and expiry times are sufficient. +

    +
    diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index 629f7b020..057424041 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -276,6 +276,94 @@ function Releases() { ), }, + // July 28, 2026 - IAPKit conditional entitlement snapshots + { + id: 'iapkit-conditional-entitlement-snapshots-2026-07-28', + date: new Date('2026-07-28'), + element: ( +
    + + July 28, 2026 - IAPKit conditional entitlement snapshots + + +

    + Updates the hosted IAPKit service and its documentation with the + supported replacement for the removed IAPKit-to-app SSE surface. + Apple ASN v2 and Google RTDN continue to update IAPKit's + canonical subscription state; apps read only a user-scoped snapshot + through bounded request/response APIs. +

    + +
    Hosted IAPKit changes
    +
      +
    • + GET /v1/subscriptions/status?userId= and{' '} + GET /v1/subscriptions/entitlements?userId= return an + API-key, route, user, and content-scoped ETag for + publishable-key clients. A matching If-None-Match{' '} + returns body-free 304; a tag from another key, user, + or route cannot match. Secret-key responses remain{' '} + private, no-store without an ETag. +
    • +
    • + Convex returns a time-independent row snapshot and invalidates its + cached result when a dependent row changes. Fly reevaluates expiry + against its own current clock before returning 200 or{' '} + 304, so cached results and caller-controlled + timestamps cannot preserve expired access. Polls create no usage + or last-access mutation, use the project-user-updated index, + support up to 200 subscription rows with one bounded overflow + probe, and fail closed rather than return partial entitlements. +
    • +
    • + Apps should persist the last snapshot, render it immediately, and + conditionally refresh on cold start, when it is stale after + foregrounding, or after an explicit user action. Continuous + timers, raw webhook feeds, SSE, WebSockets, and long polling are + not supported. Offline fallback must use an app-defined maximum + stale age rather than retain access indefinitely. +
    • +
    • + Existing API-key, source-IP, and process rate limits remain in + front of Convex. Clients must respect 429 Retry-After{' '} + and use jittered backoff. +
    • +
    + +

    + This is a hosted-service and documentation update. It has no + installable OpenIAP package or framework-library version. Existing{' '} + kitApi.status() and kitApi.entitlements(){' '} + helpers remain unconditional body-only reads; conditional clients + use the documented raw HTTP contract or an app wrapper. +

    +
    + ), + }, + // July 25, 2026 - IAPKit security and SDK patch train { id: 'iapkit-security-cross-sdk-payload-integrity-2026-07-25', diff --git a/packages/kit/COST-SAFETY.md b/packages/kit/COST-SAFETY.md index 25facdeed..b5a98f8c7 100644 --- a/packages/kit/COST-SAFETY.md +++ b/packages/kit/COST-SAFETY.md @@ -1,6 +1,6 @@ # IAPKit cost and abuse safety -This document records the cost model for the public IAPKit API as of July 26, 2026. It is an operational estimate, not an invoice forecast: actual Convex +This document records the cost model for the public IAPKit API as of July 28, 2026. It is an operational estimate, not an invoice forecast: actual Convex database I/O depends on each project's document sizes and should be measured from production function logs. @@ -20,6 +20,7 @@ lookup while its compatibility fallback is active. | Direct payload, matching ETag (`304`) | 1 query | 3 auth + 1 product + 1 summary = up to 5; payload body row is not read | 0 | | Catalog without payloads | 1 query | 3 auth + at most 50 product rows; payload tables are not queried | 0 | | Catalog with payloads | 1 query | 3 auth + at most 50 product rows + at most 50 exact payload rows = up to 103 | 0 | +| Subscription status or entitlements | 1 query | 3 auth + at most 201 user-indexed subscription rows = up to 204 | 0 | | Apple purchase verification | 7-8 executions on a successful request | Indexed/point reads for auth, the Apple P8 file, and existing receipt/subscription rows | Existing receipt/statistics and optional subscription writes only | | Google purchase verification | 7 executions on a successful request | Indexed/point reads for auth, service-account file, optional catalog type, and existing receipt/subscription rows | Existing receipt/statistics and optional subscription writes only | @@ -38,7 +39,12 @@ enrichment; enrichment absence or failure does not change a successful receipt result. All catalog reads use indexed pagination with a default of 25 and maximum of 50. Payload-inclusive pages perform a bounded N+1 of at most 50 exact indexed -lookups. A payload body is limited to 16 KiB of decoded UTF-8. +lookups. A payload body is limited to 16 KiB of decoded UTF-8. User-scoped +status and entitlement reads use +`subscriptions.by_project_and_user_and_updated` and cap the indexed slice at +201 rows: up to 200 are supported and the final row detects overflow. IAPKit +fails closed instead of returning a partial entitlement set. Typical users have +only one or a few rows. ## Edge protection @@ -67,6 +73,17 @@ cross-machine hard brake; a distributed globally consistent edge limit would require additional infrastructure and is not justified for the current single-machine deployment. +The Convex deployment URL is public configuration, and the legacy public +`subscriptionStatus` / `entitlements` functions remain callable with a +publishable key for rolling-deploy and rollback compatibility. Such direct +Convex calls bypass Fly's in-memory limiter. Their reads are still indexed and +bounded, and the new evaluation snapshot exposes only stored-state entitlement +candidates plus one latest status fallback rather than raw subscription +history. This is a pre-existing, non-expanding residual risk; Convex usage +limits are the current process-independent cost brake. Remove the legacy direct +functions in a later coordinated major deployment after every old Fly binary +has drained. + Only the `Fly-Client-IP` header inserted by the current Fly ingress is trusted. Forwarding and CDN headers supplied by callers are ignored. Direct local requests without the Fly header share the bounded `unknown` IP bucket. @@ -78,15 +95,35 @@ and payload version. A matching `If-None-Match` returns `304` without reading the payload body row. The scope prevents an ETag from one project/key being accepted for another. +Subscription status and entitlement responses use a content ETag scoped by API +key, route, and opaque user ID. A tag from another key, user, or route cannot +produce a `304`. Convex returns a time-independent, bounded row snapshot and +invalidates its cached query result whenever a dependent subscription row +changes. Fly evaluates `expiresAt` against its own current clock on every HTTP +request before comparing the tag. This prevents cached query results or +caller-controlled timestamps from preserving expired access and detects a +time-only expiration transition on the next refresh even when no webhook +arrives. Therefore a subscription `304` still costs one query invocation; when +Convex recomputes it, the indexed-read ceiling is the same as a `200`, while a +valid Convex cache hit may avoid those reads. The `304` itself avoids +response-body egress and unnecessary client state replacement. + The direct response sends `Cache-Control: private, no-cache` and -`Vary: Authorization`. Catalog pages and every secret-admin response use +`Vary: Authorization`. The subscription snapshots use the same private, +revalidate-on-use policy. Catalog pages and every secret-admin response use `private, no-store`; shared caches must never retain them. React Native IAP and Expo IAP accept an AsyncStorage-compatible `clientPayloadCache`. They persist the body, version, and ETag, return a valid cache entry without polling, and conditionally revalidate only when `refresh: true` is requested. Apps with a known product ID should use the direct endpoint. Do not download -the complete payload catalog on every foreground event. +the complete payload catalog on every foreground event. For entitlement state, +persist one user-scoped snapshot and conditionally refresh it on cold start, +when it is stale on foreground, or after explicit user action. Do not run a +continuous timer or rebuild the removed outbound event stream. Coalesce +concurrent callers through one in-app refresh coordinator. Define a maximum +stale age for offline or rate-limited fallback and fail closed after that +app-specific window. ## Approximate high-volume cost @@ -113,6 +150,13 @@ some overhead. A `304` still uses one Convex query and reads small auth/product/summary documents, but avoids the 16 KiB body read and response egress. +One million user-scoped status or entitlement polls use one million Convex +function calls. Database I/O depends on the number and size of that user's +subscription rows: normal accounts read one or a few rows, while the bounded +pathological ceiling is 201 million small row reads across those requests. +Rate limiting is the primary protection against an app polling continuously; +`304` responses do not make that request free. + Pricing references: [Convex pricing](https://www.convex.dev/pricing), [Convex usage limits](https://docs.convex.dev/production/usage-limits), and diff --git a/packages/kit/README.md b/packages/kit/README.md index 8f1c71e74..560437af1 100644 --- a/packages/kit/README.md +++ b/packages/kit/README.md @@ -217,6 +217,65 @@ before JSON parsing; it is not per-product metadata capacity. A `clientPayload.body` is separately limited to **16 KiB of UTF-8**, must be nonblank, and—when its format is JSON or TOML—must pass syntax validation. +### Subscription lifecycle refresh without a stream + +Apple App Store Server Notifications v2 and Google RTDN are accepted only in +the store-to-IAPKit direction. IAPKit deduplicates those deliveries and updates +the canonical subscription snapshot. It does not relay raw events, SSE, +WebSockets, long polls, APNs, or FCM to shipped apps. + +The raw event row exists for bounded operational history and deduplication, then +expires under webhook retention. The canonical subscription snapshot remains. +Polling reads that current state rather than replaying a cursor, so an app does +not have to consume every event in order. + +The recommended client flow is: + +1. For a purchase started in the current app session, use the SDK purchase + callback and verification response for the immediate UI update. Bind the + stable purchase token to an opaque, app-scoped `userId`. +2. Render from a persisted entitlement snapshot on startup. +3. Revalidate `GET /v1/subscriptions/status?userId=...` or + `GET /v1/subscriptions/entitlements?userId=...` with the publishable key in + `Authorization` on cold start, when a foreground snapshot is stale, or after + an explicit user refresh. Route refreshes through one coordinator so + concurrent screens share the same in-flight request. Define an app-specific + maximum stale age for offline or rate-limited fallback, and fail closed after + that window. +4. Persist the response body and `ETag`. Send that tag as `If-None-Match` next + time. A `304` means the snapshot is unchanged; a `200` replaces it. +5. Respect `429 Retry-After` and use jittered backoff. Do not run a continuous + foreground/background polling timer. + +Key the local cache by IAPKit project and opaque user ID, and delete it on +sign-out. Never render one signed-in user's cached snapshot for another. +Persist only the fields the UI needs; avoid retaining purchase tokens in +general-purpose local storage when product IDs, states, and expiry times are +sufficient. + +Snapshot reads use the `(projectId, userId, updatedAt)` index, support up to 200 +subscription rows per user, and perform no usage or last-access mutation. One +additional indexed row is read only to detect overflow; IAPKit fails closed +instead of returning a partial entitlement set. Convex returns a +time-independent row snapshot and invalidates its cached result whenever a +dependent subscription row changes. Fly evaluates `expiresAt` against its own +current clock on every HTTP request before returning `200` or `304`. Cached +query results and caller-controlled timestamps therefore cannot preserve +expired access, and a time-only expiration transition is detected on the next +refresh. The conditional response saves body transfer and unnecessary app state +updates; it does not eliminate the Convex query invocation. + +The raw HTTP response exposes `ETag`. The current `kitApi.status()` and +`kitApi.entitlements()` convenience methods perform unconditional reads and +return only the decoded body, so use raw HTTP or an app wrapper when conditional +revalidation is required. + +This client-readable snapshot is appropriate for app UI and local feature +gating. If paid content is protected by a developer-owned backend, that backend +must authenticate the user and make the entitlement decision. It may query the +same user-scoped endpoint or send its own APNs/FCM notification after processing +store webhooks. + ### Product client payloads Each iOS or Android catalog row can carry an optional public diff --git a/packages/kit/convex/schema.ts b/packages/kit/convex/schema.ts index 83974040f..4ebdd27b2 100644 --- a/packages/kit/convex/schema.ts +++ b/packages/kit/convex/schema.ts @@ -725,6 +725,11 @@ const schema = defineSchema({ .index("by_project", ["projectId"]) .index("by_project_and_token", ["projectId", "purchaseToken"]) .index("by_project_and_user", ["projectId", "userId"]) + .index("by_project_and_user_and_updated", [ + "projectId", + "userId", + "updatedAt", + ]) .index("by_project_and_state", ["projectId", "state"]) .index("by_project_and_updated", ["projectId", "updatedAt"]) .index("by_project_and_product", ["projectId", "productId"]) diff --git a/packages/kit/convex/subscriptions/query.test.ts b/packages/kit/convex/subscriptions/query.test.ts index f264cd664..5d676a306 100644 --- a/packages/kit/convex/subscriptions/query.test.ts +++ b/packages/kit/convex/subscriptions/query.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from "vitest"; +import { ConvexError } from "convex/values"; import type { Doc, Id } from "../_generated/dataModel"; -import { selectReportingMrr, shapeSubscriptionRow } from "./query"; +import { + assertUserSubscriptionRowLimit, + MAX_USER_SUBSCRIPTION_ROWS, + selectReportingMrr, + shapeSubscriptionEvaluationSnapshot, + shapeSubscriptionRow, +} from "./query"; function subscriptionDoc( overrides: Partial>, @@ -102,3 +109,75 @@ describe("shapeSubscriptionRow", () => { expect(row.originalTransactionId).toBeUndefined(); }); }); + +describe("assertUserSubscriptionRowLimit", () => { + it("accepts the documented 200-row boundary", () => { + const rows = Array.from( + { length: MAX_USER_SUBSCRIPTION_ROWS }, + (_, index) => + subscriptionDoc({ + _id: `subscriptions_${index}` as Id<"subscriptions">, + }), + ); + + expect(() => assertUserSubscriptionRowLimit(rows)).not.toThrow(); + }); + + it("fails closed on the single overflow-probe row", () => { + const rows = Array.from( + { length: MAX_USER_SUBSCRIPTION_ROWS + 1 }, + (_, index) => + subscriptionDoc({ + _id: `subscriptions_${index}` as Id<"subscriptions">, + }), + ); + + try { + assertUserSubscriptionRowLimit(rows); + throw new Error("Expected the subscription row limit to fail"); + } catch (error) { + expect(error).toBeInstanceOf(ConvexError); + expect( + (error as ConvexError<{ code: string; message: string }>).data, + ).toEqual({ + code: "ENTITLEMENT_SNAPSHOT_TOO_LARGE", + message: + "This user has more than 200 subscription rows. Contact IAPKit support before retrying.", + }); + } + }); +}); + +describe("shapeSubscriptionEvaluationSnapshot", () => { + it("exposes only entitlement candidates and the latest status fallback", () => { + const rows = [ + subscriptionDoc({ + _id: "subscriptions_latest" as Id<"subscriptions">, + state: "Expired", + updatedAt: 4, + purchaseToken: "latest-token", + }), + subscriptionDoc({ + _id: "subscriptions_active" as Id<"subscriptions">, + state: "Active", + updatedAt: 3, + purchaseToken: "active-token", + }), + subscriptionDoc({ + _id: "subscriptions_refunded" as Id<"subscriptions">, + state: "Refunded", + updatedAt: 2, + purchaseToken: "historical-token", + }), + ]; + + const snapshot = shapeSubscriptionEvaluationSnapshot(rows); + + expect(snapshot.candidates).toHaveLength(1); + expect(snapshot.candidates[0]?.purchaseToken).toBe("active-token"); + expect(snapshot.candidates[0]?.createdAt).toBe(0); + expect(snapshot.fallback?.purchaseToken).toBe("latest-token"); + expect(snapshot.fallback?.createdAt).toBe(0); + expect(JSON.stringify(snapshot)).not.toContain("historical-token"); + }); +}); diff --git a/packages/kit/convex/subscriptions/query.ts b/packages/kit/convex/subscriptions/query.ts index 0476ca7e7..9d5965d27 100644 --- a/packages/kit/convex/subscriptions/query.ts +++ b/packages/kit/convex/subscriptions/query.ts @@ -1,5 +1,5 @@ import { query, type QueryCtx } from "../_generated/server"; -import { v, type Infer } from "convex/values"; +import { ConvexError, v, type Infer } from "convex/values"; import type { Doc, Id } from "../_generated/dataModel"; import { @@ -24,7 +24,7 @@ const subscriptionStateValidator = v.union( v.literal("Unknown"), ); -const subscriptionShape = v.object({ +const subscriptionFields = { id: v.id("subscriptions"), productId: v.string(), platform: v.union(v.literal("IOS"), v.literal("Android")), @@ -40,16 +40,77 @@ const subscriptionShape = v.object({ purchaseToken: v.string(), originalTransactionId: v.optional(v.string()), userId: v.optional(v.string()), +}; +const subscriptionShape = v.object(subscriptionFields); +const subscriptionEvaluationRowShape = v.object({ + ...subscriptionFields, + createdAt: v.number(), }); type SubscriptionRow = Infer; +type SubscriptionEvaluationRow = Infer; +export const MAX_USER_SUBSCRIPTION_ROWS = 200; function isActive(sub: Doc<"subscriptions">, now: number): boolean { - const entitled = sub.state === "Active" || sub.state === "InGracePeriod"; - if (!entitled) return false; + if (!isEntitledState(sub)) return false; if (sub.expiresAt != null && sub.expiresAt <= now) return false; return true; } +function isEntitledState(sub: Doc<"subscriptions">): boolean { + return sub.state === "Active" || sub.state === "InGracePeriod"; +} + +export function assertUserSubscriptionRowLimit( + rows: Array>, +): void { + if (rows.length <= MAX_USER_SUBSCRIPTION_ROWS) return; + throw new ConvexError({ + code: "ENTITLEMENT_SNAPSHOT_TOO_LARGE", + message: + "This user has more than 200 subscription rows. Contact IAPKit support before retrying.", + }); +} + +async function userSubscriptionRows( + ctx: QueryCtx, + projectId: Id<"projects">, + userId: string, +): Promise>> { + const rows = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_user_and_updated", (q) => + q.eq("projectId", projectId).eq("userId", userId), + ) + .order("desc") + .take(MAX_USER_SUBSCRIPTION_ROWS + 1); + assertUserSubscriptionRowLimit(rows); + return rows; +} + +export function shapeSubscriptionEvaluationSnapshot( + rows: Array>, +): { + candidates: SubscriptionEvaluationRow[]; + fallback: SubscriptionEvaluationRow | null; +} { + const fallback = selectMostRecentlyUpdatedSubscription(rows); + return { + candidates: rows + .filter(isEntitledState) + .map(shapeSubscriptionEvaluationRow), + fallback: fallback ? shapeSubscriptionEvaluationRow(fallback) : null, + }; +} + +function shapeSubscriptionEvaluationRow( + sub: Doc<"subscriptions">, +): SubscriptionEvaluationRow { + return { + ...shapeSubscriptionRow(sub), + createdAt: sub._creationTime, + }; +} + export function shapeSubscriptionRow( sub: Doc<"subscriptions">, ): SubscriptionRow { @@ -162,6 +223,30 @@ export function selectReportingMrr( }; } +// Time-independent evaluation snapshot for the Fly HTTP boundary. It excludes +// refunded, revoked, and other historical rows except for the single latest +// fallback needed by status. State-entitled candidates may include a row whose +// expiresAt has just passed; Fly removes it with its own current clock before +// producing the public HTTP response. Convex may safely cache the snapshot and +// invalidates it when a dependent row changes. +export const subscriptionEvaluationSnapshot = query({ + args: { + apiKey: v.string(), + userId: v.string(), + }, + returns: v.object({ + candidates: v.array(subscriptionEvaluationRowShape), + fallback: v.union(subscriptionEvaluationRowShape, v.null()), + }), + handler: async (ctx, args) => { + const project = await projectByApiKey(ctx, args.apiKey); + if (!project) return { candidates: [], fallback: null }; + + const rows = await userSubscriptionRows(ctx, project._id, args.userId); + return shapeSubscriptionEvaluationSnapshot(rows); + }, +}); + // Match onesub's `/onesub/status?userId=` — returns the most-recently- // updated active subscription when the user is entitled, otherwise the // most-recently-updated subscription overall, plus one `active` boolean @@ -176,12 +261,7 @@ export const subscriptionStatus = query({ const project = await projectByApiKey(ctx, args.apiKey); if (!project) return { active: false, subscription: null }; - const subs = await ctx.db - .query("subscriptions") - .withIndex("by_project_and_user", (q) => - q.eq("projectId", project._id).eq("userId", args.userId), - ) - .collect(); + const subs = await userSubscriptionRows(ctx, project._id, args.userId); const now = Date.now(); const activeSubs = subs.filter((candidate) => isActive(candidate, now)); @@ -214,12 +294,7 @@ export const entitlements = query({ return { userId: args.userId, productIds: [], subscriptions: [] }; } - const all = await ctx.db - .query("subscriptions") - .withIndex("by_project_and_user", (q) => - q.eq("projectId", project._id).eq("userId", args.userId), - ) - .collect(); + const all = await userSubscriptionRows(ctx, project._id, args.userId); const now = Date.now(); const active = all.filter((sub) => isActive(sub, now)); diff --git a/packages/kit/public/llms-full.txt b/packages/kit/public/llms-full.txt index f517c7e35..bccf9add1 100644 --- a/packages/kit/public/llms-full.txt +++ b/packages/kit/public/llms-full.txt @@ -167,6 +167,44 @@ URL. Publishable keys receive `403 INSUFFICIENT_SCOPE`. - Publishable keys embedded in an app can be extracted, but cannot call administrative operations. Use separate keys per app build/environment and never ship a secret key. - A matching IAPKit catalog row must exist before a payload write. After creating a product directly in App Store Connect or Play Console, run a secret-authenticated pull sync and wait for its job to succeed, or create the row with `POST /v1/products`. Early writes return `PRODUCT_NOT_FOUND`. +### GET /v1/subscriptions/status?userId={userId} + +Returns `{ active, subscription }` for one opaque, app-scoped user ID. Send a +publishable key as `Authorization: Bearer openiap-kit_pk_...`. The older +key-in-path route remains a publishable-key compatibility alias. + +### GET /v1/subscriptions/entitlements?userId={userId} + +Returns `{ userId, productIds, subscriptions }` for the user's active +entitlements. The status and entitlement queries use the +`(projectId, userId, updatedAt)` index, read at most 201 rows, support 200, and +return `400 ENTITLEMENT_SNAPSHOT_TOO_LARGE` rather than a partial result when +the overflow probe finds another row. They do not update API-key usage or +last-access fields. + +For publishable-key calls, both routes return a weak `ETag` scoped by the API +key, route, user, and response content. Persist the fields the UI needs, send +that exact tag in `If-None-Match`, reuse the cached snapshot on body-free `304`, +and replace it on `200`. IAPKit still makes one Convex query invocation before +`304`. Convex returns a time-independent row snapshot whose cache is invalidated +when dependent rows change, and Fly reevaluates expiry against its own current +clock on every HTTP request. Cached query results and caller-controlled +timestamps therefore cannot preserve expired access. Secret-key calls return +`Cache-Control: private, no-store` without an ETag. + +Refresh on cold start, when a foreground snapshot is stale, or after explicit +user action. Coalesce concurrent refreshes, honor `429 Retry-After`, use +jittered backoff, and fail closed after an app-defined maximum stale age. +Persist only necessary product/state/expiry fields rather than purchase tokens +when possible, key the cache by project and user, and clear it on sign-out. + +This raw HTTP contract replaces the removed outbound IAPKit-to-app event +stream. IAPKit accepts Apple ASN v2 and Google RTDN, updates canonical state, +and does not expose SSE, WebSockets, push relays, raw event feeds, or long +polling. The current `kitApi.status()` and `kitApi.entitlements()` convenience +methods remain unconditional body-only reads; use raw HTTP or an app wrapper +when response headers are required. + ### Header-authenticated product and administrative subscription routes `GET /v1/products` is a client-safe catalog read and accepts a Bearer key. diff --git a/packages/kit/public/llms.txt b/packages/kit/public/llms.txt index 843bb9a09..aba0e9e0c 100644 --- a/packages/kit/public/llms.txt +++ b/packages/kit/public/llms.txt @@ -23,6 +23,8 @@ Bearer header and out of URLs. - [GET /v1/products/{apiKey}/{productId}/client-payload?platform=IOS](https://kit.openiap.dev/docs/products) — fetch `{ clientPayload }`; returns 404 when the product is missing/Removed or no payload exists - [GET/PUT/DELETE /v1/products/client-payload/{productId}?platform=IOS](https://kit.openiap.dev/docs/products) — read durable editor state or create/update/remove a payload from CI or MCP with `Authorization: Bearer `; publishable keys receive 403 - [GET /v1/products and catalog/store-sync writes](https://openiap.dev/docs/kit-backend) — header-authenticated GET is client-safe; POST/DELETE and `/v1/products/sync/{ios|android}` require `Authorization: Bearer `; secret-key-in-path requests receive 410 +- [GET /v1/subscriptions/status?userId=...](https://kit.openiap.dev/docs/api) — publishable Bearer-key entitlement gate; save the weak `ETag`, resend it as `If-None-Match`, and reuse the persisted snapshot on body-free `304` +- [GET /v1/subscriptions/entitlements?userId=...](https://kit.openiap.dev/docs/api) — publishable Bearer-key active product snapshot with the same conditional contract; supports up to 200 indexed subscription rows and fails closed on overflow - [GET /v1/subscriptions/{list|metrics|revenue}](https://openiap.dev/docs/kit-backend) — administrative subscription data with `Authorization: Bearer ` - [POST /v1/webhooks/{apiKey}](https://openiap.dev/docs/webhooks#setup) — lifecycle webhook receiver. Paste this URL into App Store Connect (Production + Sandbox) AND Google Cloud Pub/Sub push subscription. Auto-detects ASN v2 vs Pub/Sub by payload shape. **POST-only**; opening in a browser returns 404 — that's expected. - [GET /v1/openapi](https://kit.openiap.dev/v1/openapi) — machine-readable OpenAPI spec @@ -33,6 +35,17 @@ Bearer header and out of URLs. Also mounted at `/api/v1/*` for backwards compatibility. `/v1/verify-purchase` is an alias of `/v1/purchase/verify`. Pick `/v1/purchase/verify` for new code. +Apple ASN v2 and Google RTDN update IAPKit's canonical subscription state. +IAPKit does not relay those events through SSE, WebSockets, push, or long +polling. Apps persist only the user-scoped fields they need and conditionally +refresh on cold start, stale foreground, or explicit user action. Each refresh +still performs one mutation-free indexed Convex query so an expiry with no new +webhook is detected. Respect `429 Retry-After`, coalesce concurrent refreshes, +and enforce an app-defined maximum stale age for offline fallback. Secret-key +responses are `private, no-store` and omit `ETag`. The current +`kitApi.status()` and `kitApi.entitlements()` helpers are unconditional, +body-only reads; use raw HTTP or an app wrapper to retain response headers. + ## Request shapes (discriminated on `store`) - Apple — `{ store: "apple", jws, expectedProductId?, includeClientPayload? }` where `jws` is a StoreKit 2 JWS (≤ 16 KB) diff --git a/packages/kit/server/api/v1/subscriptions.test.ts b/packages/kit/server/api/v1/subscriptions.test.ts index e5305b2ee..5ae90d1c3 100644 --- a/packages/kit/server/api/v1/subscriptions.test.ts +++ b/packages/kit/server/api/v1/subscriptions.test.ts @@ -1,5 +1,5 @@ import { Buffer } from "node:buffer"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Hono } from "hono"; const mocks = vi.hoisted(() => ({ @@ -12,6 +12,7 @@ vi.mock("@/convex", () => ({ api: { subscriptions: { query: { + subscriptionEvaluationSnapshot: "subscriptionEvaluationSnapshot", subscriptionStatus: "subscriptionStatus", entitlements: "entitlements", listSubscriptions: "listSubscriptions", @@ -55,6 +56,49 @@ function compactJwsFromRawPayload(payload: string): string { ].join("."); } +function subscriptionRow( + overrides: Partial<{ + id: string; + productId: string; + platform: "IOS" | "Android"; + state: + | "Active" + | "InGracePeriod" + | "InBillingRetry" + | "Expired" + | "Revoked" + | "Refunded" + | "Paused" + | "Unknown"; + expiresAt: number; + startedAt: number; + updatedAt: number; + createdAt: number; + purchaseToken: string; + userId: string; + }> = {}, +) { + return { + id: "subscription-1", + productId: "premium", + platform: "IOS" as const, + state: "Active" as const, + startedAt: 1, + updatedAt: 2, + createdAt: 1, + purchaseToken: "transaction-1", + userId: "user-1", + ...overrides, + }; +} + +function evaluationSnapshot( + candidates: Array> = [], + fallback: ReturnType | null = candidates[0] ?? null, +) { + return { candidates, fallback }; +} + describe("subscriptionsRoutes", () => { beforeEach(() => { mocks.handleConvexError.mockReset(); @@ -63,13 +107,17 @@ describe("subscriptionsRoutes", () => { mocks.mutation.mockReset(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it("supports Bearer-authenticated routes without keys in URLs", async () => { const app = buildApp(); const headers = { authorization: "Bearer openiap-kit_sk_admin", "content-type": "application/json", }; - mocks.query.mockResolvedValue({}); + mocks.query.mockResolvedValue(evaluationSnapshot()); mocks.mutation.mockResolvedValue({ ok: true }); const responses = [ @@ -144,12 +192,8 @@ describe("subscriptionsRoutes", () => { "content-type": "application/json", }; mocks.query - .mockResolvedValueOnce({ active: true, subscription: null }) - .mockResolvedValueOnce({ - userId: "user-1", - productIds: ["premium"], - subscriptions: [], - }); + .mockResolvedValueOnce(evaluationSnapshot()) + .mockResolvedValueOnce(evaluationSnapshot()); mocks.mutation.mockResolvedValueOnce({ ok: true, bound: true }); const responses = [ @@ -174,19 +218,364 @@ describe("subscriptionsRoutes", () => { expect(response.headers.get("x-ratelimit-limit")).toBe("600"); expect(response.headers.get("x-ratelimit-remaining")).not.toBeNull(); } - expect(mocks.query).toHaveBeenNthCalledWith(1, "subscriptionStatus", { + expect(mocks.query).toHaveBeenNthCalledWith( + 1, + "subscriptionEvaluationSnapshot", + { + apiKey: "openiap-kit_pk_mobile", + userId: "user-1", + }, + ); + expect(mocks.query).toHaveBeenNthCalledWith( + 2, + "subscriptionEvaluationSnapshot", + { + apiKey: "openiap-kit_pk_mobile", + userId: "user-1", + }, + ); + expect(mocks.mutation).toHaveBeenCalledWith("bindUser", { apiKey: "openiap-kit_pk_mobile", + purchaseToken: "purchase-token", userId: "user-1", }); - expect(mocks.query).toHaveBeenNthCalledWith(2, "entitlements", { - apiKey: "openiap-kit_pk_mobile", - userId: "user-1", + }); + + it("conditionally revalidates user-scoped snapshots without mutations", async () => { + const app = buildApp(); + const headers = { + authorization: "Bearer openiap-kit_pk_mobile", + }; + const snapshot = evaluationSnapshot([subscriptionRow()]); + mocks.query.mockResolvedValueOnce(snapshot); + + const initialStatus = await app.request( + "/subscriptions/status?userId=user-1", + { headers }, + ); + const statusEtag = initialStatus.headers.get("etag"); + + expect(initialStatus.status).toBe(200); + expect(statusEtag).toMatch(/^W\/"iapkit-subscription-status-[^"]+"$/); + expect(statusEtag).not.toContain("openiap-kit_pk_mobile"); + expect(initialStatus.headers.get("cache-control")).toBe( + "private, no-cache", + ); + expect(initialStatus.headers.get("vary")).toBe("Authorization"); + + mocks.query.mockResolvedValueOnce(snapshot); + const unchangedStatus = await app.request( + "/subscriptions/status?userId=user-1", + { + headers: { + ...headers, + "if-none-match": `"other", ${statusEtag?.replace(/^W\//, "")}`, + }, + }, + ); + + expect(unchangedStatus.status).toBe(304); + expect(unchangedStatus.headers.get("etag")).toBe(statusEtag); + expect(await unchangedStatus.text()).toBe(""); + + mocks.query.mockResolvedValueOnce(snapshot); + const initialEntitlements = await app.request( + "/subscriptions/entitlements?userId=user-1", + { headers }, + ); + const entitlementsEtag = initialEntitlements.headers.get("etag"); + + expect(initialEntitlements.status).toBe(200); + expect(entitlementsEtag).toMatch( + /^W\/"iapkit-subscription-entitlements-[^"]+"$/, + ); + expect(entitlementsEtag).not.toBe(statusEtag); + + mocks.query.mockResolvedValueOnce(snapshot); + const unchangedEntitlements = await app.request( + "/subscriptions/entitlements?userId=user-1", + { + headers: { + ...headers, + "if-none-match": entitlementsEtag!, + }, + }, + ); + + expect(unchangedEntitlements.status).toBe(304); + expect(mocks.query).toHaveBeenCalledTimes(4); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("derives status and entitlements from the sorted row snapshot at the Fly boundary", async () => { + const app = buildApp(); + vi.spyOn(Date, "now").mockReturnValue(1_000); + const expiredNewest = subscriptionRow({ + id: "expired-newest", + state: "Expired", + updatedAt: 4, }); - expect(mocks.mutation).toHaveBeenCalledWith("bindUser", { - apiKey: "openiap-kit_pk_mobile", - purchaseToken: "purchase-token", + const snapshot = evaluationSnapshot( + [ + subscriptionRow({ + id: "grace-current", + productId: "premium", + state: "InGracePeriod", + expiresAt: 2_000, + updatedAt: 3, + }), + subscriptionRow({ + id: "active-duplicate-product", + productId: "premium", + state: "Active", + updatedAt: 2, + }), + subscriptionRow({ + id: "expired-by-time", + productId: "legacy", + state: "Active", + expiresAt: 999, + updatedAt: 1, + }), + ], + expiredNewest, + ); + mocks.query.mockResolvedValue(snapshot); + const headers = { + authorization: "Bearer openiap-kit_pk_mobile", + }; + + const status = await app.request("/subscriptions/status?userId=user-1", { + headers, + }); + const entitlements = await app.request( + "/subscriptions/entitlements?userId=user-1", + { headers }, + ); + + await expect(status.json()).resolves.toMatchObject({ + active: true, + subscription: { id: "grace-current" }, + }); + await expect(entitlements.json()).resolves.toMatchObject({ userId: "user-1", + productIds: ["premium"], + subscriptions: [ + { id: "grace-current" }, + { id: "active-duplicate-product" }, + ], }); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("uses creation time to break equal-updatedAt status ties", async () => { + const app = buildApp(); + vi.spyOn(Date, "now").mockReturnValue(1_000); + mocks.query.mockResolvedValue( + evaluationSnapshot([ + subscriptionRow({ + id: "older-created", + updatedAt: 5, + createdAt: 10, + }), + subscriptionRow({ + id: "newer-created", + updatedAt: 5, + createdAt: 20, + }), + ]), + ); + + const response = await app.request("/subscriptions/status?userId=user-1", { + headers: { + authorization: "Bearer openiap-kit_pk_mobile", + }, + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toMatchObject({ + active: true, + subscription: { id: "newer-created" }, + }); + expect(body.subscription).not.toHaveProperty("createdAt"); + }); + + it("does not accept a snapshot ETag from another key, user, or route", async () => { + const app = buildApp(); + const snapshot = evaluationSnapshot(); + mocks.query.mockResolvedValue(snapshot); + + const first = await app.request("/subscriptions/status?userId=user-1", { + headers: { + authorization: "Bearer openiap-kit_pk_first", + }, + }); + const etag = first.headers.get("etag"); + expect(first.status).toBe(200); + expect(etag).not.toBeNull(); + + const otherKey = await app.request("/subscriptions/status?userId=user-1", { + headers: { + authorization: "Bearer openiap-kit_pk_second", + "if-none-match": etag!, + }, + }); + const otherUser = await app.request("/subscriptions/status?userId=user-2", { + headers: { + authorization: "Bearer openiap-kit_pk_first", + "if-none-match": etag!, + }, + }); + const otherRoute = await app.request( + "/subscriptions/entitlements?userId=user-1", + { + headers: { + authorization: "Bearer openiap-kit_pk_first", + "if-none-match": etag!, + }, + }, + ); + + expect([otherKey.status, otherUser.status, otherRoute.status]).toEqual([ + 200, 200, 200, + ]); + expect(otherKey.headers.get("etag")).not.toBe(etag); + expect(otherUser.headers.get("etag")).not.toBe(etag); + expect(otherRoute.headers.get("etag")).not.toBe(etag); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("keeps compatibility-path ETags stable for the same row snapshot", async () => { + const app = buildApp(); + mocks.query + .mockResolvedValueOnce(evaluationSnapshot()) + .mockResolvedValueOnce(evaluationSnapshot()); + + const initial = await app.request( + "/subscriptions/status/openiap-kit_pk_mobile?userId=user-1", + ); + const etag = initial.headers.get("etag"); + expect(initial.status).toBe(200); + expect(etag).not.toBeNull(); + + const unchanged = await app.request( + "/subscriptions/status/openiap-kit_pk_mobile?userId=user-1", + { + headers: { + "if-none-match": etag!, + }, + }, + ); + + expect(unchanged.status).toBe(304); + expect(unchanged.headers.get("cache-control")).toBe("private, no-cache"); + expect(unchanged.headers.get("x-ratelimit-limit")).toBe("600"); + expect(mocks.query).toHaveBeenCalledTimes(2); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("does not cache or conditionally reuse secret-key snapshot responses", async () => { + const app = buildApp(); + const snapshot = evaluationSnapshot(); + mocks.query.mockResolvedValue(snapshot); + + const response = await app.request("/subscriptions/status?userId=user-1", { + headers: { + authorization: "Bearer openiap-kit_sk_admin", + "if-none-match": "*", + }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("private, no-store"); + expect(response.headers.get("vary")).toBe("Authorization"); + expect(response.headers.get("etag")).toBeNull(); + await expect(response.json()).resolves.toEqual({ + active: false, + subscription: null, + }); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("returns a new snapshot when webhook-backed state changes", async () => { + const app = buildApp(); + const headers = { + authorization: "Bearer openiap-kit_pk_mobile", + }; + mocks.query.mockResolvedValueOnce( + evaluationSnapshot([subscriptionRow({ state: "Active", updatedAt: 1 })]), + ); + + const initial = await app.request("/subscriptions/status?userId=user-1", { + headers, + }); + const initialEtag = initial.headers.get("etag"); + + const expired = subscriptionRow({ state: "Expired", updatedAt: 2 }); + mocks.query.mockResolvedValueOnce(evaluationSnapshot([], expired)); + const changed = await app.request("/subscriptions/status?userId=user-1", { + headers: { + ...headers, + "if-none-match": initialEtag!, + }, + }); + + expect(changed.status).toBe(200); + expect(changed.headers.get("etag")).not.toBe(initialEtag); + await expect(changed.json()).resolves.toMatchObject({ + active: false, + subscription: { state: "Expired" }, + }); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + + it("reevaluates the identical cached row snapshot when time crosses expiry", async () => { + const app = buildApp(); + const expiresAt = Date.UTC(2026, 6, 28, 12, 0, 30); + const dateNow = vi.spyOn(Date, "now"); + mocks.query.mockResolvedValue( + evaluationSnapshot([subscriptionRow({ expiresAt, updatedAt: 1 })]), + ); + + dateNow.mockReturnValue(expiresAt - 1); + const initial = await app.request("/subscriptions/status?userId=user-1", { + headers: { + authorization: "Bearer openiap-kit_pk_mobile", + }, + }); + const initialEtag = initial.headers.get("etag"); + expect(initial.status).toBe(200); + await expect(initial.json()).resolves.toMatchObject({ active: true }); + + dateNow.mockReturnValue(expiresAt + 1); + const expired = await app.request("/subscriptions/status?userId=user-1", { + headers: { + authorization: "Bearer openiap-kit_pk_mobile", + "if-none-match": initialEtag!, + }, + }); + + expect(expired.status).toBe(200); + expect(expired.headers.get("etag")).not.toBe(initialEtag); + await expect(expired.json()).resolves.toMatchObject({ active: false }); + expect(mocks.query).toHaveBeenNthCalledWith( + 1, + "subscriptionEvaluationSnapshot", + { + apiKey: "openiap-kit_pk_mobile", + userId: "user-1", + }, + ); + expect(mocks.query).toHaveBeenNthCalledWith( + 2, + "subscriptionEvaluationSnapshot", + { + apiKey: "openiap-kit_pk_mobile", + userId: "user-1", + }, + ); + expect(mocks.mutation).not.toHaveBeenCalled(); }); it("requires Bearer authentication on keyless routes", async () => { @@ -722,6 +1111,33 @@ describe("subscriptionsRoutes", () => { expect(mocks.query).toHaveBeenCalledOnce(); }); + it("fails closed when a user exceeds the bounded snapshot row limit", async () => { + const app = buildApp(); + const overflowError = { + code: "ENTITLEMENT_SNAPSHOT_TOO_LARGE", + message: + "This user has more than 200 subscription rows. Contact IAPKit support before retrying.", + }; + mocks.query.mockRejectedValueOnce(new Error("convex structured error")); + mocks.handleConvexError.mockReturnValueOnce(overflowError); + + const response = await app.request( + "/subscriptions/entitlements?userId=user-1", + { + headers: { + authorization: "Bearer openiap-kit_pk_mobile", + }, + }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + errors: [overflowError], + }); + expect(mocks.query).toHaveBeenCalledOnce(); + expect(mocks.mutation).not.toHaveBeenCalled(); + }); + it("rejects publishable-key analytics before Convex access", async () => { const app = buildApp(); const scopeError = { diff --git a/packages/kit/server/api/v1/subscriptions.ts b/packages/kit/server/api/v1/subscriptions.ts index 009d4d415..d51712a45 100644 --- a/packages/kit/server/api/v1/subscriptions.ts +++ b/packages/kit/server/api/v1/subscriptions.ts @@ -1,4 +1,6 @@ +import * as crypto from "node:crypto"; import { Buffer } from "node:buffer"; +import type { FunctionReturnType } from "convex/server"; import { Hono, type Context, type Next } from "hono"; import { api } from "@/convex"; @@ -37,15 +39,13 @@ const MAX_PRODUCT_ID_LENGTH = 256; const MAX_BIND_USER_BODY_BYTES = 32 * 1024; const INVALID_APPLE_JWS_PURCHASE_TOKEN_MESSAGE = "purchaseToken must be a valid Apple JWS containing originalTransactionId or transactionId"; -type SubscriptionState = - | "Active" - | "InGracePeriod" - | "InBillingRetry" - | "Expired" - | "Revoked" - | "Refunded" - | "Paused" - | "Unknown"; +type SubscriptionEvaluationSnapshot = FunctionReturnType< + typeof api.subscriptions.query.subscriptionEvaluationSnapshot +>; +type SubscriptionSnapshotRow = + SubscriptionEvaluationSnapshot["candidates"][number]; +type PublicSubscriptionSnapshotRow = Omit; +type SubscriptionState = SubscriptionSnapshotRow["state"]; const SUBSCRIPTION_STATES = new Set([ "Active", "InGracePeriod", @@ -78,14 +78,17 @@ async function handleSubscriptionStatus(c: Context, apiKey: string) { return invalidInput(c, "userId must be ≤ 256 chars"); } try { - const result = await client.query( - api.subscriptions.query.subscriptionStatus, - { - apiKey, - userId, - }, + const snapshot = await client.query( + api.subscriptions.query.subscriptionEvaluationSnapshot, + { apiKey, userId }, ); - return c.json(result); + const result = evaluateSubscriptionStatus(snapshot, Date.now()); + return conditionalSubscriptionSnapshot(c, { + apiKey, + kind: "status", + userId, + result, + }); } catch (error) { return subscriptionRouteError( c, @@ -112,11 +115,21 @@ async function handleEntitlements(c: Context, apiKey: string) { return invalidInput(c, "userId must be ≤ 256 chars"); } try { - const result = await client.query(api.subscriptions.query.entitlements, { + const snapshot = await client.query( + api.subscriptions.query.subscriptionEvaluationSnapshot, + { apiKey, userId }, + ); + const result = evaluateEntitlements( + userId, + snapshot.candidates, + Date.now(), + ); + return conditionalSubscriptionSnapshot(c, { apiKey, + kind: "entitlements", userId, + result, }); - return c.json(result); } catch (error) { return subscriptionRouteError( c, @@ -457,6 +470,171 @@ function isNonBlankString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } +function conditionalSubscriptionSnapshot( + c: Context, + args: { + apiKey: string; + kind: "status" | "entitlements"; + userId: string; + result: unknown; + }, +): Response { + // These app-readable snapshots are user- and project-scoped. A device cache + // may retain them, but every reuse must be revalidated with IAPKit so an + // inbound store webhook or time-based expiration can change the response. + c.header("Vary", "Authorization"); + if (isSecretApiKey(args.apiKey)) { + c.header("Cache-Control", "private, no-store"); + return c.json(args.result); + } + c.header("Cache-Control", "private, no-cache"); + + const etag = subscriptionSnapshotEtag(args); + c.header("ETag", etag); + if (ifNoneMatchIncludes(c.req.header("if-none-match"), etag)) { + return c.body(null, 304); + } + + return c.json(args.result); +} + +function evaluateSubscriptionStatus( + snapshot: SubscriptionEvaluationSnapshot, + now: number, +): { + active: boolean; + subscription: PublicSubscriptionSnapshotRow | null; +} { + const activeSubscriptions = snapshot.candidates.filter((subscription) => + isActiveSubscription(subscription, now), + ); + const selected = selectMostRecentlyUpdatedSnapshot(activeSubscriptions); + return { + active: selected !== null, + subscription: toPublicSubscriptionRow(selected ?? snapshot.fallback), + }; +} + +function evaluateEntitlements( + userId: string, + subscriptions: SubscriptionSnapshotRow[], + now: number, +): { + userId: string; + productIds: string[]; + subscriptions: PublicSubscriptionSnapshotRow[]; +} { + const active = subscriptions.filter((subscription) => + isActiveSubscription(subscription, now), + ); + return { + userId, + productIds: Array.from( + new Set(active.map((subscription) => subscription.productId)), + ), + subscriptions: active.map((subscription) => + toPublicSubscriptionRow(subscription), + ), + }; +} + +function selectMostRecentlyUpdatedSnapshot( + subscriptions: readonly SubscriptionSnapshotRow[], +): SubscriptionSnapshotRow | null { + let selected: SubscriptionSnapshotRow | null = null; + for (const subscription of subscriptions) { + if ( + selected === null || + subscription.updatedAt > selected.updatedAt || + (subscription.updatedAt === selected.updatedAt && + subscription.createdAt > selected.createdAt) + ) { + selected = subscription; + } + } + return selected; +} + +function toPublicSubscriptionRow( + subscription: SubscriptionSnapshotRow, +): PublicSubscriptionSnapshotRow; +function toPublicSubscriptionRow(subscription: null): null; +function toPublicSubscriptionRow( + subscription: SubscriptionSnapshotRow | null, +): PublicSubscriptionSnapshotRow | null; +function toPublicSubscriptionRow( + subscription: SubscriptionSnapshotRow | null, +): PublicSubscriptionSnapshotRow | null { + if (subscription === null) return null; + const { createdAt: _createdAt, ...publicSubscription } = subscription; + return publicSubscription; +} + +function isActiveSubscription( + subscription: SubscriptionSnapshotRow, + now: number, +): boolean { + const entitled = + subscription.state === "Active" || subscription.state === "InGracePeriod"; + if (!entitled) return false; + return subscription.expiresAt == null || subscription.expiresAt > now; +} + +function subscriptionSnapshotEtag(args: { + apiKey: string; + kind: "status" | "entitlements"; + userId: string; + result: unknown; +}): string { + const digest = crypto + .createHash("sha256") + .update(stableJson(args)) + .digest("base64url") + .slice(0, 32); + return `W/"iapkit-subscription-${args.kind}-${digest}"`; +} + +function stableJson(value: unknown): string { + return JSON.stringify(canonicalizeForEtag(value)); +} + +function canonicalizeForEtag(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalizeForEtag); + } + if (!isJsonObject(value)) { + return value; + } + + const canonical: Record = {}; + for (const key of Object.keys(value).sort()) { + const item = value[key]; + if (item !== undefined) { + canonical[key] = canonicalizeForEtag(item); + } + } + return canonical; +} + +function ifNoneMatchIncludes( + ifNoneMatch: string | undefined, + etag: string, +): boolean { + if (!ifNoneMatch) return false; + const normalizedEtag = normalizeEtagForWeakComparison(etag); + return ifNoneMatch.split(",").some((candidate) => { + const normalized = candidate.trim(); + return ( + normalized === "*" || + normalizeEtagForWeakComparison(normalized) === normalizedEtag + ); + }); +} + +function normalizeEtagForWeakComparison(etag: string): string { + return etag.replace(/^W\//, ""); +} + function isIsoDay(value: unknown): value is string { return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value); } diff --git a/packages/kit/src/pages/docs/nav.ts b/packages/kit/src/pages/docs/nav.ts index 59e60dd56..b75a9b204 100644 --- a/packages/kit/src/pages/docs/nav.ts +++ b/packages/kit/src/pages/docs/nav.ts @@ -60,7 +60,8 @@ export const DOCS_NAV: DocsNavEntry[] = [ { slug: "api", title: "API reference", - summary: "POST /v1/purchase/verify — request shapes, responses, errors.", + summary: + "Purchase verification, user-scoped subscription snapshots, responses, and errors.", }, { slug: "analytics", diff --git a/packages/kit/src/pages/docs/sections/api.tsx b/packages/kit/src/pages/docs/sections/api.tsx index 98208b845..c8f9c186f 100644 --- a/packages/kit/src/pages/docs/sections/api.tsx +++ b/packages/kit/src/pages/docs/sections/api.tsx @@ -9,7 +9,7 @@ export default function ApiReferencePage() {

    IAPKit exposes one core purchase-verification endpoint for your app:{" "} @@ -196,6 +196,201 @@ export default function ApiReferencePage() { state: "INAUTHENTIC" on mismatch.

    +

    + Refresh access without SSE +

    +

    + Apple and Google lifecycle webhooks update IAPKit's canonical + subscription snapshot. IAPKit does not relay those events, expose a raw + event feed, or keep a mobile SSE, WebSocket, or long-poll connection + open. Apps read only their user-scoped snapshot: +

    +

    + Webhook event rows are bounded operational history for deduplication and + retention. Polling reads the canonical snapshot, not an event cursor, so + pruning old event rows does not erase the user's latest state and + apps do not have to consume every event in order. +

    +
      +
    1. + For a purchase started in this app session, update the UI from the SDK + purchase callback and verified result. Bind its stable token to an + opaque user ID. +
    2. +
    3. Render immediately from the last persisted snapshot.
    4. +
    5. + Refresh on cold start, when the cached value is stale after + foregrounding, or after an explicit user action. Use one refresh + coordinator so concurrent screens share the same in-flight request. + Define a maximum stale age for offline or rate-limited fallback and + fail closed after that app-specific window. +
    6. +
    7. + Persist the response body and ETag, then send the tag as{" "} + If-None-Match on the next request. +
    8. +
    9. + Reuse the cached body on 304, replace it on{" "} + 200, and respect 429 Retry-After. +
    10. +
    11. + Key local storage by IAPKit project and opaque user ID, and clear it + on sign-out. Never render one user's snapshot for another. +
    12. +
    +

    + This example uses raw HTTP so it can retain response headers. The + current kitApi.status() and{" "} + kitApi.entitlements() convenience methods perform + unconditional reads and return only the decoded body; use raw{" "} + fetch or an app wrapper for conditional revalidation. +

    + + {`type CachedEntitlements = { + cacheScope: string; // App-defined IAPKit project/environment identifier. + etag: string | null; + checkedAt: number; + snapshot: { + userId: string; + productIds: string[]; + subscriptions: Array<{ + productId: string; + state: string; + expiresAt?: number; + renewsAt?: number; + willRenew?: boolean; + }>; + }; +}; + +async function refreshEntitlements( + iapkitCacheScope: string, + userId: string, + cached: CachedEntitlements | null, + maxStaleMs: number, + iapkitPublishableKey: string, +) { + const cachedForScope = + cached?.cacheScope === iapkitCacheScope && + cached.snapshot.userId === userId + ? cached + : null; + const canUseCachedSnapshot = () => { + if ( + cachedForScope === null || + !Number.isFinite(cachedForScope.checkedAt) || + !Number.isFinite(maxStaleMs) || + maxStaleMs < 0 + ) { + return false; + } + const cacheAgeMs = Date.now() - cachedForScope.checkedAt; + return cacheAgeMs >= 0 && cacheAgeMs <= maxStaleMs; + }; + let response: Response; + try { + response = await fetch( + \`https://kit.openiap.dev/v1/subscriptions/entitlements?userId=\${encodeURIComponent(userId)}\`, + { + headers: { + Authorization: \`Bearer \${iapkitPublishableKey}\`, + ...(cachedForScope?.etag + ? { "If-None-Match": cachedForScope.etag } + : {}), + }, + }, + ); + } catch (error) { + if (cachedForScope && canUseCachedSnapshot()) { + return cachedForScope.snapshot; + } + throw error; + } + + if (response.status === 304 && cachedForScope) { + const refreshed = { ...cachedForScope, checkedAt: Date.now() }; + await persistSnapshot(refreshed); + return refreshed.snapshot; + } + if (response.status === 429) { + scheduleRetry(response.headers.get("Retry-After")); + if (cachedForScope && canUseCachedSnapshot()) { + return cachedForScope.snapshot; + } + throw new Error("Entitlement refresh is rate limited"); + } + if (!response.ok) throw new Error("Entitlement refresh failed"); + + const wireSnapshot = (await response.json()) as { + userId: string; + productIds: string[]; + subscriptions: Array<{ + productId: string; + state: string; + expiresAt?: number; + renewsAt?: number; + willRenew?: boolean; + }>; + }; + const snapshot = { + userId: wireSnapshot.userId, + productIds: wireSnapshot.productIds, + subscriptions: wireSnapshot.subscriptions.map( + ({ productId, state, expiresAt, renewsAt, willRenew }) => ({ + productId, + state, + expiresAt, + renewsAt, + willRenew, + }), + ), + }; + await persistSnapshot({ + cacheScope: iapkitCacheScope, + snapshot, + etag: response.headers.get("ETag"), + checkedAt: Date.now(), + }); + return snapshot; +}`} + + +

    + IAPKit still performs one Convex query invocation before returning{" "} + 304. Convex returns a time-independent row snapshot and + invalidates its cached result when a dependent row changes. Fly then + evaluates expiry against its own current clock on every HTTP request. + Cached query results and caller-controlled timestamps therefore cannot + preserve expired access, and a time-only transition is detected on the + next refresh. Conditional requests save response bandwidth and + app-side state work; they do not justify a continuous timer. A normal + client refreshes only at lifecycle boundaries and uses jittered + backoff after failures. +

    +

    + A user snapshot supports up to 200 subscription rows. IAPKit reads one + additional indexed row only to detect overflow and returns{" "} + 400 ENTITLEMENT_SNAPSHOT_TOO_LARGE instead of exposing a + partial entitlement set. +

    +
    + +

    + A mobile snapshot is suitable for app UI and local feature gating. If + paid content or an API is protected by your own backend, authenticate + the user there and let that backend make the entitlement decision. A + developer-owned backend also owns any optional APNs or FCM push. +

    +

    + Persist only the fields the UI needs. Avoid retaining purchase tokens + in general-purpose local storage when product IDs, states, and expiry + times are sufficient. +

    +
    +

    Subscription identity fields

    diff --git a/packages/kit/src/pages/docs/sections/quickstart.tsx b/packages/kit/src/pages/docs/sections/quickstart.tsx index 2d6e7c81e..016019a75 100644 --- a/packages/kit/src/pages/docs/sections/quickstart.tsx +++ b/packages/kit/src/pages/docs/sections/quickstart.tsx @@ -107,6 +107,16 @@ export default function QuickstartPage() { When clients call status or entitlements directly, use opaque app-scoped user IDs rather than public identifiers like email addresses.

    +

    + IAPKit does not stream store events back to apps. Cache the latest + user-scoped status or entitlement response and conditionally refresh it + on cold start, when it is stale after foregrounding, or after an + explicit user action. Coalesce concurrent refreshes through one + coordinator. Send its ETag as If-None-Match;{" "} + 304 reuses the cached snapshot, while 200{" "} + replaces it. Define a maximum stale age for offline fallback and avoid + continuous polling. +

    A publishable key lets your app call IAPKit's restricted managed diff --git a/packages/kit/src/pages/docs/sections/release-notes.tsx b/packages/kit/src/pages/docs/sections/release-notes.tsx index ef67a31c4..c79a6e756 100644 --- a/packages/kit/src/pages/docs/sections/release-notes.tsx +++ b/packages/kit/src/pages/docs/sections/release-notes.tsx @@ -26,6 +26,26 @@ const KIND_STYLES: Record = { }; const RELEASES: ReleaseEntry[] = [ + { + id: "hosted-2026-07-28", + date: "2026-07-28", + tagline: + "Conditional entitlement snapshots replace continuous app event streams.", + items: [ + { + kind: "feature", + text: "Publishable-key subscription status and entitlement reads now return API-key, route, user, and content-scoped ETags. Matching If-None-Match requests return a body-free 304 after Convex supplies a database-invalidated row snapshot and Fly reevaluates expiry with its own current clock; secret-key responses remain no-store without an ETag.", + }, + { + kind: "ops", + text: "Snapshot reads remain mutation-free, use the project-user-updated index, support 200 subscription rows with one bounded overflow probe, and fail closed instead of returning partial entitlements. Existing API-key, source-IP, and process rate limits still run before Convex.", + }, + { + kind: "docs", + text: "The supported replacement for the removed outbound SSE feature is persisted snapshot state plus raw-HTTP conditional refresh on cold start, stale foreground, or explicit user action—not a raw webhook feed, WebSocket, long poll, or continuous timer. Offline fallback must have an app-defined maximum stale age.", + }, + ], + }, { id: "hosted-2026-07-25", date: "2026-07-25",