From a8b4e7ad24c6588cb56c0e1828441c739323f786 Mon Sep 17 00:00:00 2001
From: Hyo
Date: Tue, 28 Jul 2026 07:42:04 +0900
Subject: [PATCH 1/4] feat(kit): add conditional entitlement refresh
Expose bounded user-scoped subscription snapshots with conditional ETag responses for cold-start, stale-foreground, and explicit refresh flows.\n\nDocument persistent client caching, rate-limit handling, expiry evaluation, and the hosted-only rollout while preserving inbound store webhooks and keeping outbound event streams removed.
---
packages/docs/public/llms-full.txt | 27 +-
packages/docs/public/llms.txt | 16 +-
packages/docs/src/pages/docs/kit-backend.tsx | 193 ++++++++-
.../docs/src/pages/docs/updates/releases.tsx | 88 ++++
packages/kit/COST-SAFETY.md | 52 ++-
packages/kit/README.md | 59 +++
packages/kit/convex/schema.ts | 5 +
.../kit/convex/subscriptions/query.test.ts | 79 +++-
packages/kit/convex/subscriptions/query.ts | 88 +++-
packages/kit/public/llms-full.txt | 38 ++
packages/kit/public/llms.txt | 13 +
.../kit/server/api/v1/subscriptions.test.ts | 409 +++++++++++++++++-
packages/kit/server/api/v1/subscriptions.ts | 183 +++++++-
packages/kit/src/pages/docs/nav.ts | 3 +-
packages/kit/src/pages/docs/sections/api.tsx | 160 ++++++-
.../src/pages/docs/sections/quickstart.tsx | 10 +
.../src/pages/docs/sections/release-notes.tsx | 20 +
17 files changed, 1375 insertions(+), 68 deletions(-)
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..c164f30b5 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,183 @@ 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.
+
+
+ -
+ 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.
+
+ - Render from a persisted status or entitlement snapshot.
+ -
+ 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.
+
+ -
+ Save the response
ETag and send it as{' '}
+ If-None-Match next time. Reuse the cached body on{' '}
+ 304; replace it on 200.
+
+ -
+ Respect
429 Retry-After and use jittered backoff after
+ network failures.
+
+ -
+ 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.
+
+
+
+ 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 = {
+ 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(
+ userId: string,
+ cached: CachedEntitlements | null,
+ maxStaleMs: number,
+) {
+ const canUseCachedSnapshot = () => {
+ if (
+ cached === null ||
+ !Number.isFinite(cached.checkedAt) ||
+ !Number.isFinite(maxStaleMs) ||
+ maxStaleMs < 0
+ ) {
+ return false;
+ }
+ const cacheAgeMs = Date.now() - cached.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}\`,
+ ...(cached?.etag ? { 'If-None-Match': cached.etag } : {}),
+ },
+ },
+ );
+ } catch (error) {
+ if (cached && canUseCachedSnapshot()) return cached.snapshot;
+ throw error;
+ }
+
+ if (response.status === 304 && cached) {
+ const refreshed = { ...cached, checkedAt: Date.now() };
+ await persistEntitlements(refreshed);
+ return refreshed.snapshot;
+ }
+ if (response.status === 429) {
+ scheduleRetry(response.headers.get('Retry-After'));
+ if (cached && canUseCachedSnapshot()) return cached.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({
+ 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..69df20b9b 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,73 @@ 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.fallback?.purchaseToken).toBe("latest-token");
+ 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..b44eccbed 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 {
@@ -42,14 +42,58 @@ const subscriptionShape = v.object({
userId: v.optional(v.string()),
});
type SubscriptionRow = 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: SubscriptionRow[];
+ fallback: SubscriptionRow | null;
+} {
+ const fallback = selectMostRecentlyUpdatedSubscription(rows);
+ return {
+ candidates: rows.filter(isEntitledState).map(shapeSubscriptionRow),
+ fallback: fallback ? shapeSubscriptionRow(fallback) : null,
+ };
+}
+
export function shapeSubscriptionRow(
sub: Doc<"subscriptions">,
): SubscriptionRow {
@@ -162,6 +206,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(subscriptionShape),
+ fallback: v.union(subscriptionShape, 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 +244,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 +277,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..15ecab2ab 100644
--- a/packages/kit/server/api/v1/subscriptions.test.ts
+++ b/packages/kit/server/api/v1/subscriptions.test.ts
@@ -12,6 +12,7 @@ vi.mock("@/convex", () => ({
api: {
subscriptions: {
query: {
+ subscriptionEvaluationSnapshot: "subscriptionEvaluationSnapshot",
subscriptionStatus: "subscriptionStatus",
entitlements: "entitlements",
listSubscriptions: "listSubscriptions",
@@ -55,6 +56,47 @@ 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;
+ purchaseToken: string;
+ userId: string;
+ }> = {},
+) {
+ return {
+ id: "subscription-1",
+ productId: "premium",
+ platform: "IOS" as const,
+ state: "Active" as const,
+ startedAt: 1,
+ updatedAt: 2,
+ 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();
@@ -69,7 +111,7 @@ describe("subscriptionsRoutes", () => {
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 +186,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 +212,335 @@ 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();
+ const dateNow = 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();
+
+ dateNow.mockRestore();
+ });
+
+ 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();
+
+ dateNow.mockRestore();
});
it("requires Bearer authentication on keyless routes", async () => {
@@ -722,6 +1076,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..efcc0c8ac 100644
--- a/packages/kit/server/api/v1/subscriptions.ts
+++ b/packages/kit/server/api/v1/subscriptions.ts
@@ -1,3 +1,4 @@
+import * as crypto from "node:crypto";
import { Buffer } from "node:buffer";
import { Hono, type Context, type Next } from "hono";
@@ -46,6 +47,27 @@ type SubscriptionState =
| "Refunded"
| "Paused"
| "Unknown";
+type SubscriptionSnapshotRow = {
+ id: string;
+ productId: string;
+ platform: "IOS" | "Android";
+ state: SubscriptionState;
+ expiresAt?: number;
+ renewsAt?: number;
+ willRenew?: boolean;
+ cancellationReason?: string;
+ currency?: string;
+ priceAmountMicros?: number;
+ startedAt: number;
+ updatedAt: number;
+ purchaseToken: string;
+ originalTransactionId?: string;
+ userId?: string;
+};
+type SubscriptionEvaluationSnapshot = {
+ candidates: SubscriptionSnapshotRow[];
+ fallback: SubscriptionSnapshotRow | null;
+};
const SUBSCRIPTION_STATES = new Set([
"Active",
"InGracePeriod",
@@ -78,14 +100,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 +137,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 +492,136 @@ 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: SubscriptionSnapshotRow | null;
+} {
+ const activeSubscriptions = snapshot.candidates.filter((subscription) =>
+ isActiveSubscription(subscription, now),
+ );
+ return {
+ active: activeSubscriptions.length > 0,
+ subscription: activeSubscriptions[0] ?? snapshot.fallback,
+ };
+}
+
+function evaluateEntitlements(
+ userId: string,
+ subscriptions: SubscriptionSnapshotRow[],
+ now: number,
+): {
+ userId: string;
+ productIds: string[];
+ subscriptions: SubscriptionSnapshotRow[];
+} {
+ const active = subscriptions.filter((subscription) =>
+ isActiveSubscription(subscription, now),
+ );
+ return {
+ userId,
+ productIds: Array.from(
+ new Set(active.map((subscription) => subscription.productId)),
+ ),
+ subscriptions: active,
+ };
+}
+
+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..2e19daa02 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,164 @@ 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.
+
+
+ -
+ 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.
+
+ - Render immediately from the last persisted snapshot.
+ -
+ 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.
+
+ -
+ Persist the response body and
ETag, then send the tag as{" "}
+ If-None-Match on the next request.
+
+ -
+ Reuse the cached body on
304, replace it on{" "}
+ 200, and respect 429 Retry-After.
+
+ -
+ Key local storage by IAPKit project and opaque user ID, and clear it
+ on sign-out. Never render one user's snapshot for another.
+
+
+
+ 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.
+
+
+ {`const canUseCachedSnapshot = () => {
+ if (
+ cached === null ||
+ !Number.isFinite(cached.checkedAt) ||
+ !Number.isFinite(maxStaleMs) ||
+ maxStaleMs < 0
+ ) {
+ return false;
+ }
+ const cacheAgeMs = Date.now() - cached.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}\`,
+ ...(cached?.etag ? { "If-None-Match": cached.etag } : {}),
+ },
+ },
+ );
+} catch (error) {
+ if (cached && canUseCachedSnapshot()) return cached.snapshot;
+ throw error;
+}
+
+if (response.status === 304 && cached) {
+ const refreshed = { ...cached, checkedAt: Date.now() };
+ await persistSnapshot(refreshed);
+ return refreshed.snapshot;
+}
+if (response.status === 429) {
+ scheduleRetry(response.headers.get("Retry-After"));
+ if (cached && canUseCachedSnapshot()) return cached.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({
+ 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",
From 1413729b0fd2b1aae8a77625825b79d34ffd3e33 Mon Sep 17 00:00:00 2001
From: Hyo
Date: Tue, 28 Jul 2026 08:00:30 +0900
Subject: [PATCH 2/4] fix(kit): stabilize conditional subscription snapshots
---
packages/docs/src/pages/docs/kit-backend.tsx | 1 +
.../kit/convex/subscriptions/query.test.ts | 2 +
packages/kit/convex/subscriptions/query.ts | 31 +++-
.../kit/server/api/v1/subscriptions.test.ts | 45 ++++-
packages/kit/server/api/v1/subscriptions.ts | 83 ++++++----
packages/kit/src/pages/docs/sections/api.tsx | 155 ++++++++++--------
6 files changed, 204 insertions(+), 113 deletions(-)
diff --git a/packages/docs/src/pages/docs/kit-backend.tsx b/packages/docs/src/pages/docs/kit-backend.tsx
index c164f30b5..3eea82908 100644
--- a/packages/docs/src/pages/docs/kit-backend.tsx
+++ b/packages/docs/src/pages/docs/kit-backend.tsx
@@ -622,6 +622,7 @@ async function refreshEntitlements(
userId: string,
cached: CachedEntitlements | null,
maxStaleMs: number,
+ iapkitPublishableKey: string,
) {
const canUseCachedSnapshot = () => {
if (
diff --git a/packages/kit/convex/subscriptions/query.test.ts b/packages/kit/convex/subscriptions/query.test.ts
index 69df20b9b..5d676a306 100644
--- a/packages/kit/convex/subscriptions/query.test.ts
+++ b/packages/kit/convex/subscriptions/query.test.ts
@@ -175,7 +175,9 @@ describe("shapeSubscriptionEvaluationSnapshot", () => {
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 b44eccbed..9d5965d27 100644
--- a/packages/kit/convex/subscriptions/query.ts
+++ b/packages/kit/convex/subscriptions/query.ts
@@ -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,8 +40,14 @@ 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 {
@@ -84,13 +90,24 @@ async function userSubscriptionRows(
export function shapeSubscriptionEvaluationSnapshot(
rows: Array>,
): {
- candidates: SubscriptionRow[];
- fallback: SubscriptionRow | null;
+ candidates: SubscriptionEvaluationRow[];
+ fallback: SubscriptionEvaluationRow | null;
} {
const fallback = selectMostRecentlyUpdatedSubscription(rows);
return {
- candidates: rows.filter(isEntitledState).map(shapeSubscriptionRow),
- fallback: fallback ? shapeSubscriptionRow(fallback) : null,
+ candidates: rows
+ .filter(isEntitledState)
+ .map(shapeSubscriptionEvaluationRow),
+ fallback: fallback ? shapeSubscriptionEvaluationRow(fallback) : null,
+ };
+}
+
+function shapeSubscriptionEvaluationRow(
+ sub: Doc<"subscriptions">,
+): SubscriptionEvaluationRow {
+ return {
+ ...shapeSubscriptionRow(sub),
+ createdAt: sub._creationTime,
};
}
@@ -218,8 +235,8 @@ export const subscriptionEvaluationSnapshot = query({
userId: v.string(),
},
returns: v.object({
- candidates: v.array(subscriptionShape),
- fallback: v.union(subscriptionShape, v.null()),
+ candidates: v.array(subscriptionEvaluationRowShape),
+ fallback: v.union(subscriptionEvaluationRowShape, v.null()),
}),
handler: async (ctx, args) => {
const project = await projectByApiKey(ctx, args.apiKey);
diff --git a/packages/kit/server/api/v1/subscriptions.test.ts b/packages/kit/server/api/v1/subscriptions.test.ts
index 15ecab2ab..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(() => ({
@@ -73,6 +73,7 @@ function subscriptionRow(
expiresAt: number;
startedAt: number;
updatedAt: number;
+ createdAt: number;
purchaseToken: string;
userId: string;
}> = {},
@@ -84,6 +85,7 @@ function subscriptionRow(
state: "Active" as const,
startedAt: 1,
updatedAt: 2,
+ createdAt: 1,
purchaseToken: "transaction-1",
userId: "user-1",
...overrides,
@@ -105,6 +107,10 @@ describe("subscriptionsRoutes", () => {
mocks.mutation.mockReset();
});
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
it("supports Bearer-authenticated routes without keys in URLs", async () => {
const app = buildApp();
const headers = {
@@ -303,7 +309,7 @@ describe("subscriptionsRoutes", () => {
it("derives status and entitlements from the sorted row snapshot at the Fly boundary", async () => {
const app = buildApp();
- const dateNow = vi.spyOn(Date, "now").mockReturnValue(1_000);
+ vi.spyOn(Date, "now").mockReturnValue(1_000);
const expiredNewest = subscriptionRow({
id: "expired-newest",
state: "Expired",
@@ -360,8 +366,39 @@ describe("subscriptionsRoutes", () => {
],
});
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,
+ }),
+ ]),
+ );
- dateNow.mockRestore();
+ 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 () => {
@@ -539,8 +576,6 @@ describe("subscriptionsRoutes", () => {
},
);
expect(mocks.mutation).not.toHaveBeenCalled();
-
- dateNow.mockRestore();
});
it("requires Bearer authentication on keyless routes", async () => {
diff --git a/packages/kit/server/api/v1/subscriptions.ts b/packages/kit/server/api/v1/subscriptions.ts
index efcc0c8ac..d51712a45 100644
--- a/packages/kit/server/api/v1/subscriptions.ts
+++ b/packages/kit/server/api/v1/subscriptions.ts
@@ -1,5 +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";
@@ -38,36 +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 SubscriptionSnapshotRow = {
- id: string;
- productId: string;
- platform: "IOS" | "Android";
- state: SubscriptionState;
- expiresAt?: number;
- renewsAt?: number;
- willRenew?: boolean;
- cancellationReason?: string;
- currency?: string;
- priceAmountMicros?: number;
- startedAt: number;
- updatedAt: number;
- purchaseToken: string;
- originalTransactionId?: string;
- userId?: string;
-};
-type SubscriptionEvaluationSnapshot = {
- candidates: SubscriptionSnapshotRow[];
- fallback: SubscriptionSnapshotRow | null;
-};
+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",
@@ -525,14 +503,15 @@ function evaluateSubscriptionStatus(
now: number,
): {
active: boolean;
- subscription: SubscriptionSnapshotRow | null;
+ subscription: PublicSubscriptionSnapshotRow | null;
} {
const activeSubscriptions = snapshot.candidates.filter((subscription) =>
isActiveSubscription(subscription, now),
);
+ const selected = selectMostRecentlyUpdatedSnapshot(activeSubscriptions);
return {
- active: activeSubscriptions.length > 0,
- subscription: activeSubscriptions[0] ?? snapshot.fallback,
+ active: selected !== null,
+ subscription: toPublicSubscriptionRow(selected ?? snapshot.fallback),
};
}
@@ -543,7 +522,7 @@ function evaluateEntitlements(
): {
userId: string;
productIds: string[];
- subscriptions: SubscriptionSnapshotRow[];
+ subscriptions: PublicSubscriptionSnapshotRow[];
} {
const active = subscriptions.filter((subscription) =>
isActiveSubscription(subscription, now),
@@ -553,10 +532,44 @@ function evaluateEntitlements(
productIds: Array.from(
new Set(active.map((subscription) => subscription.productId)),
),
- subscriptions: active,
+ 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,
diff --git a/packages/kit/src/pages/docs/sections/api.tsx b/packages/kit/src/pages/docs/sections/api.tsx
index 2e19daa02..8262cf333 100644
--- a/packages/kit/src/pages/docs/sections/api.tsx
+++ b/packages/kit/src/pages/docs/sections/api.tsx
@@ -246,76 +246,99 @@ export default function ApiReferencePage() {
fetch or an app wrapper for conditional revalidation.
- {`const canUseCachedSnapshot = () => {
- if (
- cached === null ||
- !Number.isFinite(cached.checkedAt) ||
- !Number.isFinite(maxStaleMs) ||
- maxStaleMs < 0
- ) {
- return false;
- }
- const cacheAgeMs = Date.now() - cached.checkedAt;
- return cacheAgeMs >= 0 && cacheAgeMs <= maxStaleMs;
+ {`type CachedEntitlements = {
+ etag: string | null;
+ checkedAt: number;
+ snapshot: {
+ userId: string;
+ productIds: string[];
+ subscriptions: Array<{
+ productId: string;
+ state: string;
+ expiresAt?: number;
+ renewsAt?: number;
+ willRenew?: boolean;
+ }>;
+ };
};
-let response: Response;
-try {
- response = await fetch(
- \`https://kit.openiap.dev/v1/subscriptions/entitlements?userId=\${encodeURIComponent(userId)}\`,
- {
- headers: {
- Authorization: \`Bearer \${iapkitPublishableKey}\`,
- ...(cached?.etag ? { "If-None-Match": cached.etag } : {}),
+
+async function refreshEntitlements(
+ userId: string,
+ cached: CachedEntitlements | null,
+ maxStaleMs: number,
+ iapkitPublishableKey: string,
+) {
+ const canUseCachedSnapshot = () => {
+ if (
+ cached === null ||
+ !Number.isFinite(cached.checkedAt) ||
+ !Number.isFinite(maxStaleMs) ||
+ maxStaleMs < 0
+ ) {
+ return false;
+ }
+ const cacheAgeMs = Date.now() - cached.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}\`,
+ ...(cached?.etag ? { "If-None-Match": cached.etag } : {}),
+ },
},
- },
- );
-} catch (error) {
- if (cached && canUseCachedSnapshot()) return cached.snapshot;
- throw error;
-}
+ );
+ } catch (error) {
+ if (cached && canUseCachedSnapshot()) return cached.snapshot;
+ throw error;
+ }
-if (response.status === 304 && cached) {
- const refreshed = { ...cached, checkedAt: Date.now() };
- await persistSnapshot(refreshed);
- return refreshed.snapshot;
-}
-if (response.status === 429) {
- scheduleRetry(response.headers.get("Retry-After"));
- if (cached && canUseCachedSnapshot()) return cached.snapshot;
- throw new Error("Entitlement refresh is rate limited");
-}
-if (!response.ok) throw new Error("Entitlement refresh failed");
+ if (response.status === 304 && cached) {
+ const refreshed = { ...cached, checkedAt: Date.now() };
+ await persistSnapshot(refreshed);
+ return refreshed.snapshot;
+ }
+ if (response.status === 429) {
+ scheduleRetry(response.headers.get("Retry-After"));
+ if (cached && canUseCachedSnapshot()) return cached.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({
- snapshot,
- etag: response.headers.get("ETag"),
- checkedAt: Date.now(),
-});
-return snapshot;`}
+ 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({
+ snapshot,
+ etag: response.headers.get("ETag"),
+ checkedAt: Date.now(),
+ });
+ return snapshot;
+}`}
From 484d01387200b8d4408c914837e972807dfedaed Mon Sep 17 00:00:00 2001
From: Hyo
Date: Tue, 28 Jul 2026 08:10:45 +0900
Subject: [PATCH 3/4] docs(kit): isolate cached entitlement users
---
packages/docs/src/pages/docs/kit-backend.tsx | 24 +++++++++++++-------
packages/kit/src/pages/docs/sections/api.tsx | 24 +++++++++++++-------
2 files changed, 32 insertions(+), 16 deletions(-)
diff --git a/packages/docs/src/pages/docs/kit-backend.tsx b/packages/docs/src/pages/docs/kit-backend.tsx
index 3eea82908..398c6dfb0 100644
--- a/packages/docs/src/pages/docs/kit-backend.tsx
+++ b/packages/docs/src/pages/docs/kit-backend.tsx
@@ -624,16 +624,18 @@ async function refreshEntitlements(
maxStaleMs: number,
iapkitPublishableKey: string,
) {
+ const cachedForUser =
+ cached?.snapshot.userId === userId ? cached : null;
const canUseCachedSnapshot = () => {
if (
- cached === null ||
- !Number.isFinite(cached.checkedAt) ||
+ cachedForUser === null ||
+ !Number.isFinite(cachedForUser.checkedAt) ||
!Number.isFinite(maxStaleMs) ||
maxStaleMs < 0
) {
return false;
}
- const cacheAgeMs = Date.now() - cached.checkedAt;
+ const cacheAgeMs = Date.now() - cachedForUser.checkedAt;
return cacheAgeMs >= 0 && cacheAgeMs <= maxStaleMs;
};
let response: Response;
@@ -643,23 +645,29 @@ async function refreshEntitlements(
{
headers: {
Authorization: \`Bearer \${iapkitPublishableKey}\`,
- ...(cached?.etag ? { 'If-None-Match': cached.etag } : {}),
+ ...(cachedForUser?.etag
+ ? { 'If-None-Match': cachedForUser.etag }
+ : {}),
},
},
);
} catch (error) {
- if (cached && canUseCachedSnapshot()) return cached.snapshot;
+ if (cachedForUser && canUseCachedSnapshot()) {
+ return cachedForUser.snapshot;
+ }
throw error;
}
- if (response.status === 304 && cached) {
- const refreshed = { ...cached, checkedAt: Date.now() };
+ if (response.status === 304 && cachedForUser) {
+ const refreshed = { ...cachedForUser, checkedAt: Date.now() };
await persistEntitlements(refreshed);
return refreshed.snapshot;
}
if (response.status === 429) {
scheduleRetry(response.headers.get('Retry-After'));
- if (cached && canUseCachedSnapshot()) return cached.snapshot;
+ if (cachedForUser && canUseCachedSnapshot()) {
+ return cachedForUser.snapshot;
+ }
throw new Error('Entitlement refresh is rate limited');
}
if (!response.ok) throw new Error('Entitlement refresh failed');
diff --git a/packages/kit/src/pages/docs/sections/api.tsx b/packages/kit/src/pages/docs/sections/api.tsx
index 8262cf333..df4fd68f5 100644
--- a/packages/kit/src/pages/docs/sections/api.tsx
+++ b/packages/kit/src/pages/docs/sections/api.tsx
@@ -268,16 +268,18 @@ async function refreshEntitlements(
maxStaleMs: number,
iapkitPublishableKey: string,
) {
+ const cachedForUser =
+ cached?.snapshot.userId === userId ? cached : null;
const canUseCachedSnapshot = () => {
if (
- cached === null ||
- !Number.isFinite(cached.checkedAt) ||
+ cachedForUser === null ||
+ !Number.isFinite(cachedForUser.checkedAt) ||
!Number.isFinite(maxStaleMs) ||
maxStaleMs < 0
) {
return false;
}
- const cacheAgeMs = Date.now() - cached.checkedAt;
+ const cacheAgeMs = Date.now() - cachedForUser.checkedAt;
return cacheAgeMs >= 0 && cacheAgeMs <= maxStaleMs;
};
let response: Response;
@@ -287,23 +289,29 @@ async function refreshEntitlements(
{
headers: {
Authorization: \`Bearer \${iapkitPublishableKey}\`,
- ...(cached?.etag ? { "If-None-Match": cached.etag } : {}),
+ ...(cachedForUser?.etag
+ ? { "If-None-Match": cachedForUser.etag }
+ : {}),
},
},
);
} catch (error) {
- if (cached && canUseCachedSnapshot()) return cached.snapshot;
+ if (cachedForUser && canUseCachedSnapshot()) {
+ return cachedForUser.snapshot;
+ }
throw error;
}
- if (response.status === 304 && cached) {
- const refreshed = { ...cached, checkedAt: Date.now() };
+ if (response.status === 304 && cachedForUser) {
+ const refreshed = { ...cachedForUser, checkedAt: Date.now() };
await persistSnapshot(refreshed);
return refreshed.snapshot;
}
if (response.status === 429) {
scheduleRetry(response.headers.get("Retry-After"));
- if (cached && canUseCachedSnapshot()) return cached.snapshot;
+ if (cachedForUser && canUseCachedSnapshot()) {
+ return cachedForUser.snapshot;
+ }
throw new Error("Entitlement refresh is rate limited");
}
if (!response.ok) throw new Error("Entitlement refresh failed");
From 8dcb05f6b29e3e912c145922fdf30c810830baa4 Mon Sep 17 00:00:00 2001
From: Hyo
Date: Tue, 28 Jul 2026 08:22:53 +0900
Subject: [PATCH 4/4] docs(kit): scope entitlement caches by project
---
packages/docs/src/pages/docs/kit-backend.tsx | 32 ++++++++++++--------
packages/kit/src/pages/docs/sections/api.tsx | 32 ++++++++++++--------
2 files changed, 38 insertions(+), 26 deletions(-)
diff --git a/packages/docs/src/pages/docs/kit-backend.tsx b/packages/docs/src/pages/docs/kit-backend.tsx
index 398c6dfb0..dddf3c571 100644
--- a/packages/docs/src/pages/docs/kit-backend.tsx
+++ b/packages/docs/src/pages/docs/kit-backend.tsx
@@ -603,6 +603,7 @@ if (status.Active)
revalidation.
{`type CachedEntitlements = {
+ cacheScope: string; // App-defined IAPKit project/environment identifier.
etag: string | null;
checkedAt: number;
snapshot: {
@@ -619,23 +620,27 @@ if (status.Active)
};
async function refreshEntitlements(
+ iapkitCacheScope: string,
userId: string,
cached: CachedEntitlements | null,
maxStaleMs: number,
iapkitPublishableKey: string,
) {
- const cachedForUser =
- cached?.snapshot.userId === userId ? cached : null;
+ const cachedForScope =
+ cached?.cacheScope === iapkitCacheScope &&
+ cached.snapshot.userId === userId
+ ? cached
+ : null;
const canUseCachedSnapshot = () => {
if (
- cachedForUser === null ||
- !Number.isFinite(cachedForUser.checkedAt) ||
+ cachedForScope === null ||
+ !Number.isFinite(cachedForScope.checkedAt) ||
!Number.isFinite(maxStaleMs) ||
maxStaleMs < 0
) {
return false;
}
- const cacheAgeMs = Date.now() - cachedForUser.checkedAt;
+ const cacheAgeMs = Date.now() - cachedForScope.checkedAt;
return cacheAgeMs >= 0 && cacheAgeMs <= maxStaleMs;
};
let response: Response;
@@ -645,28 +650,28 @@ async function refreshEntitlements(
{
headers: {
Authorization: \`Bearer \${iapkitPublishableKey}\`,
- ...(cachedForUser?.etag
- ? { 'If-None-Match': cachedForUser.etag }
+ ...(cachedForScope?.etag
+ ? { 'If-None-Match': cachedForScope.etag }
: {}),
},
},
);
} catch (error) {
- if (cachedForUser && canUseCachedSnapshot()) {
- return cachedForUser.snapshot;
+ if (cachedForScope && canUseCachedSnapshot()) {
+ return cachedForScope.snapshot;
}
throw error;
}
- if (response.status === 304 && cachedForUser) {
- const refreshed = { ...cachedForUser, checkedAt: Date.now() };
+ 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 (cachedForUser && canUseCachedSnapshot()) {
- return cachedForUser.snapshot;
+ if (cachedForScope && canUseCachedSnapshot()) {
+ return cachedForScope.snapshot;
}
throw new Error('Entitlement refresh is rate limited');
}
@@ -697,6 +702,7 @@ async function refreshEntitlements(
),
};
await persistEntitlements({
+ cacheScope: iapkitCacheScope,
snapshot,
etag: response.headers.get('ETag'),
checkedAt: Date.now(),
diff --git a/packages/kit/src/pages/docs/sections/api.tsx b/packages/kit/src/pages/docs/sections/api.tsx
index df4fd68f5..c8f9c186f 100644
--- a/packages/kit/src/pages/docs/sections/api.tsx
+++ b/packages/kit/src/pages/docs/sections/api.tsx
@@ -247,6 +247,7 @@ export default function ApiReferencePage() {
{`type CachedEntitlements = {
+ cacheScope: string; // App-defined IAPKit project/environment identifier.
etag: string | null;
checkedAt: number;
snapshot: {
@@ -263,23 +264,27 @@ export default function ApiReferencePage() {
};
async function refreshEntitlements(
+ iapkitCacheScope: string,
userId: string,
cached: CachedEntitlements | null,
maxStaleMs: number,
iapkitPublishableKey: string,
) {
- const cachedForUser =
- cached?.snapshot.userId === userId ? cached : null;
+ const cachedForScope =
+ cached?.cacheScope === iapkitCacheScope &&
+ cached.snapshot.userId === userId
+ ? cached
+ : null;
const canUseCachedSnapshot = () => {
if (
- cachedForUser === null ||
- !Number.isFinite(cachedForUser.checkedAt) ||
+ cachedForScope === null ||
+ !Number.isFinite(cachedForScope.checkedAt) ||
!Number.isFinite(maxStaleMs) ||
maxStaleMs < 0
) {
return false;
}
- const cacheAgeMs = Date.now() - cachedForUser.checkedAt;
+ const cacheAgeMs = Date.now() - cachedForScope.checkedAt;
return cacheAgeMs >= 0 && cacheAgeMs <= maxStaleMs;
};
let response: Response;
@@ -289,28 +294,28 @@ async function refreshEntitlements(
{
headers: {
Authorization: \`Bearer \${iapkitPublishableKey}\`,
- ...(cachedForUser?.etag
- ? { "If-None-Match": cachedForUser.etag }
+ ...(cachedForScope?.etag
+ ? { "If-None-Match": cachedForScope.etag }
: {}),
},
},
);
} catch (error) {
- if (cachedForUser && canUseCachedSnapshot()) {
- return cachedForUser.snapshot;
+ if (cachedForScope && canUseCachedSnapshot()) {
+ return cachedForScope.snapshot;
}
throw error;
}
- if (response.status === 304 && cachedForUser) {
- const refreshed = { ...cachedForUser, checkedAt: Date.now() };
+ 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 (cachedForUser && canUseCachedSnapshot()) {
- return cachedForUser.snapshot;
+ if (cachedForScope && canUseCachedSnapshot()) {
+ return cachedForScope.snapshot;
}
throw new Error("Entitlement refresh is rate limited");
}
@@ -341,6 +346,7 @@ async function refreshEntitlements(
),
};
await persistSnapshot({
+ cacheScope: iapkitCacheScope,
snapshot,
etag: response.headers.get("ETag"),
checkedAt: Date.now(),