@mpgd/capacitor-storekit 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  Opt-in StoreKit 2 one-time purchase collector for Capacitor 8 on iOS.
4
4
  It does not enable native IAP in the base Capacitor shell. Install it only in a
5
- game-owned iOS project and register `createCapacitorStoreKitProvider(...)` in
6
- that game's provider list.
5
+ game-owned iOS project and register `createCapacitorStoreKitServiceProvider(...)`
6
+ in that game's provider list.
7
7
 
8
8
  Map each logical game product to its App Store Connect product ID and provide
9
9
  an authenticated, stable UUID `getAppAccountToken()` value. The game backend
@@ -12,14 +12,13 @@ must resolve the same UUID for the player. Product lookup, purchase results,
12
12
  entitlements provide **provisional evidence**, never a wallet or entitlement
13
13
  grant. A completed native purchase returns no `authoritativeGrant`.
14
14
 
15
- ## Commerce v2 provider
15
+ ## Commerce provider
16
16
 
17
- Commerce v2 ([RFC](../../docs/specs/commerce/commerce-v2-rfc.md), PR B3)
18
- adds a second surface next to the v1 provider above. The package exposes the
19
- native plugin as a `StoreSdk` from `@mpgd/adapter-capacitor` and a
20
- `CommerceProvider` from `@mpgd/platform/commerce` built on
21
- `createStoreCommerceProvider`. Register the v2 service provider **or** the v1
22
- `createCapacitorStoreKitProvider` in a gateway, never both.
17
+ Commerce ([RFC](../../docs/specs/commerce/commerce-v2-rfc.md)) is the only
18
+ surface of this package. It exposes the native plugin as a `StoreSdk` from
19
+ `@mpgd/adapter-capacitor` and a `CommerceProvider` from
20
+ `@mpgd/platform/commerce` built on `createStoreCommerceProvider`, registered
21
+ with the Capacitor gateway as `gateway.commerce`.
23
22
 
