@mpgd/capacitor-storekit 0.2.3 → 0.3.1
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 +60 -105
- package/dist/commerce-profile.d.ts +24 -0
- package/dist/commerce-profile.js +51 -0
- package/dist/commerce-provider.d.ts +7 -12
- package/dist/commerce-provider.js +10 -13
- package/dist/definitions.d.ts +6 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/store-sdk.d.ts +3 -3
- package/dist/store-sdk.js +6 -14
- package/ios/Sources/CapacitorStoreKit/CapacitorStoreKitPlugin.swift +2 -2
- package/package.json +10 -10
- package/dist/provider.d.ts +0 -121
- package/dist/provider.js +0 -354
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 `
|
|
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
|
|
15
|
+
## Commerce provider
|
|
16
16
|
|
|
17
|
-
Commerce
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
|
111
|
-
bridge methods from it
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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,
|
|
@@ -152,87 +155,39 @@ whose shell lacks the native plugin still runs: the plugin answers
|
|
|
152
155
|
Capacitor's `UNIMPLEMENTED`, which the SDK reports as
|
|
153
156
|
`configuration-required`, so no purchase sheet opens.
|
|
154
157
|
|
|
155
|
-
##
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
`
|
|
188
|
-
authenticating the game player, and bind `playerId` to that authenticated
|
|
189
|
-
identity. Pass the same durable `GameServicesStore` instance and
|
|
190
|
-
`createGameServicesBackend(...).purchases` used for checkout. Its optional
|
|
191
|
-
`resolveOriginalIdempotencyKey` callback must query the game's durable
|
|
192
|
-
checkout journal for the **original** key associated with this player and
|
|
193
|
-
transaction. The adapter first checks existing App Store ledger evidence and
|
|
194
|
-
rejects cross-player, cross-product, cross-deployment and key collisions. If
|
|
195
|
-
neither a matching grant nor an original journal key exists, it returns
|
|
196
|
-
`pending` without attempting a new grant. Do not use the in-memory store for
|
|
197
|
-
production recovery. The game's HTTP/RPC endpoint must authenticate the
|
|
198
|
-
request; this helper does not expose one or authenticate clients.
|
|
199
|
-
For restored non-consumables whose new transaction ID differs from the
|
|
200
|
-
original, configure `restoredNonConsumables` with the same catalog and official
|
|
201
|
-
App Store evidence verifier, bundle ID and environment used by the game backend.
|
|
202
|
-
When no prior grant exists, also bind `deploymentTarget` on the server-side
|
|
203
|
-
recovery backend. The client-supplied target is not trusted to select a product;
|
|
204
|
-
without a bound target this restore stays pending until the game backend is
|
|
205
|
-
configured for its deployment. An existing grant can supply its recorded target.
|
|
206
|
-
The bundle/environment identify an existing grant for its deployment target
|
|
207
|
-
before product lookup; they must match the verifier configuration. The helper re-verifies
|
|
208
|
-
the **current** Apple transaction and looks up the prior grant by its canonical
|
|
209
|
-
original-transaction verification ID before reusing the original key. Without
|
|
210
|
-
that verifier, this restore remains pending rather than trusting a client-
|
|
211
|
-
reported original ID. A verifier that exceeds its timeout also remains pending.
|
|
212
|
-
The native provider forwards both IDs and product type.
|
|
213
|
-
For production-scale ledgers, implement the store's indexed
|
|
214
|
-
`findEntitlementTransactionByPlatformEvidence` and
|
|
215
|
-
`findEntitlementTransactionByIdempotency` methods, plus
|
|
216
|
-
`findEntitlementTransactionByEvidenceVerificationId` for restored
|
|
217
|
-
non-consumables. Without them, the helper
|
|
218
|
-
uses the same full-ledger scan fallback as the generic game-services server.
|
|
219
|
-
|
|
220
|
-
An existing grant is returned by the normal backend idempotent-retry path;
|
|
221
|
-
that path relies on the prior verified ledger record rather than fetching
|
|
222
|
-
fresh Apple status. Continue to process App Store revocations separately.
|
|
223
|
-
Permanently rejected and account-mismatched transactions are reported as
|
|
224
|
-
`rejected` and are **not** finished: the helper cannot prove content delivery
|
|
225
|
-
for them. Persist that terminal result in the game-owned operation journal and
|
|
226
|
-
do not run an automatic retry loop for it. A support flow may recheck a
|
|
227
|
-
corrected account or backend decision later. One malformed transaction is
|
|
228
|
-
reported separately and cannot block valid sibling transactions.
|
|
229
|
-
|
|
230
|
-
The iOS native plugin also emits `transactionUpdated` for delayed StoreKit
|
|
231
|
-
transactions. Call recovery after this event and on app launch because events
|
|
232
|
-
can be missed while the game is closed. `commerce.restore` intentionally
|
|
233
|
-
invokes user-initiated `AppStore.sync()` before returning no local entitlements;
|
|
234
|
-
call recovery afterward. Subscriptions are not supported by this package;
|
|
235
|
-
they require a separate subscription status and renewal flow.
|
|
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` |
|
|
236
191
|
|
|
237
192
|
The Kit tests use mocked StoreKit responses and a syntax-only local Swift
|
|
238
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
|
|
44
|
+
/** Registry id. Defaults to `apple-storekit`. */
|
|
49
45
|
readonly id?: string;
|
|
50
46
|
}
|
|
51
47
|
/**
|
|
52
|
-
* The commerce
|
|
53
|
-
* `createCapacitorPlatformGateway({ providers })`: the registry serves the
|
|
54
|
-
* `commerce.v2.*`
|
|
55
|
-
*
|
|
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
|
-
|
|
6
|
-
|
|
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:
|
|
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:
|
|
89
|
+
authority: storeKitCommerceAuthority,
|
|
92
90
|
mapError: { ...capacitorStoreKitErrorCodeMap, ...input.mapError },
|
|
93
91
|
});
|
|
94
92
|
}
|
|
95
93
|
/**
|
|
96
|
-
* The commerce
|
|
97
|
-
* `createCapacitorPlatformGateway({ providers })`: the registry serves the
|
|
98
|
-
* `commerce.v2.*`
|
|
99
|
-
*
|
|
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;
|
package/dist/definitions.d.ts
CHANGED
|
@@ -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
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* this plugin method directly. That
|
|
59
|
-
* code that controls the
|
|
60
|
-
* server-side
|
|
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';
|
package/dist/store-sdk.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { type StoreSdk, type
|
|
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
|
-
|
|
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:
|
|
248
|
+
store: appStoreStoreId,
|
|
248
249
|
capabilities: storeKitStoreSdkCapabilities,
|
|
249
250
|
async fetchProducts(request) {
|
|
250
251
|
requireIos();
|
|
@@ -406,19 +407,10 @@ export function createCapacitorStoreKitStoreSdk(input) {
|
|
|
406
407
|
},
|
|
407
408
|
};
|
|
408
409
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
durable: true,
|
|
414
|
-
subscription: false,
|
|
415
|
-
quantity: false,
|
|
416
|
-
}),
|
|
417
|
-
offers: false,
|
|
418
|
-
pending: true,
|
|
419
|
-
restore: true,
|
|
420
|
-
finish: 'client',
|
|
421
|
-
});
|
|
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 };
|
|
422
414
|
/**
|
|
423
415
|
* StoreKit plugin codes merged over `defaultStoreSdkErrorCodeMap`. Codes the
|
|
424
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
|
|
124
|
-
//
|
|
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.
|
|
3
|
+
"version": "0.3.1",
|
|
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.
|
|
42
|
-
"@mpgd/bridge": "^0.
|
|
43
|
-
"@mpgd/platform": "^0.
|
|
41
|
+
"@mpgd/adapter-capacitor": "^0.10.1",
|
|
42
|
+
"@mpgd/bridge": "^0.13.1",
|
|
43
|
+
"@mpgd/platform": "^0.23.1"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@capacitor/core": "8.5.2",
|
|
47
|
-
"ttsc": "0.
|
|
47
|
+
"ttsc": "0.32.1",
|
|
48
48
|
"typescript": "7.0.2",
|
|
49
|
-
"@mpgd/
|
|
50
|
-
"@mpgd/
|
|
51
|
-
"@mpgd/
|
|
52
|
-
"@mpgd/
|
|
49
|
+
"@mpgd/bridge": "0.13.1",
|
|
50
|
+
"@mpgd/game-services": "0.24.1",
|
|
51
|
+
"@mpgd/adapter-capacitor": "0.10.1",
|
|
52
|
+
"@mpgd/platform": "0.23.1"
|
|
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/
|
|
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
|
}
|
package/dist/provider.d.ts
DELETED
|
@@ -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
|
-
}
|