24
23
  ```ts
25
24
  import { createCapacitorPlatformGateway } from '@mpgd/adapter-capacitor';
@@ -61,7 +60,7 @@ listed, a `revokedAt` maps to `state: 'revoked'`, and one malformed
61
60
  transaction is reported as a `purchaseError` without blocking its siblings);
62
61
  `finishTransaction({ transactionId, ledgerEntryId })` over the native finish;
63
62
  `restorePurchases` over `sync`; `transactionUpdated` as `purchaseUpdated`;
64
- and the v1 availability probe as `getAvailability`, probing the configured
63
+ and an availability probe as `getAvailability`, probing the configured
65
64
  SKUs batch by batch until one is sold. Every purchase carries the
66
65
  StoreKit-verified `signedTransaction` JWS as its `token`, the
67
66
  `transactionId`, `originalTransactionId`, `purchasedAt`, `quantity: 1`, and
@@ -107,23 +106,27 @@ from the App Store Server API, so a JWS retained from before a refund never
107
106
  grants; see the backend guide for the opt-in offline-only mode. Subscription
108
107
  mappings are rejected at construction. `createCapacitorStoreKitServiceProvider`
109
108
  wraps the provider as a `CapacitorServiceProvider` (id `apple-storekit` by
110
- default) so the gateway serves the `commerce.v2.*` and v1 `commerce.*`
111
- bridge methods from it and exposes it as `gateway.commerceV2`.
112
-
113
- Finishing never grants. The v2 SDK finishes any transaction it is given a
114
- `ledgerEntryId` for: the commerce v2 reconciler
109
+ default, no `features`, no `methods`) so the gateway serves the six
110
+ `commerce.v2.*` bridge methods from it, exposes it as `gateway.commerce`, and
111
+ reports its capability block in `getCapabilities().commerce`.
112
+ `createStoreKitCommerceCapabilities(availability)` (from `src/commerce-profile.ts`,
113
+ which imports types only and no Capacitor code) returns the full
114
+ `CommerceCapabilities` block this provider declares while the store reports
115
+ `availability`, so a validator or server can read the profile without loading
116
+ the plugin; a unit test keeps it equal to `provider.getCapabilities()`.
117
+
118
+ Finishing never grants. The SDK finishes any transaction it is given a
119
+ `ledgerEntryId` for: the commerce reconciler
115
120
  (`createCommerceReconciler` in `@mpgd/game-services`) is the gate, and it
116
121
  calls `finishTransaction` only for a journal record in `granted` or
117
122
  `finishing` that holds the ledger entry id the backend grant wrote
118
- (`assertCommerceFinishAllowed`). The v1 `finishGrantedTransaction`
119
- allow-list guard is not reproduced in the SDK: the journal revision the grant
120
- wrote is the pairing, and the real control stays server-side, in the verifier
121
- that checks the JWS before the ledger grant, and in the native plugin never
122
- granting anything on finish. The v1 recovery helpers
123
- (`recoverStoreKitPurchases`, `getRecoverableTransactions`,
124
- `finishGrantedTransaction`) are deprecated in favor of that reconciler (RFC
125
- C1) and are removed in C4; `src/commerce-reconciler-storekit-smoke.test.ts`
126
- shows both paths reaching the same outcomes over the fake plugin.
123
+ (`assertCommerceFinishAllowed`). The journal revision the grant wrote is the
124
+ pairing, and the real control stays server-side, in the verifier that checks
125
+ the JWS before the ledger grant, and in the native plugin never granting
126
+ anything on finish. `src/commerce-reconciler-storekit-smoke.test.ts` shows the
127
+ reconciler over the fake plugin: a grant finishes with the ledger entry, a
128
+ rejection and an outage never finish, a failed finish is retried, and another
129
+ account's transaction is never submitted.
127
130
 
128
131
  The conformance test runs every store vector whose profile the App Store
129
132
  supports against a fake plugin, directly and through the gateway bridge path,
@@ -131,87 +134,60 @@ and lists each skipped vector with its reason (host or server finish,
131
134
  quantity, server-push, an asynchronous store error event StoreKit never
132
135
  emits, and a pending transaction state StoreKit never reports).
133
136
 
134
- ## v1 provider and recovery
135
-
136
- On startup, account restoration, and after a pending or uncertain checkout,
137
- call `recoverStoreKitPurchases({ provider, backend, playerId })`. It sends only
138
- transactions whose `appAccountToken` matches the current authenticated game
139
- account to the game-owned `backend.recoverPurchase` contract. That backend
140
- must reuse the original idempotency key from the durable purchase journal, or
141
- return the existing ledger grant for the same Apple evidence, product and
142
- player. It must not invent a new key for an already-granted transaction: the
143
- generic game-services verifier rejects such evidence replay. For new evidence,
144
- the backend uses the existing App Store Server API/JWS verifier to validate
145
- bundle, product, environment and account binding before the ledger grant.
146
- The recovery helper calls native `finishTransaction` only after a
147
- verified response with a ledger entry ID. If finish fails, it reports
148
- `finishPending: true`; repeat recovery with the original purchase identity.
149
- `provider.finishGrantedTransaction` accepts only a transaction and ledger
150
- entry pair that `recoverStoreKitPurchases` recorded from a backend answer in
151
- the current session. The pair is consumed before the native call, so
152
- overlapping finishes (for example startup recovery and a `transactionUpdated`
153
- recovery) share one native call, and a failed native finish restores the pair
154
- for a retry. Calling it, or the native `finishTransaction` plugin method, with
155
- any other value is rejected so an unfinished purchase keeps its recovery
156
- signal. This guard prevents arbitrary or accidental finish calls with
157
- unverified ledger IDs and binds finishing to the recovery flow; it is **not**
158
- a trust boundary against code that controls the `backend` object it passes to
159
- `recoverStoreKitPurchases`. The real control is server-side: the authenticated
160
- `recoverPurchase` implementation must verify Apple evidence before it grants,
161
- and finishing a transaction never grants anything by itself.
162
- An unknown backend result stays pending and never triggers a
163
- new purchase sheet automatically.
164
-
165
- On the game server, `@mpgd/game-services/app-store-recovery` provides
166
- `createAppStoreRecoveryBackend(...)` for this contract. Construct it only after
167
- authenticating the game player, and bind `playerId` to that authenticated
168
- identity. Pass the same durable `GameServicesStore` instance and
169
- `createGameServicesBackend(...).purchases` used for checkout. Its optional
170
- `resolveOriginalIdempotencyKey` callback must query the game's durable
171
- checkout journal for the **original** key associated with this player and
172
- transaction. The adapter first checks existing App Store ledger evidence and
173
- rejects cross-player, cross-product, cross-deployment and key collisions. If
174
- neither a matching grant nor an original journal key exists, it returns
175
- `pending` without attempting a new grant. Do not use the in-memory store for
176
- production recovery. The game's HTTP/RPC endpoint must authenticate the
177
- request; this helper does not expose one or authenticate clients.
178
- For restored non-consumables whose new transaction ID differs from the
179
- original, configure `restoredNonConsumables` with the same catalog and official
180
- App Store evidence verifier, bundle ID and environment used by the game backend.
181
- When no prior grant exists, also bind `deploymentTarget` on the server-side
182
- recovery backend. The client-supplied target is not trusted to select a product;
183
- without a bound target this restore stays pending until the game backend is
184
- configured for its deployment. An existing grant can supply its recorded target.
185
- The bundle/environment identify an existing grant for its deployment target
186
- before product lookup; they must match the verifier configuration. The helper re-verifies
187
- the **current** Apple transaction and looks up the prior grant by its canonical
188
- original-transaction verification ID before reusing the original key. Without
189
- that verifier, this restore remains pending rather than trusting a client-
190
- reported original ID. A verifier that exceeds its timeout also remains pending.
191
- The native provider forwards both IDs and product type.
192
- For production-scale ledgers, implement the store's indexed
193
- `findEntitlementTransactionByPlatformEvidence` and
194
- `findEntitlementTransactionByIdempotency` methods, plus
195
- `findEntitlementTransactionByEvidenceVerificationId` for restored
196
- non-consumables. Without them, the helper
197
- uses the same full-ledger scan fallback as the generic game-services server.
198
-
199
- An existing grant is returned by the normal backend idempotent-retry path;
200
- that path relies on the prior verified ledger record rather than fetching
201
- fresh Apple status. Continue to process App Store revocations separately.
202
- Permanently rejected and account-mismatched transactions are reported as
203
- `rejected` and are **not** finished: the helper cannot prove content delivery
204
- for them. Persist that terminal result in the game-owned operation journal and
205
- do not run an automatic retry loop for it. A support flow may recheck a
206
- corrected account or backend decision later. One malformed transaction is
207
- reported separately and cannot block valid sibling transactions.
208
-
209
- The iOS native plugin also emits `transactionUpdated` for delayed StoreKit
210
- transactions. Call recovery after this event and on app launch because events
211
- can be missed while the game is closed. `commerce.restore` intentionally
212
- invokes user-initiated `AppStore.sync()` before returning no local entitlements;
213
- call recovery afterward. Subscriptions are not supported by this package;
214
- they require a separate subscription status and renewal flow.
137
+ ### Generated games
138
+
139
+ Games generated by `mpgd game create` (and the kit's private starter) ship
140
+ this wiring in `src/platform/buildGateways/capacitorIos.ts` (commerce v2 PR
141
+ C3): the service provider is registered for every catalog product whose
142
+ `platformProductIds.ios` names an App Store product id (subscriptions are
143
+ skipped), and `getAppAccountToken` is the reference binding in
144
+ `src/platform/commerceAccountBinding.ts`, a UUID (version 5 layout) over the
145
+ SHA-256 of `mpgd-commerce-account:<playerId>` for the authenticated player;
146
+ a guest session throws, so no sheet opens without a binding. The backend's
147
+ `resolveAppAccountToken` must derive the same UUID, or both are replaced
148
+ together.
149
+
150
+ `mpgd target init capacitor --providers storekit` installs this package into
151
+ the game-owned shell and the game at one exact version and lets `cap add` or
152
+ `cap sync` register its Swift package; on a game that still has the unwired
153
+ gateway of an earlier template it also writes the wiring above. A build
154
+ whose shell lacks the native plugin still runs: the plugin answers
155
+ Capacitor's `UNIMPLEMENTED`, which the SDK reports as
156
+ `configuration-required`, so no purchase sheet opens.
157
+
158
+ ## Recovery
159
+
160
+ Call the reconciler (`monetizationRecovery.reconcile()` from
161
+ `@mpgd/game-services`) on startup, on resume, and after the iOS plugin emits
162
+ `transactionUpdated` for a delayed transaction (Ask to Buy, an interrupted
163
+ checkout, a transaction finished on another device): the events can be missed
164
+ while the game is closed, so `getAvailablePurchases` (`Transaction.unfinished`)
165
+ is the source of truth. The reconciler sends only transactions whose
166
+ `appAccountToken` matches the current authenticated game account, verifies
167
+ each one on the backend under its original idempotency key or returns the
168
+ existing ledger grant, and calls `finishTransaction` only after a verified
169
+ response with a ledger entry id. Permanently rejected and account-mismatched
170
+ transactions are not finished: the client cannot prove content delivery for
171
+ them, so persist that terminal result in the game-owned operation journal and
172
+ do not run an automatic retry loop for it. An unknown backend result stays
173
+ pending and never opens a new purchase sheet. Process App Store revocations
174
+ separately on the server. `restorePurchases` invokes the user-initiated
175
+ `AppStore.sync()` and then runs a reconciler pass; it returns no local
176
+ entitlements. Subscriptions are not supported by this package; they require a
177
+ separate subscription status and renewal flow.
178
+
179
+ ### Migrating from v1
180
+
181
+ Commerce v2 PR C4 removed the v1 provider and its recovery driver.
182
+
183
+ | Removed | Replacement |
184
+ | --- | --- |
185
+ | `createCapacitorStoreKitProvider`, `StoreKitProviderOptions`, `StoreKitProductMapping`, `CapacitorStoreKitProvider` | `createCapacitorStoreKitServiceProvider` (registry entry) or `createCapacitorStoreKitCommerceProvider`; rename `id`/`storeId`/`type` to `logicalId`/`storeSku`/`kind` (`non_consumable` becomes `durable`); `getAppAccountToken` keeps its meaning |
186
+ | `recoverStoreKitPurchases`, `StoreKitRecoveryBackend`, `RecoveredStoreKitPurchase`, `StoreKitPurchaseVerification`, `StoreKitRecoverySnapshot`, `RecoverableStoreKitTransaction`, `InvalidStoreKitTransaction` | the reconciler in `@mpgd/game-services` (`createCommerceReconciler`, behind `createGameServicesClient` and `monetizationRecovery.reconcile()`), driven by `getAvailablePurchases` and `purchaseUpdated` |
187
+ | `getRecoverableTransactions`, `finishGrantedTransaction` | `getAvailablePurchases` and `finishTransaction`, called by the reconciler after the ledger grant (`assertCommerceFinishAllowed`) |
188
+ | `getAppAccountToken()` on the provider object | the `accountBinding` of the commerce provider resolves the same token |
189
+ | `gateway.commerceV2`, the v1 `commerce.*` bridge methods | `gateway.commerce` and the `commerce.v2.*` methods |
190
+ | `features: ['nativeIap']` on the registry entry | none: the entry declares no `features` and its readiness is `getCapabilities().commerce` |
215
191
 
216
192
  The Kit tests use mocked StoreKit responses and a syntax-only local Swift
217
193
  parse. The macOS CI job compiles the optional Swift package against an iOS
@@ -0,0 +1,24 @@
1
+ import type { StoreSdkCapabilities } from '@mpgd/adapter-capacitor';
2
+ import type { CommerceAuthorityCapabilities, CommerceCapabilities, PlatformProviderAvailability } from '@mpgd/platform';
3
+ /** The store id of the StoreKit `StoreSdk` and its commerce provider (RFC "Capabilities", `app-store`). */
4
+ export declare const appStoreStoreId: 'app-store';
5
+ /**
6
+ * Evidence schema of a commerce v2 App Store purchase. The payload carries
7
+ * the StoreKit-verified JWS so `@mpgd/game-services/app-store-verifier`
8
+ * verifies it offline and falls back to the Server API only without it.
9
+ */
10
+ export declare const appStoreSignedTransactionEvidenceSchema = "app-store.signed-transaction.v1";
11
+ /** The App Store profile from the RFC "Capabilities" table. */
12
+ export declare const storeKitStoreSdkCapabilities: StoreSdkCapabilities;
13
+ /** RFC "Capabilities" profile row for `app-store`: the signed transaction is the proof, bound to an app account token. */
14
+ export declare const storeKitCommerceAuthority: CommerceAuthorityCapabilities;
15
+ /**
16
+ * The `CommerceCapabilities` block `createCapacitorStoreKitCommerceProvider`
17
+ * declares while the store reports `availability`: composed from
18
+ * `storeKitStoreSdkCapabilities` and `storeKitCommerceAuthority` exactly as
19
+ * `createStoreCommerceProvider` composes it (the provider has no capability
20
+ * overrides, and the StoreKit SDK implements `restorePurchases` through
21
+ * `AppStore.sync()`). Returns a fresh object on every call and has no side
22
+ * effects.
23
+ */
24
+ export declare function createStoreKitCommerceCapabilities(availability: PlatformProviderAvailability): CommerceCapabilities;
@@ -0,0 +1,51 @@
1
+ /** The store id of the StoreKit `StoreSdk` and its commerce provider (RFC "Capabilities", `app-store`). */
2
+ export const appStoreStoreId = 'app-store';
3
+ /**
4
+ * Evidence schema of a commerce v2 App Store purchase. The payload carries
5
+ * the StoreKit-verified JWS so `@mpgd/game-services/app-store-verifier`
6
+ * verifies it offline and falls back to the Server API only without it.
7
+ */
8
+ export const appStoreSignedTransactionEvidenceSchema = 'app-store.signed-transaction.v1';
9
+ /** The App Store profile from the RFC "Capabilities" table. */
10
+ export const storeKitStoreSdkCapabilities = Object.freeze({
11
+ purchase: Object.freeze({
12
+ consumable: true,
13
+ durable: true,
14
+ subscription: false,
15
+ quantity: false,
16
+ }),
17
+ offers: false,
18
+ pending: true,
19
+ restore: true,
20
+ finish: 'client',
21
+ });
22
+ /** RFC "Capabilities" profile row for `app-store`: the signed transaction is the proof, bound to an app account token. */
23
+ export const storeKitCommerceAuthority = Object.freeze({
24
+ evidence: 'client-token',
25
+ accountBinding: 'required',
26
+ });
27
+ /**
28
+ * The `CommerceCapabilities` block `createCapacitorStoreKitCommerceProvider`
29
+ * declares while the store reports `availability`: composed from
30
+ * `storeKitStoreSdkCapabilities` and `storeKitCommerceAuthority` exactly as
31
+ * `createStoreCommerceProvider` composes it (the provider has no capability
32
+ * overrides, and the StoreKit SDK implements `restorePurchases` through
33
+ * `AppStore.sync()`). Returns a fresh object on every call and has no side
34
+ * effects.
35
+ */
36
+ export function createStoreKitCommerceCapabilities(availability) {
37
+ const sdk = storeKitStoreSdkCapabilities;
38
+ return {
39
+ purchase: { ...sdk.purchase },
40
+ catalog: { products: true, offers: sdk.offers ?? false, localizedPrice: true },
41
+ lifecycle: {
42
+ availablePurchases: true,
43
+ pending: sdk.pending,
44
+ restore: sdk.restore,
45
+ events: true,
46
+ finish: sdk.finish,
47
+ },
48
+ authority: { ...storeKitCommerceAuthority },
49
+ availability,
50
+ };
51
+ }
@@ -1,11 +1,7 @@
1
1
  import { type CapacitorCommerceServiceProvider, type StoreCommerceProductMapping, type StoreCommerceProvider, type StoreSdkErrorCodeMap } from '@mpgd/adapter-capacitor';
2
+ import { appStoreSignedTransactionEvidenceSchema } from './commerce-profile.js';
2
3
  import { type CapacitorStoreKitDiagnostic, type CapacitorStoreKitStoreSdkPlugin } from './store-sdk.js';
3
- /**
4
- * Evidence schema of a commerce v2 App Store purchase. The payload carries
5
- * the StoreKit-verified JWS so `@mpgd/game-services/app-store-verifier`
6
- * verifies it offline and falls back to the Server API only without it.
7
- */
8
- export declare const appStoreSignedTransactionEvidenceSchema = "app-store.signed-transaction.v1";
4
+ export { appStoreSignedTransactionEvidenceSchema };
9
5
  /** Payload of an `app-store.signed-transaction.v1` envelope. */
10
6
  export interface AppStoreSignedTransactionEvidencePayload {
11
7
  readonly signedTransaction: string;
@@ -45,14 +41,13 @@ export interface CreateCapacitorStoreKitCommerceProviderInput {
45
41
  */
46
42
  export declare function createCapacitorStoreKitCommerceProvider(input: CreateCapacitorStoreKitCommerceProviderInput): StoreCommerceProvider;
47
43
  export interface CreateCapacitorStoreKitServiceProviderInput extends CreateCapacitorStoreKitCommerceProviderInput {
48
- /** Registry id. Defaults to `apple-storekit`, the v1 provider id. */
44
+ /** Registry id. Defaults to `apple-storekit`. */
49
45
  readonly id?: string;
50
46
  }
51
47
  /**
52
- * The commerce v2 provider wrapped as a `CapacitorServiceProvider` for
53
- * `createCapacitorPlatformGateway({ providers })`: the registry serves the
54
- * `commerce.v2.*` and v1 `commerce.*` bridge methods from it and exposes it
55
- * as `gateway.commerceV2`. Register either this or the v1
56
- * `createCapacitorStoreKitProvider`, never both.
48
+ * The commerce provider wrapped as a `CapacitorServiceProvider` for
49
+ * `createCapacitorPlatformGateway({ providers })`: the registry serves the six
50
+ * `commerce.v2.*` bridge methods from it, exposes it as `gateway.commerce`, and
51
+ * reports its capability block in `getCapabilities()`.
57
52
  */
58
53
  export declare function createCapacitorStoreKitServiceProvider(input: CreateCapacitorStoreKitServiceProviderInput): CapacitorCommerceServiceProvider;
@@ -1,12 +1,10 @@
1
1
  import { createCommerceServiceProvider, createStoreCommerceProvider, } from '@mpgd/adapter-capacitor';
2
2
  import { CommerceError } from '@mpgd/platform/commerce';
3
+ import { appStoreSignedTransactionEvidenceSchema, appStoreStoreId, storeKitCommerceAuthority, } from './commerce-profile.js';
3
4
  import { capacitorStoreKitErrorCodeMap, createCapacitorStoreKitStoreSdk, readAppAccountToken, } from './store-sdk.js';
4
- /**
5
- * Evidence schema of a commerce v2 App Store purchase. The payload carries
6
- * the StoreKit-verified JWS so `@mpgd/game-services/app-store-verifier`
7
- * verifies it offline and falls back to the Server API only without it.
8
- */
9
- export const appStoreSignedTransactionEvidenceSchema = 'app-store.signed-transaction.v1';
5
+ // The profile constants live in the SDK-free `commerce-profile.ts` so a tools
6
+ // project can read them without loading Capacitor; they stay exported here.
7
+ export { appStoreSignedTransactionEvidenceSchema };
10
8
  /**
11
9
  * Commerce v2 `CommerceProvider` for the App Store: `createStoreCommerceProvider`
12
10
  * over `createCapacitorStoreKitStoreSdk` with the App Store profile,
@@ -45,7 +43,7 @@ export function createCapacitorStoreKitCommerceProvider(input) {
45
43
  });
46
44
  return createStoreCommerceProvider({
47
45
  sdk,
48
- store: 'app-store',
46
+ store: appStoreStoreId,
49
47
  products: input.products,
50
48
  evidenceSchema: appStoreSignedTransactionEvidenceSchema,
51
49
  evidencePayload: (purchase) => {
@@ -88,16 +86,15 @@ export function createCapacitorStoreKitCommerceProvider(input) {
88
86
  });
89
87
  }
90
88
  },
91
- authority: { evidence: 'client-token', accountBinding: 'required' },
89
+ authority: storeKitCommerceAuthority,
92
90
  mapError: { ...capacitorStoreKitErrorCodeMap, ...input.mapError },
93
91
  });
94
92
  }
95
93
  /**
96
- * The commerce v2 provider wrapped as a `CapacitorServiceProvider` for
97
- * `createCapacitorPlatformGateway({ providers })`: the registry serves the
98
- * `commerce.v2.*` and v1 `commerce.*` bridge methods from it and exposes it
99
- * as `gateway.commerceV2`. Register either this or the v1
100
- * `createCapacitorStoreKitProvider`, never both.
94
+ * The commerce provider wrapped as a `CapacitorServiceProvider` for
95
+ * `createCapacitorPlatformGateway({ providers })`: the registry serves the six
96
+ * `commerce.v2.*` bridge methods from it, exposes it as `gateway.commerce`, and
97
+ * reports its capability block in `getCapabilities()`.
101
98
  */
102
99
  export function createCapacitorStoreKitServiceProvider(input) {
103
100
  const { id, ...providerInput } = input;
@@ -52,12 +52,12 @@ export interface CapacitorStoreKitPlugin {
52
52
  /**
53
53
  * Call only after the authenticated backend confirms the ledger grant.
54
54
  * Native validation is intentionally shallow and finishing never grants
55
- * anything. The TypeScript provider's finishGrantedTransaction binds this
56
- * call to the recoverStoreKitPurchases flow so games cannot finish a
57
- * purchase accidentally or with an arbitrary ledger ID; games must not call
58
- * this plugin method directly. That binding is not a trust boundary against
59
- * code that controls the recovery backend object: the authenticated
60
- * server-side recoverPurchase verification is the real control.
55
+ * anything. The commerce provider's `finishTransaction` is the only caller:
56
+ * the reconciler in `@mpgd/game-services` reaches it through
57
+ * `assertCommerceFinishAllowed` after the ledger grant, so games must not
58
+ * call this plugin method directly. That gate is not a trust boundary
59
+ * against code that controls the backend object given to the reconciler:
60
+ * the authenticated server-side verification is the real control.
61
61
  */
62
62
  finishTransaction(input: {
63
63
  readonly transactionId: string;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
+ export { appStoreStoreId, createStoreKitCommerceCapabilities, storeKitCommerceAuthority, } from './commerce-profile.js';
1
2
  export * from './commerce-provider.js';
2
3
  export * from './definitions.js';
3
4
  export * from './plugin.js';
4
- export * from './provider.js';
5
5
  export * from './store-sdk.js';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
+ export { appStoreStoreId, createStoreKitCommerceCapabilities, storeKitCommerceAuthority, } from './commerce-profile.js';
1
2
  export * from './commerce-provider.js';
2
3
  export * from './definitions.js';
3
4
  export * from './plugin.js';
4
- export * from './provider.js';
5
5
  export * from './store-sdk.js';
@@ -1,6 +1,7 @@
1
- import { type StoreSdk, type StoreSdkCapabilities, type StoreSdkErrorCodeMap, type StoreSdkPurchase } from '@mpgd/adapter-capacitor';
1
+ import { type StoreSdk, type StoreSdkErrorCodeMap, type StoreSdkPurchase } from '@mpgd/adapter-capacitor';
2
2
  import type { StorePurchaseAccountBinding } from '@mpgd/platform/commerce';
3
3
  import type { CapacitorStoreKitPlugin, StoreKitTransaction } from './definitions.js';
4
+ import { storeKitStoreSdkCapabilities } from './commerce-profile.js';
4
5
  /** The plugin surface the commerce v2 SDK drives; `CapacitorStoreKit` by default. */
5
6
  export type CapacitorStoreKitStoreSdkPlugin = Pick<CapacitorStoreKitPlugin, 'getProducts' | 'purchase' | 'getTransactions' | 'sync' | 'finishTransaction' | 'addListener'>;
6
7
  /** One-time kinds the App Store sells; `durable` is a StoreKit non-consumable. */
@@ -62,8 +63,7 @@ export interface CreateCapacitorStoreKitStoreSdkInput {
62
63
  * to another player.
63
64
  */
64
65
  export declare function createCapacitorStoreKitStoreSdk(input: CreateCapacitorStoreKitStoreSdkInput): StoreSdk;
65
- /** The App Store profile from the RFC "Capabilities" table. */
66
- export declare const storeKitStoreSdkCapabilities: StoreSdkCapabilities;
66
+ export { storeKitStoreSdkCapabilities };
67
67
  /**
68
68
  * StoreKit plugin codes merged over `defaultStoreSdkErrorCodeMap`. Codes the
69
69
  * default table already carries (`STOREKIT_PURCHASE_UNCERTAIN`,
package/dist/store-sdk.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Capacitor } from '@capacitor/core';
2
2
  import { defaultStoreSdkErrorCodeMap, } from '@mpgd/adapter-capacitor';
3
+ import { appStoreStoreId, storeKitStoreSdkCapabilities } from './commerce-profile.js';
3
4
  import { CapacitorStoreKit } from './plugin.js';
4
5
  /**
5
6
  * Capacitor StoreKit `StoreSdk`: the commerce v2 surface over the native
@@ -244,7 +245,7 @@ export function createCapacitorStoreKitStoreSdk(input) {
244
245
  // native call; the v2 reconciler is the gate that authorizes the call.
245
246
  const inFlightFinishes = new Map();
246
247
  return {
247
- store: 'app-store',
248
+ store: appStoreStoreId,
248
249
  capabilities: storeKitStoreSdkCapabilities,
249
250
  async fetchProducts(request) {
250
251
  requireIos();
@@ -396,26 +397,20 @@ export function createCapacitorStoreKitStoreSdk(input) {
396
397
  }
397
398
  catch (error) {
398
399
  const code = isRecord(error) ? error.code : undefined;
399
- return code === 'STOREKIT_INVALID_PRODUCTS' || code === 'STOREKIT_PRODUCT_UNAVAILABLE'
400
+ // Capacitor's `UNIMPLEMENTED` means the native plugin is not installed in the shell.
401
+ return code === 'STOREKIT_INVALID_PRODUCTS'
402
+ || code === 'STOREKIT_PRODUCT_UNAVAILABLE'
403
+ || code === 'UNIMPLEMENTED'
400
404
  ? 'configuration-required'
401
405
  : 'temporarily-unavailable';
402
406
  }
403
407
  },
404
408
  };
405
409
  }
406
- /** The App Store profile from the RFC "Capabilities" table. */
407
- export const storeKitStoreSdkCapabilities = Object.freeze({
408
- purchase: Object.freeze({
409
- consumable: true,
410
- durable: true,
411
- subscription: false,
412
- quantity: false,
413
- }),
414
- offers: false,
415
- pending: true,
416
- restore: true,
417
- finish: 'client',
418
- });
410
+ // The App Store profile (RFC "Capabilities") lives in the SDK-free
411
+ // `commerce-profile.ts` so a tools project can read it without loading
412
+ // Capacitor; it stays exported here.
413
+ export { storeKitStoreSdkCapabilities };
419
414
  /**
420
415
  * StoreKit plugin codes merged over `defaultStoreSdkErrorCodeMap`. Codes the
421
416
  * default table already carries (`STOREKIT_PURCHASE_UNCERTAIN`,
@@ -120,8 +120,8 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
120
120
  byId[id] = payload
121
121
  }
122
122
  }
123
- // Current non-consumable entitlements are listed for the v1 restore
124
- // flow; `unfinished: false` lets the commerce v2 SDK exclude them.
123
+ // Current non-consumable entitlements are listed too;
124
+ // `unfinished: false` lets the commerce SDK exclude them.
125
125
  for await result in Transaction.currentEntitlements {
126
126
  if var payload = Self.verifiedPayload(result),
127
127
  payload["type"] as? String == "non_consumable",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/capacitor-storekit",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Opt-in StoreKit 2 one-time purchase collector and commerce v2 provider for mpgd Capacitor games.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,18 +38,18 @@
38
38
  ],
39
39
  "peerDependencies": {
40
40
  "@capacitor/core": "^8.5.1",
41
- "@mpgd/adapter-capacitor": "^0.9.2",
42
- "@mpgd/bridge": "^0.12.3",
43
- "@mpgd/platform": "^0.22.0"
41
+ "@mpgd/adapter-capacitor": "^0.10.0",
42
+ "@mpgd/bridge": "^0.13.0",
43
+ "@mpgd/platform": "^0.23.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@capacitor/core": "8.5.2",
47
47
  "ttsc": "0.30.4",
48
48
  "typescript": "7.0.2",
49
- "@mpgd/adapter-capacitor": "0.9.2",
50
- "@mpgd/bridge": "0.12.3",
51
- "@mpgd/platform": "0.22.0",
52
- "@mpgd/game-services": "0.22.0"
49
+ "@mpgd/adapter-capacitor": "0.10.0",
50
+ "@mpgd/bridge": "0.13.0",
51
+ "@mpgd/game-services": "0.24.0",
52
+ "@mpgd/platform": "0.23.0"
53
53
  },
54
54
  "main": "./dist/index.js",
55
55
  "types": "./dist/index.d.ts",
@@ -59,6 +59,6 @@
59
59
  "scripts": {
60
60
  "check": "ttsc --noEmit",
61
61
  "lint": "ttsc --noEmit",
62
- "test": "cd ../.. && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/provider.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/store-sdk.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-provider.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-provider.conformance.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-reconciler-storekit-smoke.test.ts"
62
+ "test": "cd ../.. && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/store-sdk.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-profile.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-provider.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-provider.conformance.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-reconciler-storekit-smoke.test.ts"
63
63
  }
64
64
  }
@@ -1,121 +0,0 @@
1
- import type { BridgeRequest, BridgeResponse } from '@mpgd/bridge';
2
- import type { PurchaseResult } from '@mpgd/platform';
3
- import type { CapacitorStoreKitPlugin } from './definitions.js';
4
- export interface StoreKitProductMapping {
5
- readonly id: string;
6
- readonly storeId: string;
7
- readonly type: 'consumable' | 'non_consumable';
8
- }
9
- export interface StoreKitProviderOptions {
10
- readonly products: readonly StoreKitProductMapping[];
11
- /** Must agree with the authenticated backend's resolveAppAccountToken. */
12
- readonly getAppAccountToken: () => string | Promise<string>;
13
- readonly sdk?: Pick<CapacitorStoreKitPlugin, 'getProducts' | 'purchase' | 'getTransactions' | 'sync' | 'finishTransaction'>;
14
- readonly isIos?: () => boolean;
15
- }
16
- export interface RecoverableStoreKitTransaction {
17
- readonly productId: string;
18
- readonly originalTransactionId: string;
19
- readonly type: 'consumable' | 'non_consumable';
20
- readonly appAccountToken?: string;
21
- readonly purchasedAt: string;
22
- readonly result: PurchaseResult;
23
- }
24
- export interface InvalidStoreKitTransaction {
25
- readonly productId: string;
26
- readonly transactionId?: string;
27
- readonly reason: 'invalid-evidence' | 'revoked';
28
- }
29
- export interface StoreKitRecoverySnapshot {
30
- readonly transactions: readonly RecoverableStoreKitTransaction[];
31
- readonly invalid: readonly InvalidStoreKitTransaction[];
32
- }
33
- export interface CapacitorStoreKitProvider {
34
- readonly id: 'apple-storekit';
35
- readonly features: readonly ['nativeIap'];
36
- readonly methods: readonly [
37
- 'commerce.getProducts',
38
- 'commerce.purchase',
39
- 'commerce.restore',
40
- 'commerce.getEntitlements'
41
- ];
42
- readonly bridge: {
43
- request(input: BridgeRequest): Promise<BridgeResponse>;
44
- };
45
- getAvailability(): Promise<Readonly<{
46
- nativeIap: 'available' | 'configuration-required' | 'temporarily-unavailable' | 'unsupported';
47
- }>>;
48
- getAppAccountToken(): Promise<string>;
49
- /**
50
- * @deprecated v1 recovery driver. Commerce v2 lists unfinished transactions
51
- * through `createCapacitorStoreKitCommerceProvider().getAvailablePurchases`
52
- * and the single reconciler (RFC C1) resolves them; removed in C4.
53
- */
54
- getRecoverableTransactions(): Promise<StoreKitRecoverySnapshot>;
55
- /**
56
- * @deprecated v1 recovery driver. Commerce v2 finishes through
57
- * `CommerceProvider.finishTransaction` after the ledger grant, driven by
58
- * the single reconciler (RFC C1); removed in C4.
59
- *
60
- * Finish a transaction the backend already granted. The provider accepts
61
- * only a transaction and ledger entry pair that recoverStoreKitPurchases
62
- * recorded from a backend answer in this session, and consumes the pair
63
- * before the native call so overlapping finishes share one native call;
64
- * any other pair is rejected so an unfinished purchase keeps its recovery
65
- * signal. A failed native finish restores the pair for a retry.
66
- *
67
- * The guard prevents arbitrary or accidental finish calls with unverified
68
- * ledger IDs. It is not a trust boundary against code that controls the
69
- * backend object given to recoverStoreKitPurchases; the authenticated
70
- * server-side recoverPurchase implementation is the real control.
71
- */
72
- finishGrantedTransaction(input: {
73
- readonly transactionId: string;
74
- readonly ledgerEntryId: string;
75
- }): Promise<boolean>;
76
- }
77
- export interface StoreKitPurchaseVerification {
78
- readonly verified: boolean;
79
- readonly disposition?: 'pending' | 'rejected';
80
- readonly alreadyProcessed: boolean;
81
- readonly ledgerEntryId?: string;
82
- readonly reason?: string;
83
- }
84
- export interface StoreKitRecoveryBackend {
85
- /**
86
- * Resolve a journaled checkout with its ORIGINAL idempotency key, or return
87
- * the already-recorded grant for matching App Store evidence and owner.
88
- * The backend must verify Apple data before returning verified=true.
89
- */
90
- recoverPurchase(input: {
91
- readonly target: 'ios';
92
- readonly deploymentTarget?: string;
93
- readonly playerId: string;
94
- readonly productId: string;
95
- readonly platformTransactionId: string;
96
- readonly originalTransactionId: string;
97
- readonly productType: 'consumable' | 'non_consumable';
98
- readonly purchasedAt: string;
99
- }): Promise<StoreKitPurchaseVerification>;
100
- }
101
- export interface RecoveredStoreKitPurchase {
102
- readonly productId: string;
103
- readonly transactionId?: string;
104
- readonly status: 'granted' | 'pending' | 'rejected';
105
- readonly reason?: 'invalid-evidence' | 'revoked' | 'account-mismatch';
106
- readonly finishPending?: boolean;
107
- readonly verification?: StoreKitPurchaseVerification;
108
- }
109
- /**
110
- * Submit unfinished purchases to the backend before asking StoreKit to finish.
111
- *
112
- * @deprecated v1 recovery driver. The commerce v2 reconciler (RFC C1) over
113
- * `createCapacitorStoreKitCommerceProvider` replaces it; removed in C4.
114
- */
115
- export declare function recoverStoreKitPurchases(input: {
116
- readonly provider: CapacitorStoreKitProvider;
117
- readonly backend: StoreKitRecoveryBackend;
118
- readonly playerId: string;
119
- readonly deploymentTarget?: string;
120
- }): Promise<readonly RecoveredStoreKitPurchase[]>;
121
- export declare function createCapacitorStoreKitProvider(options: StoreKitProviderOptions): CapacitorStoreKitProvider;
package/dist/provider.js DELETED
@@ -1,354 +0,0 @@
1
- import { Capacitor } from '@capacitor/core';
2
- import { CapacitorStoreKit } from './plugin.js';
3
- const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
4
- const productPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
5
- const transactionPattern = /^[1-9][0-9]{0,19}$/u;
6
- /**
7
- * Grants recorded by recoverStoreKitPurchases per provider instance:
8
- * transaction ID to the ledger entry ID the recovery backend returned. The map
9
- * binds finishGrantedTransaction to that recovery flow so a game cannot finish
10
- * an unfinished purchase by accident, or with an arbitrary ledger string, and
11
- * so each recorded pair reaches the native finish call at most once.
12
- *
13
- * This is not a trust boundary against a caller who controls the backend
14
- * object passed to recoverStoreKitPurchases: such a caller can record any
15
- * pair. The real control stays server-side, in the authenticated
16
- * recoverPurchase implementation that verifies Apple evidence before it
17
- * grants, and in the native plugin never granting anything on finish.
18
- */
19
- const verifiedGrants = new WeakMap();
20
- /**
21
- * Submit unfinished purchases to the backend before asking StoreKit to finish.
22
- *
23
- * @deprecated v1 recovery driver. The commerce v2 reconciler (RFC C1) over
24
- * `createCapacitorStoreKitCommerceProvider` replaces it; removed in C4.
25
- */
26
- export async function recoverStoreKitPurchases(input) {
27
- if (input.playerId.trim() === '') {
28
- throw new TypeError('StoreKit recovery requires an authenticated player ID.');
29
- }
30
- const token = await input.provider.getAppAccountToken();
31
- const snapshot = await input.provider.getRecoverableTransactions();
32
- const recovered = await Promise.all(snapshot.transactions.map(async (item) => {
33
- const transactionId = item.result.transactionId;
34
- if (transactionId === undefined) {
35
- return { productId: item.productId, status: 'rejected', reason: 'invalid-evidence' };
36
- }
37
- if (item.appAccountToken?.toLowerCase() !== token) {
38
- // Shared Apple IDs can expose a transaction owned by another game user.
39
- return {
40
- productId: item.productId, transactionId, status: 'rejected', reason: 'account-mismatch',
41
- };
42
- }
43
- try {
44
- const verification = await input.backend.recoverPurchase({
45
- target: 'ios',
46
- ...(input.deploymentTarget === undefined ? {} : { deploymentTarget: input.deploymentTarget }),
47
- playerId: input.playerId,
48
- productId: item.productId,
49
- platformTransactionId: transactionId,
50
- originalTransactionId: item.originalTransactionId,
51
- productType: item.type,
52
- purchasedAt: item.purchasedAt,
53
- });
54
- if (!verification.verified) {
55
- return {
56
- productId: item.productId,
57
- transactionId,
58
- status: verification.disposition ?? 'pending',
59
- verification,
60
- };
61
- }
62
- if (verification.ledgerEntryId === undefined || verification.ledgerEntryId.trim() === '') {
63
- return { productId: item.productId, transactionId, status: 'pending', verification };
64
- }
65
- verifiedGrants.get(input.provider)?.set(transactionId, verification.ledgerEntryId);
66
- try {
67
- const finished = await input.provider.finishGrantedTransaction({
68
- transactionId,
69
- ledgerEntryId: verification.ledgerEntryId,
70
- });
71
- return {
72
- productId: item.productId,
73
- transactionId,
74
- status: 'granted',
75
- finishPending: !finished,
76
- verification,
77
- };
78
- }
79
- catch {
80
- return {
81
- productId: item.productId,
82
- transactionId,
83
- status: 'granted',
84
- finishPending: true,
85
- verification,
86
- };
87
- }
88
- }
89
- catch {
90
- return { productId: item.productId, transactionId, status: 'pending' };
91
- }
92
- }));
93
- return [
94
- ...recovered,
95
- ...snapshot.invalid.map((item) => ({ ...item, status: 'rejected' })),
96
- ];
97
- }
98
- export function createCapacitorStoreKitProvider(options) {
99
- const sdk = options.sdk ?? CapacitorStoreKit;
100
- const isIos = options.isIos ?? (() => Capacitor.getPlatform() === 'ios');
101
- const byLogicalId = new Map();
102
- const byStoreId = new Map();
103
- for (const product of options.products) {
104
- if (!productPattern.test(product.id)
105
- || !productPattern.test(product.storeId)
106
- || byLogicalId.has(product.id)
107
- || byStoreId.has(product.storeId)) {
108
- throw new TypeError('StoreKit product mappings must be unique and valid.');
109
- }
110
- byLogicalId.set(product.id, product);
111
- byStoreId.set(product.storeId, product);
112
- }
113
- if (byLogicalId.size === 0 || byLogicalId.size > 100) {
114
- throw new TypeError('StoreKit requires 1-100 one-time product mappings.');
115
- }
116
- async function getAppAccountToken() {
117
- const token = (await options.getAppAccountToken()).toLowerCase();
118
- if (!uuidPattern.test(token)) {
119
- throw new TypeError('StoreKit requires a UUID app account token.');
120
- }
121
- return token;
122
- }
123
- async function getRecoverableTransactions() {
124
- if (!isIos()) {
125
- throw new Error('StoreKit is only available on iOS.');
126
- }
127
- const response = await sdk.getTransactions();
128
- const owned = [];
129
- const invalid = [];
130
- const seen = new Set();
131
- for (const transaction of response.transactions) {
132
- const mapping = byStoreId.get(transaction.productId);
133
- if (mapping === undefined) {
134
- continue;
135
- }
136
- if (mapping.type !== transaction.type || !validTransaction(transaction)) {
137
- invalid.push({
138
- productId: mapping.id,
139
- transactionId: transaction.transactionId,
140
- reason: 'invalid-evidence',
141
- });
142
- continue;
143
- }
144
- if (transaction.revokedAt !== undefined) {
145
- invalid.push({
146
- productId: mapping.id,
147
- transactionId: transaction.transactionId,
148
- reason: 'revoked',
149
- });
150
- continue;
151
- }
152
- if (seen.has(transaction.transactionId)) {
153
- continue;
154
- }
155
- seen.add(transaction.transactionId);
156
- owned.push({
157
- productId: mapping.id,
158
- originalTransactionId: transaction.originalTransactionId,
159
- type: transaction.type,
160
- ...(transaction.appAccountToken === undefined
161
- ? {} : { appAccountToken: transaction.appAccountToken }),
162
- purchasedAt: transaction.purchasedAt,
163
- result: toPurchaseResult(transaction),
164
- });
165
- }
166
- return { transactions: owned, invalid };
167
- }
168
- async function request(input) {
169
- if (!isIos()) {
170
- return failure(input.id, 'STOREKIT_IOS_REQUIRED');
171
- }
172
- try {
173
- switch (input.method) {
174
- case 'commerce.getProducts': {
175
- const response = await sdk.getProducts({ productIds: [...byStoreId.keys()] });
176
- const products = [];
177
- for (const item of response.products) {
178
- const mapping = byStoreId.get(item.productId);
179
- if (mapping === undefined || mapping.type !== item.type) {
180
- continue;
181
- }
182
- products.push({
183
- id: mapping.id,
184
- type: mapping.type,
185
- title: item.title,
186
- description: item.description,
187
- price: { formatted: item.formattedPrice, currencyCode: item.currencyCode },
188
- });
189
- }
190
- return success(input.id, products);
191
- }
192
- case 'commerce.purchase': {
193
- const payload = input.payload;
194
- const productId = isRecord(payload) ? payload.productId : undefined;
195
- const mapping = typeof productId === 'string' ? byLogicalId.get(productId) : undefined;
196
- if (mapping === undefined) {
197
- return failure(input.id, 'STOREKIT_PRODUCT_UNKNOWN');
198
- }
199
- const appAccountToken = await getAppAccountToken();
200
- const outcome = await sdk.purchase({
201
- productId: mapping.storeId,
202
- appAccountToken,
203
- });
204
- return success(input.id, convertOutcome(outcome, mapping, appAccountToken));
205
- }
206
- case 'commerce.restore':
207
- await sdk.sync();
208
- return success(input.id, { restoredEntitlements: [] });
209
- case 'commerce.getEntitlements':
210
- return success(input.id, []);
211
- default:
212
- return failure(input.id, 'STOREKIT_METHOD_UNSUPPORTED');
213
- }
214
- }
215
- catch (error) {
216
- const code = isRecord(error) ? error.code : undefined;
217
- if (input.method === 'commerce.purchase' && code === 'STOREKIT_PURCHASE_UNCERTAIN') {
218
- return success(input.id, { status: 'pending', entitlementIds: [] });
219
- }
220
- if (typeof code === 'string' && /^STOREKIT_[A-Z_]+$/u.test(code)) {
221
- const retryable = code === 'STOREKIT_UNAVAILABLE'
222
- || code === 'STOREKIT_PRODUCT_LOOKUP_FAILED'
223
- || code === 'STOREKIT_SYNC_FAILED';
224
- return failure(input.id, code, retryable);
225
- }
226
- if (error instanceof TypeError) {
227
- return failure(input.id, 'STOREKIT_CONFIGURATION_ERROR');
228
- }
229
- return failure(input.id, 'STOREKIT_UNAVAILABLE', true);
230
- }
231
- }
232
- const grants = new Map();
233
- const inFlight = new Map();
234
- async function finishReserved(transactionId, ledgerEntryId) {
235
- let finished = false;
236
- try {
237
- finished = (await sdk.finishTransaction({ transactionId, ledgerEntryId })).finished;
238
- }
239
- finally {
240
- // Restore the pair after a rejected or failed native finish so recovery
241
- // can retry it, unless a newer recovery recorded the transaction again.
242
- if (!finished && !grants.has(transactionId)) {
243
- grants.set(transactionId, ledgerEntryId);
244
- }
245
- }
246
- return finished;
247
- }
248
- const provider = {
249
- id: 'apple-storekit',
250
- features: ['nativeIap'],
251
- methods: [
252
- 'commerce.getProducts',
253
- 'commerce.purchase',
254
- 'commerce.restore',
255
- 'commerce.getEntitlements',
256
- ],
257
- bridge: { request },
258
- async getAvailability() {
259
- if (!isIos()) {
260
- return { nativeIap: 'unsupported' };
261
- }
262
- try {
263
- await getAppAccountToken();
264
- const response = await sdk.getProducts({ productIds: [...byStoreId.keys()] });
265
- const available = response.products.some((item) => {
266
- const mapping = byStoreId.get(item.productId);
267
- return mapping !== undefined && mapping.type === item.type;
268
- });
269
- return { nativeIap: available ? 'available' : 'configuration-required' };
270
- }
271
- catch (error) {
272
- const code = isRecord(error) ? error.code : undefined;
273
- return { nativeIap: code === 'STOREKIT_INVALID_PRODUCTS'
274
- || code === 'STOREKIT_PRODUCT_UNAVAILABLE'
275
- || error instanceof TypeError
276
- ? 'configuration-required' : 'temporarily-unavailable' };
277
- }
278
- },
279
- getAppAccountToken,
280
- getRecoverableTransactions,
281
- async finishGrantedTransaction(input) {
282
- if (!isIos() || !transactionPattern.test(input.transactionId)
283
- || input.ledgerEntryId.trim() === '') {
284
- throw new TypeError('StoreKit finish requires a verified grant and transaction ID.');
285
- }
286
- const { transactionId, ledgerEntryId } = input;
287
- const active = inFlight.get(transactionId);
288
- if (active !== undefined && active.ledgerEntryId === ledgerEntryId) {
289
- return active.promise;
290
- }
291
- if (grants.get(transactionId) !== ledgerEntryId) {
292
- throw new TypeError('StoreKit finish requires the ledger entry the backend returned for this transaction via recoverStoreKitPurchases.');
293
- }
294
- // Consume the pair synchronously, before the first await, so an
295
- // overlapping finish for the same transaction cannot reach native twice.
296
- grants.delete(transactionId);
297
- const promise = finishReserved(transactionId, ledgerEntryId);
298
- inFlight.set(transactionId, { ledgerEntryId, promise });
299
- try {
300
- return await promise;
301
- }
302
- finally {
303
- if (inFlight.get(transactionId)?.promise === promise) {
304
- inFlight.delete(transactionId);
305
- }
306
- }
307
- },
308
- };
309
- verifiedGrants.set(provider, grants);
310
- return provider;
311
- }
312
- function convertOutcome(outcome, mapping, expectedAccountToken) {
313
- if (outcome.status !== 'purchased') {
314
- return { status: outcome.status, entitlementIds: [] };
315
- }
316
- if (outcome.transaction.productId !== mapping.storeId
317
- || outcome.transaction.type !== mapping.type
318
- || outcome.transaction.appAccountToken?.toLowerCase() !== expectedAccountToken
319
- || !validTransaction(outcome.transaction)) {
320
- throw Object.assign(new Error('StoreKit purchase evidence is invalid.'), {
321
- code: 'STOREKIT_EVIDENCE_INVALID',
322
- });
323
- }
324
- return toPurchaseResult(outcome.transaction);
325
- }
326
- function toPurchaseResult(transaction) {
327
- return {
328
- status: 'completed',
329
- transactionId: transaction.transactionId,
330
- entitlementIds: [],
331
- };
332
- }
333
- function validTransaction(value) {
334
- return transactionPattern.test(value.transactionId)
335
- && /^[0-9]{1,20}$/u.test(value.originalTransactionId)
336
- && productPattern.test(value.productId)
337
- && (value.type === 'consumable' || value.type === 'non_consumable')
338
- && Number.isFinite(Date.parse(value.purchasedAt))
339
- && typeof value.signedTransaction === 'string'
340
- && value.signedTransaction.length > 0
341
- && value.signedTransaction.length <= 128 * 1024
342
- && value.signedTransaction.split('.').length === 3
343
- && (value.revokedAt === undefined || Number.isFinite(Date.parse(value.revokedAt)))
344
- && (value.appAccountToken === undefined || uuidPattern.test(value.appAccountToken));
345
- }
346
- function isRecord(value) {
347
- return value !== null && typeof value === 'object' && !Array.isArray(value);
348
- }
349
- function success(id, data) {
350
- return { id, ok: true, data };
351
- }
352
- function failure(id, code, retryable = false) {
353
- return { id, ok: false, error: { code, message: code, retryable } };
354
- }