@mpgd/capacitor-storekit 0.1.9 → 0.2.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 +116 -0
- package/dist/commerce-provider.d.ts +58 -0
- package/dist/commerce-provider.js +108 -0
- package/dist/definitions.d.ts +7 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/provider.d.ts +15 -1
- package/dist/provider.js +6 -1
- package/dist/store-sdk.d.ts +82 -0
- package/dist/store-sdk.js +540 -0
- package/ios/Sources/CapacitorStoreKit/CapacitorStoreKitPlugin.swift +8 -3
- package/package.json +10 -9
package/README.md
CHANGED
|
@@ -12,6 +12,122 @@ 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
|
|
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.
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { createCapacitorPlatformGateway } from '@mpgd/adapter-capacitor';
|
|
26
|
+
import { createCapacitorStoreKitServiceProvider } from '@mpgd/capacitor-storekit';
|
|
27
|
+
|
|
28
|
+
export function createIosGateway(getAppAccountToken: () => Promise<string>) {
|
|
29
|
+
return createCapacitorPlatformGateway({
|
|
30
|
+
target: 'ios',
|
|
31
|
+
appVersion: '1.0.0',
|
|
32
|
+
buildId: 'game-build',
|
|
33
|
+
providers: [
|
|
34
|
+
createCapacitorStoreKitServiceProvider({
|
|
35
|
+
products: [
|
|
36
|
+
{ logicalId: 'gems_small', storeSku: 'com.example.game.gems.small', kind: 'consumable' },
|
|
37
|
+
{ logicalId: 'remove_ads', storeSku: 'com.example.game.remove.ads', kind: 'durable' },
|
|
38
|
+
],
|
|
39
|
+
// Authenticated, stable UUID; the backend's resolveAppAccountToken returns the same.
|
|
40
|
+
getAppAccountToken,
|
|
41
|
+
bundleId: 'com.example.game',
|
|
42
|
+
}),
|
|
43
|
+
],
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`createCapacitorStoreKitStoreSdk({ products, getAppAccountToken, sdk?, isIos?,
|
|
49
|
+
onDiagnostic? })` is the `StoreSdk`: `store: 'app-store'`; capabilities
|
|
50
|
+
consumable and durable, no subscription, no quantity, `pending` (Ask to
|
|
51
|
+
Buy), `restore` (`AppStore.sync()`), and `finish: 'client'`; `fetchProducts`
|
|
52
|
+
over `getProducts` (subscription lookups are empty without a native call);
|
|
53
|
+
`requestPurchase` over `purchase` with the `app-account-token` binding as the
|
|
54
|
+
`appAccountToken` (a missing binding fails with `ACCOUNT_BINDING_REQUIRED`,
|
|
55
|
+
a non-UUID one with `STOREKIT_CONFIGURATION_ERROR`, and the store never
|
|
56
|
+
opens), answering `purchased` with the transaction, `launched` for a pending
|
|
57
|
+
Ask to Buy, and `cancelled` for a dismissed sheet; `getAvailablePurchases`
|
|
58
|
+
over `getTransactions`, listing unfinished transactions once (a current
|
|
59
|
+
non-consumable entitlement the native side marks `unfinished: false` is not
|
|
60
|
+
listed, a `revokedAt` maps to `state: 'revoked'`, and one malformed
|
|
61
|
+
transaction is reported as a `purchaseError` without blocking its siblings);
|
|
62
|
+
`finishTransaction({ transactionId, ledgerEntryId })` over the native finish;
|
|
63
|
+
`restorePurchases` over `sync`; `transactionUpdated` as `purchaseUpdated`;
|
|
64
|
+
and the v1 availability probe as `getAvailability`, probing the configured
|
|
65
|
+
SKUs batch by batch until one is sold. Every purchase carries the
|
|
66
|
+
StoreKit-verified `signedTransaction` JWS as its `token`, the
|
|
67
|
+
`transactionId`, `originalTransactionId`, `purchasedAt`, `quantity: 1`, and
|
|
68
|
+
the `app-account-token` binding. `storeKitStoreSdkErrorCodeMap` maps every
|
|
69
|
+
`STOREKIT_*` plugin code onto the RFC "Errors" codes;
|
|
70
|
+
`STOREKIT_PURCHASE_UNCERTAIN` and `STOREKIT_UNKNOWN_RESULT` normalize to
|
|
71
|
+
`OUTCOME_UNKNOWN`, which `createStoreCommerceProvider` keeps recoverable (the
|
|
72
|
+
attempt stays `launched` and the reconciler resolves it from
|
|
73
|
+
`getAvailablePurchases`).
|
|
74
|
+
|
|
75
|
+
Before any purchase sheet opens, the SDK checks the store product against
|
|
76
|
+
the catalog's declared kind (fetching it once if `fetchProducts` has not
|
|
77
|
+
seen it): a mapping that declares `durable` for an App Store consumable, or
|
|
78
|
+
the reverse, fails with `STOREKIT_CONFIGURATION_ERROR`
|
|
79
|
+
(`CONFIGURATION_REQUIRED`), an unmapped SKU with `STOREKIT_PRODUCT_UNKNOWN`
|
|
80
|
+
and a SKU the store does not sell with `STOREKIT_PRODUCT_UNAVAILABLE` (both
|
|
81
|
+
`ITEM_UNAVAILABLE`), so the player is never charged for a purchase the
|
|
82
|
+
backend would reject with `APP_STORE_PRODUCT_TYPE_MISMATCH`; the purchased
|
|
83
|
+
transaction's type is checked against the mapping as well. The SDK also
|
|
84
|
+
answers only for the configured catalog and the active account, on the
|
|
85
|
+
polling and the event path alike: a transaction for a retired or otherwise
|
|
86
|
+
unmapped SKU, one whose signed `appAccountToken` is not the token
|
|
87
|
+
`getAppAccountToken` resolves (a shared device or an in-game account switch;
|
|
88
|
+
it is listed again once that player signs back in), or one whose StoreKit
|
|
89
|
+
type disagrees with the catalog kind is never listed or emitted, so the
|
|
90
|
+
reconciler never journals a purchase it cannot resolve or that belongs to
|
|
91
|
+
another player, and never blocks a valid sibling on one. Each exclusion is
|
|
92
|
+
reported through `onDiagnostic` (`unmapped-sku`, `account-mismatch`,
|
|
93
|
+
`kind-mismatch`); diagnostics are never a grant signal. The native
|
|
94
|
+
`transactionUpdated` listener is attached while either a `purchaseUpdated`
|
|
95
|
+
or a `purchaseError` subscriber exists.
|
|
96
|
+
|
|
97
|
+
`createCapacitorStoreKitCommerceProvider({ products, getAppAccountToken,
|
|
98
|
+
bundleId?, sdk?, isIos?, mapError?, onDiagnostic? })` builds the provider
|
|
99
|
+
with `authority: { evidence: 'client-token', accountBinding: 'required' }`
|
|
100
|
+
and evidence `{ schema: 'app-store.signed-transaction.v1', payload: {
|
|
101
|
+
signedTransaction, transactionId, originalTransactionId?, bundleId? } }`,
|
|
102
|
+
where `signedTransaction` is the same JWS as `StorePurchase.token`.
|
|
103
|
+
`@mpgd/game-services/app-store-verifier` verifies that JWS offline through
|
|
104
|
+
its `signedTransactionVerifier` (authenticity and transaction id binding)
|
|
105
|
+
and then, by default, decides the grant on the transaction's current status
|
|
106
|
+
from the App Store Server API, so a JWS retained from before a refund never
|
|
107
|
+
grants; see the backend guide for the opt-in offline-only mode. Subscription
|
|
108
|
+
mappings are rejected at construction. `createCapacitorStoreKitServiceProvider`
|
|
109
|
+
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 is the gate, and it calls
|
|
115
|
+
`finishTransaction` only after the backend ledger granted the purchase. The
|
|
116
|
+
v1 `finishGrantedTransaction` allow-list guard is not reproduced; the real
|
|
117
|
+
control stays server-side, in the verifier that checks the JWS before the
|
|
118
|
+
ledger grant, and in the native plugin never granting anything on finish.
|
|
119
|
+
The v1 recovery helpers (`recoverStoreKitPurchases`,
|
|
120
|
+
`getRecoverableTransactions`, `finishGrantedTransaction`) are deprecated in
|
|
121
|
+
favor of the single reconciler (RFC C1) and are removed in C4.
|
|
122
|
+
|
|
123
|
+
The conformance test runs every store vector whose profile the App Store
|
|
124
|
+
supports against a fake plugin, directly and through the gateway bridge path,
|
|
125
|
+
and lists each skipped vector with its reason (host or server finish,
|
|
126
|
+
quantity, server-push, an asynchronous store error event StoreKit never
|
|
127
|
+
emits, and a pending transaction state StoreKit never reports).
|
|
128
|
+
|
|
129
|
+
## v1 provider and recovery
|
|
130
|
+
|
|
15
131
|
On startup, account restoration, and after a pending or uncertain checkout,
|
|
16
132
|
call `recoverStoreKitPurchases({ provider, backend, playerId })`. It sends only
|
|
17
133
|
transactions whose `appAccountToken` matches the current authenticated game
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type CapacitorCommerceServiceProvider, type StoreCommerceProductMapping, type StoreCommerceProvider, type StoreSdkErrorCodeMap } from '@mpgd/adapter-capacitor';
|
|
2
|
+
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";
|
|
9
|
+
/** Payload of an `app-store.signed-transaction.v1` envelope. */
|
|
10
|
+
export interface AppStoreSignedTransactionEvidencePayload {
|
|
11
|
+
readonly signedTransaction: string;
|
|
12
|
+
readonly transactionId: string;
|
|
13
|
+
readonly originalTransactionId?: string;
|
|
14
|
+
readonly bundleId?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface CreateCapacitorStoreKitCommerceProviderInput {
|
|
17
|
+
/** Consumable and durable products only; StoreKit subscriptions are out of scope (RFC D-02). */
|
|
18
|
+
readonly products: readonly StoreCommerceProductMapping[];
|
|
19
|
+
/**
|
|
20
|
+
* Authenticated, stable UUID app account token of the current player. The
|
|
21
|
+
* backend's `resolveAppAccountToken` must resolve the same value.
|
|
22
|
+
*/
|
|
23
|
+
readonly getAppAccountToken: () => string | Promise<string>;
|
|
24
|
+
/** Recorded in the evidence payload when known; the backend checks the signed value anyway. */
|
|
25
|
+
readonly bundleId?: string;
|
|
26
|
+
readonly sdk?: CapacitorStoreKitStoreSdkPlugin;
|
|
27
|
+
readonly isIos?: () => boolean;
|
|
28
|
+
/** Extra store codes merged over the StoreKit table. */
|
|
29
|
+
readonly mapError?: StoreSdkErrorCodeMap;
|
|
30
|
+
/**
|
|
31
|
+
* Observes transactions the SDK keeps away from the reconciler: unmapped
|
|
32
|
+
* SKUs, transactions bound to another app account token, and StoreKit
|
|
33
|
+
* types that disagree with the catalog kind. Diagnostics only.
|
|
34
|
+
*/
|
|
35
|
+
readonly onDiagnostic?: (diagnostic: CapacitorStoreKitDiagnostic) => void;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Commerce v2 `CommerceProvider` for the App Store: `createStoreCommerceProvider`
|
|
39
|
+
* over `createCapacitorStoreKitStoreSdk` with the App Store profile,
|
|
40
|
+
* `authority: { evidence: 'client-token', accountBinding: 'required' }`, and
|
|
41
|
+
* `app-store.signed-transaction.v1` evidence whose `signedTransaction` is the
|
|
42
|
+
* same JWS as `StorePurchase.token`. The provider never grants: the game
|
|
43
|
+
* backend verifies the JWS, writes the ledger entry, and only then does the
|
|
44
|
+
* reconciler call `finishTransaction` with that entry id.
|
|
45
|
+
*/
|
|
46
|
+
export declare function createCapacitorStoreKitCommerceProvider(input: CreateCapacitorStoreKitCommerceProviderInput): StoreCommerceProvider;
|
|
47
|
+
export interface CreateCapacitorStoreKitServiceProviderInput extends CreateCapacitorStoreKitCommerceProviderInput {
|
|
48
|
+
/** Registry id. Defaults to `apple-storekit`, the v1 provider id. */
|
|
49
|
+
readonly id?: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
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.
|
|
57
|
+
*/
|
|
58
|
+
export declare function createCapacitorStoreKitServiceProvider(input: CreateCapacitorStoreKitServiceProviderInput): CapacitorCommerceServiceProvider;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createCommerceServiceProvider, createStoreCommerceProvider, } from '@mpgd/adapter-capacitor';
|
|
2
|
+
import { CommerceError } from '@mpgd/platform/commerce';
|
|
3
|
+
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';
|
|
10
|
+
/**
|
|
11
|
+
* Commerce v2 `CommerceProvider` for the App Store: `createStoreCommerceProvider`
|
|
12
|
+
* over `createCapacitorStoreKitStoreSdk` with the App Store profile,
|
|
13
|
+
* `authority: { evidence: 'client-token', accountBinding: 'required' }`, and
|
|
14
|
+
* `app-store.signed-transaction.v1` evidence whose `signedTransaction` is the
|
|
15
|
+
* same JWS as `StorePurchase.token`. The provider never grants: the game
|
|
16
|
+
* backend verifies the JWS, writes the ledger entry, and only then does the
|
|
17
|
+
* reconciler call `finishTransaction` with that entry id.
|
|
18
|
+
*/
|
|
19
|
+
export function createCapacitorStoreKitCommerceProvider(input) {
|
|
20
|
+
if (!Array.isArray(input.products) || input.products.length === 0) {
|
|
21
|
+
throw new TypeError('StoreKit requires at least one product mapping.');
|
|
22
|
+
}
|
|
23
|
+
for (const product of input.products) {
|
|
24
|
+
if (product.kind === 'subscription') {
|
|
25
|
+
throw new TypeError(`StoreKit product ${product.logicalId} is a subscription; only one-time products are sold.`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (typeof input.getAppAccountToken !== 'function') {
|
|
29
|
+
throw new TypeError('StoreKit requires getAppAccountToken.');
|
|
30
|
+
}
|
|
31
|
+
if (input.bundleId !== undefined
|
|
32
|
+
&& (typeof input.bundleId !== 'string' || input.bundleId.trim() === '')) {
|
|
33
|
+
throw new TypeError('StoreKit bundleId must be a non-empty string when provided.');
|
|
34
|
+
}
|
|
35
|
+
const bundleId = input.bundleId;
|
|
36
|
+
const sdk = createCapacitorStoreKitStoreSdk({
|
|
37
|
+
products: input.products.map((product) => ({
|
|
38
|
+
sku: product.storeSku,
|
|
39
|
+
kind: product.kind === 'consumable' ? 'consumable' : 'durable',
|
|
40
|
+
})),
|
|
41
|
+
getAppAccountToken: input.getAppAccountToken,
|
|
42
|
+
...(input.sdk === undefined ? {} : { sdk: input.sdk }),
|
|
43
|
+
...(input.isIos === undefined ? {} : { isIos: input.isIos }),
|
|
44
|
+
...(input.onDiagnostic === undefined ? {} : { onDiagnostic: input.onDiagnostic }),
|
|
45
|
+
});
|
|
46
|
+
return createStoreCommerceProvider({
|
|
47
|
+
sdk,
|
|
48
|
+
store: 'app-store',
|
|
49
|
+
products: input.products,
|
|
50
|
+
evidenceSchema: appStoreSignedTransactionEvidenceSchema,
|
|
51
|
+
evidencePayload: (purchase) => {
|
|
52
|
+
const payload = {
|
|
53
|
+
signedTransaction: purchase.token,
|
|
54
|
+
transactionId: purchase.transactionId,
|
|
55
|
+
...(purchase.originalTransactionId === undefined
|
|
56
|
+
? {}
|
|
57
|
+
: { originalTransactionId: purchase.originalTransactionId }),
|
|
58
|
+
...(bundleId === undefined ? {} : { bundleId }),
|
|
59
|
+
};
|
|
60
|
+
return { ...payload };
|
|
61
|
+
},
|
|
62
|
+
accountBinding: async () => {
|
|
63
|
+
let value;
|
|
64
|
+
try {
|
|
65
|
+
value = await input.getAppAccountToken();
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
throw new CommerceError('ACCOUNT_BINDING_REQUIRED', {
|
|
69
|
+
message: 'The StoreKit app account token could not be resolved.',
|
|
70
|
+
storeCode: 'STOREKIT_ACCOUNT_LOOKUP_FAILED',
|
|
71
|
+
cause: error,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
return {
|
|
76
|
+
kind: 'app-account-token',
|
|
77
|
+
value: readAppAccountToken({
|
|
78
|
+
kind: 'app-account-token',
|
|
79
|
+
...(typeof value === 'string' ? { value } : {}),
|
|
80
|
+
}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
throw new CommerceError('CONFIGURATION_REQUIRED', {
|
|
85
|
+
message: 'StoreKit requires a non-nil UUID app account token.',
|
|
86
|
+
storeCode: 'STOREKIT_CONFIGURATION_ERROR',
|
|
87
|
+
cause: error,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
authority: { evidence: 'client-token', accountBinding: 'required' },
|
|
92
|
+
mapError: { ...capacitorStoreKitErrorCodeMap, ...input.mapError },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
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.
|
|
101
|
+
*/
|
|
102
|
+
export function createCapacitorStoreKitServiceProvider(input) {
|
|
103
|
+
const { id, ...providerInput } = input;
|
|
104
|
+
return createCommerceServiceProvider({
|
|
105
|
+
id: id ?? 'apple-storekit',
|
|
106
|
+
commerce: createCapacitorStoreKitCommerceProvider(providerInput),
|
|
107
|
+
});
|
|
108
|
+
}
|
package/dist/definitions.d.ts
CHANGED
|
@@ -17,6 +17,13 @@ export interface StoreKitTransaction {
|
|
|
17
17
|
readonly revokedAt?: string;
|
|
18
18
|
/** StoreKit-verified JWS; the game backend independently verifies Apple data. */
|
|
19
19
|
readonly signedTransaction: string;
|
|
20
|
+
/**
|
|
21
|
+
* Present on `getTransactions` results: true for `Transaction.unfinished`
|
|
22
|
+
* entries, false for current non-consumable entitlements that are already
|
|
23
|
+
* finished. Omitted by native builds predating the field and by
|
|
24
|
+
* `purchase` and `transactionUpdated` payloads.
|
|
25
|
+
*/
|
|
26
|
+
readonly unfinished?: boolean;
|
|
20
27
|
}
|
|
21
28
|
export type StoreKitPurchaseOutcome = {
|
|
22
29
|
readonly status: 'purchased';
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/provider.d.ts
CHANGED
|
@@ -46,8 +46,17 @@ export interface CapacitorStoreKitProvider {
|
|
|
46
46
|
nativeIap: 'available' | 'configuration-required' | 'temporarily-unavailable' | 'unsupported';
|
|
47
47
|
}>>;
|
|
48
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
|
+
*/
|
|
49
54
|
getRecoverableTransactions(): Promise<StoreKitRecoverySnapshot>;
|
|
50
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
|
+
*
|
|
51
60
|
* Finish a transaction the backend already granted. The provider accepts
|
|
52
61
|
* only a transaction and ledger entry pair that recoverStoreKitPurchases
|
|
53
62
|
* recorded from a backend answer in this session, and consumes the pair
|
|
@@ -97,7 +106,12 @@ export interface RecoveredStoreKitPurchase {
|
|
|
97
106
|
readonly finishPending?: boolean;
|
|
98
107
|
readonly verification?: StoreKitPurchaseVerification;
|
|
99
108
|
}
|
|
100
|
-
/**
|
|
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
|
+
*/
|
|
101
115
|
export declare function recoverStoreKitPurchases(input: {
|
|
102
116
|
readonly provider: CapacitorStoreKitProvider;
|
|
103
117
|
readonly backend: StoreKitRecoveryBackend;
|
package/dist/provider.js
CHANGED
|
@@ -17,7 +17,12 @@ const transactionPattern = /^[1-9][0-9]{0,19}$/u;
|
|
|
17
17
|
* grants, and in the native plugin never granting anything on finish.
|
|
18
18
|
*/
|
|
19
19
|
const verifiedGrants = new WeakMap();
|
|
20
|
-
/**
|
|
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
|
+
*/
|
|
21
26
|
export async function recoverStoreKitPurchases(input) {
|
|
22
27
|
if (input.playerId.trim() === '') {
|
|
23
28
|
throw new TypeError('StoreKit recovery requires an authenticated player ID.');
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { type StoreSdk, type StoreSdkCapabilities, type StoreSdkErrorCodeMap, type StoreSdkPurchase } from '@mpgd/adapter-capacitor';
|
|
2
|
+
import type { StorePurchaseAccountBinding } from '@mpgd/platform/commerce';
|
|
3
|
+
import type { CapacitorStoreKitPlugin, StoreKitTransaction } from './definitions.js';
|
|
4
|
+
/** The plugin surface the commerce v2 SDK drives; `CapacitorStoreKit` by default. */
|
|
5
|
+
export type CapacitorStoreKitStoreSdkPlugin = Pick<CapacitorStoreKitPlugin, 'getProducts' | 'purchase' | 'getTransactions' | 'sync' | 'finishTransaction' | 'addListener'>;
|
|
6
|
+
/** One-time kinds the App Store sells; `durable` is a StoreKit non-consumable. */
|
|
7
|
+
export type StoreKitOneTimeKind = 'consumable' | 'durable';
|
|
8
|
+
/** A store SKU the SDK is configured to sell and the kind the catalog declares for it. */
|
|
9
|
+
export interface CapacitorStoreKitStoreSdkProduct {
|
|
10
|
+
readonly sku: string;
|
|
11
|
+
readonly kind: StoreKitOneTimeKind;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A transaction the SDK saw and deliberately kept away from the reconciler:
|
|
15
|
+
* one for a SKU outside the configured catalog, one bound to another app
|
|
16
|
+
* account token (shared device or in-game account switch), or one whose
|
|
17
|
+
* StoreKit type disagrees with the catalog kind. Diagnostics only; nothing
|
|
18
|
+
* here is a purchase the game may act on.
|
|
19
|
+
*/
|
|
20
|
+
export type CapacitorStoreKitDiagnostic = {
|
|
21
|
+
readonly type: 'unmapped-sku';
|
|
22
|
+
readonly sku: string;
|
|
23
|
+
readonly transactionId: string;
|
|
24
|
+
} | {
|
|
25
|
+
readonly type: 'account-mismatch';
|
|
26
|
+
readonly sku: string;
|
|
27
|
+
readonly transactionId: string;
|
|
28
|
+
readonly appAccountToken?: string;
|
|
29
|
+
} | {
|
|
30
|
+
readonly type: 'kind-mismatch';
|
|
31
|
+
readonly sku: string;
|
|
32
|
+
readonly transactionId: string;
|
|
33
|
+
readonly expected: StoreKitOneTimeKind;
|
|
34
|
+
readonly reported: StoreKitOneTimeKind;
|
|
35
|
+
};
|
|
36
|
+
export interface CreateCapacitorStoreKitStoreSdkInput {
|
|
37
|
+
/** The SKUs the catalog sells through the App Store, with their declared kinds. */
|
|
38
|
+
readonly products: readonly CapacitorStoreKitStoreSdkProduct[];
|
|
39
|
+
/**
|
|
40
|
+
* The active player's app account token (a UUID). Transactions bound to any
|
|
41
|
+
* other token are never listed or emitted; the backend binds the same value.
|
|
42
|
+
*/
|
|
43
|
+
readonly getAppAccountToken: () => string | Promise<string>;
|
|
44
|
+
readonly sdk?: CapacitorStoreKitStoreSdkPlugin;
|
|
45
|
+
readonly isIos?: () => boolean;
|
|
46
|
+
/** Observes transactions the SDK excludes; never a grant signal. */
|
|
47
|
+
readonly onDiagnostic?: (diagnostic: CapacitorStoreKitDiagnostic) => void;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Capacitor StoreKit `StoreSdk`: the commerce v2 surface over the native
|
|
51
|
+
* StoreKit 2 collector (RFC B3). The App Store profile is consumable plus
|
|
52
|
+
* durable, no subscriptions, no quantity, `pending` (Ask to Buy), `restore`
|
|
53
|
+
* (`AppStore.sync`), and `finish: 'client'` after the ledger grant. Every
|
|
54
|
+
* purchase carries the StoreKit-verified `signedTransaction` JWS as its
|
|
55
|
+
* `token`; the backend verifies that JWS itself and nothing here grants.
|
|
56
|
+
*
|
|
57
|
+
* The SDK answers only for the configured catalog and the active account: a
|
|
58
|
+
* transaction for an unmapped SKU, for another app account token, or whose
|
|
59
|
+
* StoreKit type disagrees with the catalog kind is reported through
|
|
60
|
+
* `onDiagnostic` and never reaches `getAvailablePurchases` or an event, so
|
|
61
|
+
* the reconciler never journals a purchase it cannot resolve or that belongs
|
|
62
|
+
* to another player.
|
|
63
|
+
*/
|
|
64
|
+
export declare function createCapacitorStoreKitStoreSdk(input: CreateCapacitorStoreKitStoreSdkInput): StoreSdk;
|
|
65
|
+
/** The App Store profile from the RFC "Capabilities" table. */
|
|
66
|
+
export declare const storeKitStoreSdkCapabilities: StoreSdkCapabilities;
|
|
67
|
+
/**
|
|
68
|
+
* StoreKit plugin codes merged over `defaultStoreSdkErrorCodeMap`. Codes the
|
|
69
|
+
* default table already carries (`STOREKIT_PURCHASE_UNCERTAIN`,
|
|
70
|
+
* `STOREKIT_UNAVAILABLE`, `STOREKIT_PRODUCT_UNKNOWN`,
|
|
71
|
+
* `STOREKIT_PRODUCT_UNAVAILABLE`, `STOREKIT_CONFIGURATION_ERROR`,
|
|
72
|
+
* `STOREKIT_EVIDENCE_INVALID`) are repeated here so the table reads complete.
|
|
73
|
+
*/
|
|
74
|
+
export declare const storeKitStoreSdkErrorCodeMap: StoreSdkErrorCodeMap;
|
|
75
|
+
/** The default table plus every StoreKit code; what the provider uses. */
|
|
76
|
+
export declare const capacitorStoreKitErrorCodeMap: StoreSdkErrorCodeMap;
|
|
77
|
+
/** Normalizes a required `app-account-token` binding to the lowercase UUID StoreKit expects. */
|
|
78
|
+
export declare function readAppAccountToken(binding: StorePurchaseAccountBinding | undefined): string;
|
|
79
|
+
/** Maps a verified StoreKit transaction to the SDK purchase: the JWS is the token. */
|
|
80
|
+
export declare function toSdkPurchase(transaction: StoreKitTransaction): StoreSdkPurchase;
|
|
81
|
+
/** Shape check of a transaction the native side reported; the backend verifies the JWS. */
|
|
82
|
+
export declare function isValidStoreKitTransaction(value: unknown): value is StoreKitTransaction;
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { Capacitor } from '@capacitor/core';
|
|
2
|
+
import { defaultStoreSdkErrorCodeMap, } from '@mpgd/adapter-capacitor';
|
|
3
|
+
import { CapacitorStoreKit } from './plugin.js';
|
|
4
|
+
/**
|
|
5
|
+
* Capacitor StoreKit `StoreSdk`: the commerce v2 surface over the native
|
|
6
|
+
* StoreKit 2 collector (RFC B3). The App Store profile is consumable plus
|
|
7
|
+
* durable, no subscriptions, no quantity, `pending` (Ask to Buy), `restore`
|
|
8
|
+
* (`AppStore.sync`), and `finish: 'client'` after the ledger grant. Every
|
|
9
|
+
* purchase carries the StoreKit-verified `signedTransaction` JWS as its
|
|
10
|
+
* `token`; the backend verifies that JWS itself and nothing here grants.
|
|
11
|
+
*
|
|
12
|
+
* The SDK answers only for the configured catalog and the active account: a
|
|
13
|
+
* transaction for an unmapped SKU, for another app account token, or whose
|
|
14
|
+
* StoreKit type disagrees with the catalog kind is reported through
|
|
15
|
+
* `onDiagnostic` and never reaches `getAvailablePurchases` or an event, so
|
|
16
|
+
* the reconciler never journals a purchase it cannot resolve or that belongs
|
|
17
|
+
* to another player.
|
|
18
|
+
*/
|
|
19
|
+
export function createCapacitorStoreKitStoreSdk(input) {
|
|
20
|
+
const sdk = input.sdk ?? CapacitorStoreKit;
|
|
21
|
+
const isIos = input.isIos ?? (() => Capacitor.getPlatform() === 'ios');
|
|
22
|
+
const onDiagnostic = input.onDiagnostic ?? (() => undefined);
|
|
23
|
+
if (typeof input.getAppAccountToken !== 'function') {
|
|
24
|
+
throw new TypeError('StoreKit requires getAppAccountToken.');
|
|
25
|
+
}
|
|
26
|
+
const kinds = new Map();
|
|
27
|
+
for (const product of input.products) {
|
|
28
|
+
if (!isRecord(product) || !productPattern.test(String(product.sku))) {
|
|
29
|
+
throw new TypeError(`StoreKit product id ${String(product?.sku)} is invalid.`);
|
|
30
|
+
}
|
|
31
|
+
if (product.kind !== 'consumable' && product.kind !== 'durable') {
|
|
32
|
+
throw new TypeError(`StoreKit product ${product.sku} must be consumable or durable.`);
|
|
33
|
+
}
|
|
34
|
+
if (kinds.has(product.sku)) {
|
|
35
|
+
throw new TypeError(`StoreKit product ${product.sku} is configured twice.`);
|
|
36
|
+
}
|
|
37
|
+
kinds.set(product.sku, product.kind);
|
|
38
|
+
}
|
|
39
|
+
const productIds = [...kinds.keys()];
|
|
40
|
+
// Store products seen by `fetchProducts` or the availability probe; the
|
|
41
|
+
// pre-purchase kind check reads them instead of a second lookup.
|
|
42
|
+
const knownProducts = new Map();
|
|
43
|
+
const unresolvedAccountToken = (error) => Object.assign(storeKitError('ACCOUNT_BINDING_REQUIRED', 'The StoreKit app account token could not be resolved.', false), { cause: error });
|
|
44
|
+
const normalizeAccountToken = (value) => readAppAccountToken({
|
|
45
|
+
kind: 'app-account-token',
|
|
46
|
+
...(typeof value === 'string' ? { value } : {}),
|
|
47
|
+
});
|
|
48
|
+
/**
|
|
49
|
+
* The active account token; a synchronous resolver answers synchronously so
|
|
50
|
+
* native events are admitted in the same tick they arrive, an asynchronous
|
|
51
|
+
* one defers the admission.
|
|
52
|
+
*/
|
|
53
|
+
const resolveAppAccountToken = () => {
|
|
54
|
+
let value;
|
|
55
|
+
try {
|
|
56
|
+
value = input.getAppAccountToken();
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
throw unresolvedAccountToken(error);
|
|
60
|
+
}
|
|
61
|
+
if (isPromiseLike(value)) {
|
|
62
|
+
return Promise.resolve(value).then(normalizeAccountToken, (error) => {
|
|
63
|
+
throw unresolvedAccountToken(error);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return normalizeAccountToken(value);
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Keeps a verified transaction only when it is sellable to the active
|
|
70
|
+
* player: configured SKU, declared kind, and the active account token.
|
|
71
|
+
*/
|
|
72
|
+
const admit = (transaction, appAccountToken) => {
|
|
73
|
+
const expected = kinds.get(transaction.productId);
|
|
74
|
+
if (expected === undefined) {
|
|
75
|
+
onDiagnostic({
|
|
76
|
+
type: 'unmapped-sku',
|
|
77
|
+
sku: transaction.productId,
|
|
78
|
+
transactionId: transaction.transactionId,
|
|
79
|
+
});
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
if (transaction.appAccountToken?.toLowerCase() !== appAccountToken) {
|
|
83
|
+
onDiagnostic({
|
|
84
|
+
type: 'account-mismatch',
|
|
85
|
+
sku: transaction.productId,
|
|
86
|
+
transactionId: transaction.transactionId,
|
|
87
|
+
...(transaction.appAccountToken === undefined
|
|
88
|
+
? {}
|
|
89
|
+
: { appAccountToken: transaction.appAccountToken.toLowerCase() }),
|
|
90
|
+
});
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
const reported = kindOf(transaction.type);
|
|
94
|
+
if (reported !== expected) {
|
|
95
|
+
onDiagnostic({
|
|
96
|
+
type: 'kind-mismatch',
|
|
97
|
+
sku: transaction.productId,
|
|
98
|
+
transactionId: transaction.transactionId,
|
|
99
|
+
expected,
|
|
100
|
+
reported,
|
|
101
|
+
});
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
};
|
|
106
|
+
const listeners = new Map();
|
|
107
|
+
let nativeHandle;
|
|
108
|
+
const emit = (event) => {
|
|
109
|
+
for (const listener of [...(listeners.get(event.type) ?? [])]) {
|
|
110
|
+
listener(event);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const malformed = (transaction) => {
|
|
114
|
+
emit({
|
|
115
|
+
type: 'purchaseError',
|
|
116
|
+
...(isRecord(transaction) && typeof transaction.productId === 'string'
|
|
117
|
+
? { sku: transaction.productId }
|
|
118
|
+
: {}),
|
|
119
|
+
error: evidenceInvalid(),
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
// Native events are filtered against the active account token. A deferred
|
|
123
|
+
// admission (asynchronous resolver) queues later events behind it so the
|
|
124
|
+
// delivery order is kept.
|
|
125
|
+
let pendingAdmissions = Promise.resolve();
|
|
126
|
+
let pendingAdmissionCount = 0;
|
|
127
|
+
const admitEvent = (transaction, appAccountToken) => {
|
|
128
|
+
if (admit(transaction, appAccountToken)) {
|
|
129
|
+
emit({ type: 'purchaseUpdated', purchase: toSdkPurchase(transaction) });
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const reportUnresolved = (transaction, error) => {
|
|
133
|
+
emit({ type: 'purchaseError', sku: transaction.productId, error: error });
|
|
134
|
+
};
|
|
135
|
+
const onTransactionUpdated = (transaction) => {
|
|
136
|
+
if (!isValidStoreKitTransaction(transaction)) {
|
|
137
|
+
malformed(transaction);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (kinds.get(transaction.productId) === undefined) {
|
|
141
|
+
// Unmapped before the token is read: no account lookup for a SKU the
|
|
142
|
+
// catalog does not sell.
|
|
143
|
+
admit(transaction, '');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
let token;
|
|
147
|
+
try {
|
|
148
|
+
token = resolveAppAccountToken();
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
reportUnresolved(transaction, error);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (typeof token === 'string' && pendingAdmissionCount === 0) {
|
|
155
|
+
admitEvent(transaction, token);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
pendingAdmissionCount += 1;
|
|
159
|
+
pendingAdmissions = pendingAdmissions
|
|
160
|
+
.then(() => token)
|
|
161
|
+
.then((resolved) => {
|
|
162
|
+
admitEvent(transaction, resolved);
|
|
163
|
+
}, (error) => {
|
|
164
|
+
reportUnresolved(transaction, error);
|
|
165
|
+
})
|
|
166
|
+
.finally(() => {
|
|
167
|
+
pendingAdmissionCount -= 1;
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
const transactionSubscribers = () => (listeners.get('purchaseUpdated')?.size ?? 0) + (listeners.get('purchaseError')?.size ?? 0);
|
|
171
|
+
const attachNative = () => {
|
|
172
|
+
if (nativeHandle !== undefined || !isIos()) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// Registered synchronously so an event delivered right after the first
|
|
176
|
+
// subscriber attaches is not lost; a rejected attach is forgotten so the
|
|
177
|
+
// next subscriber retries it.
|
|
178
|
+
let handle;
|
|
179
|
+
try {
|
|
180
|
+
handle = sdk.addListener('transactionUpdated', onTransactionUpdated);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
nativeHandle = handle;
|
|
186
|
+
handle.catch(() => {
|
|
187
|
+
if (nativeHandle === handle) {
|
|
188
|
+
nativeHandle = undefined;
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
const detachNative = () => {
|
|
193
|
+
const handle = nativeHandle;
|
|
194
|
+
nativeHandle = undefined;
|
|
195
|
+
handle?.then((value) => value.remove()).catch(() => undefined);
|
|
196
|
+
};
|
|
197
|
+
const requireIos = () => {
|
|
198
|
+
if (!isIos()) {
|
|
199
|
+
throw storeKitError('STOREKIT_IOS_REQUIRED', 'StoreKit is only available on iOS.', false);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
const lookupProducts = async (skus) => {
|
|
203
|
+
const products = [];
|
|
204
|
+
for (let index = 0; index < skus.length; index += maxProductIdsPerLookup) {
|
|
205
|
+
const response = await sdk.getProducts({
|
|
206
|
+
productIds: skus.slice(index, index + maxProductIdsPerLookup),
|
|
207
|
+
});
|
|
208
|
+
if (!isRecord(response) || !Array.isArray(response.products)) {
|
|
209
|
+
throw evidenceInvalid('StoreKit returned an invalid product list.');
|
|
210
|
+
}
|
|
211
|
+
for (const product of response.products) {
|
|
212
|
+
if (!isValidStoreKitProduct(product)) {
|
|
213
|
+
throw evidenceInvalid('StoreKit returned an invalid product.');
|
|
214
|
+
}
|
|
215
|
+
knownProducts.set(product.productId, product);
|
|
216
|
+
products.push(product);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return products;
|
|
220
|
+
};
|
|
221
|
+
/**
|
|
222
|
+
* The store product for a configured SKU, checked against the declared
|
|
223
|
+
* kind before any purchase sheet opens: a catalog that declares the wrong
|
|
224
|
+
* one-time kind fails here instead of charging the player for a purchase
|
|
225
|
+
* the backend would reject with `APP_STORE_PRODUCT_TYPE_MISMATCH`.
|
|
226
|
+
*/
|
|
227
|
+
const assertSellable = async (sku) => {
|
|
228
|
+
const expected = kinds.get(sku);
|
|
229
|
+
if (expected === undefined) {
|
|
230
|
+
throw storeKitError('STOREKIT_PRODUCT_UNKNOWN', `StoreKit product ${sku} is not in the configured catalog.`, false);
|
|
231
|
+
}
|
|
232
|
+
const product = knownProducts.get(sku)
|
|
233
|
+
?? (await lookupProducts([sku])).find((candidate) => candidate.productId === sku);
|
|
234
|
+
if (product === undefined) {
|
|
235
|
+
throw storeKitError('STOREKIT_PRODUCT_UNAVAILABLE', `StoreKit product ${sku} is not available in the store.`, false);
|
|
236
|
+
}
|
|
237
|
+
const reported = kindOf(product.type);
|
|
238
|
+
if (reported !== expected) {
|
|
239
|
+
throw storeKitError('STOREKIT_CONFIGURATION_ERROR', `StoreKit product ${sku} is ${product.type}; the catalog declares ${expected}.`, false);
|
|
240
|
+
}
|
|
241
|
+
return expected;
|
|
242
|
+
};
|
|
243
|
+
// Overlapping finishes for the same transaction and ledger entry share one
|
|
244
|
+
// native call; the v2 reconciler is the gate that authorizes the call.
|
|
245
|
+
const inFlightFinishes = new Map();
|
|
246
|
+
return {
|
|
247
|
+
store: 'app-store',
|
|
248
|
+
capabilities: storeKitStoreSdkCapabilities,
|
|
249
|
+
async fetchProducts(request) {
|
|
250
|
+
requireIos();
|
|
251
|
+
if (request.type === 'subscription' || request.skus.length === 0) {
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
const products = [];
|
|
255
|
+
for (const product of await lookupProducts(request.skus)) {
|
|
256
|
+
products.push({
|
|
257
|
+
sku: product.productId,
|
|
258
|
+
kind: kindOf(product.type),
|
|
259
|
+
title: product.title,
|
|
260
|
+
description: product.description,
|
|
261
|
+
price: { formatted: product.formattedPrice, currencyCode: product.currencyCode },
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return products;
|
|
265
|
+
},
|
|
266
|
+
async requestPurchase(request) {
|
|
267
|
+
requireIos();
|
|
268
|
+
const appAccountToken = readAppAccountToken(request.accountBinding);
|
|
269
|
+
const expected = await assertSellable(request.sku);
|
|
270
|
+
const outcome = await sdk.purchase({ productId: request.sku, appAccountToken });
|
|
271
|
+
if (!isRecord(outcome)) {
|
|
272
|
+
throw evidenceInvalid('StoreKit returned an invalid purchase result.');
|
|
273
|
+
}
|
|
274
|
+
switch (outcome.status) {
|
|
275
|
+
case 'purchased': {
|
|
276
|
+
const transaction = outcome.transaction;
|
|
277
|
+
if (!isValidStoreKitTransaction(transaction)
|
|
278
|
+
|| transaction.productId !== request.sku
|
|
279
|
+
|| kindOf(transaction.type) !== expected
|
|
280
|
+
|| transaction.appAccountToken?.toLowerCase() !== appAccountToken) {
|
|
281
|
+
throw evidenceInvalid('StoreKit purchase evidence is invalid.');
|
|
282
|
+
}
|
|
283
|
+
return { outcome: 'purchased', purchase: toSdkPurchase(transaction) };
|
|
284
|
+
}
|
|
285
|
+
case 'pending':
|
|
286
|
+
// Ask to Buy: the transaction arrives through `transactionUpdated`.
|
|
287
|
+
return { outcome: 'launched' };
|
|
288
|
+
case 'cancelled':
|
|
289
|
+
return {
|
|
290
|
+
outcome: 'cancelled',
|
|
291
|
+
error: { code: 'USER_CANCELLED', retryable: false, storeCode: 'userCancelled' },
|
|
292
|
+
};
|
|
293
|
+
default:
|
|
294
|
+
throw evidenceInvalid('StoreKit returned an invalid purchase result.');
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
async getAvailablePurchases() {
|
|
298
|
+
// Pending (Ask to Buy) transactions never appear in `Transaction.unfinished`
|
|
299
|
+
// before approval, so `includePending` has nothing to filter here.
|
|
300
|
+
requireIos();
|
|
301
|
+
const appAccountToken = await resolveAppAccountToken();
|
|
302
|
+
const response = await sdk.getTransactions();
|
|
303
|
+
if (!isRecord(response) || !Array.isArray(response.transactions)) {
|
|
304
|
+
throw evidenceInvalid('StoreKit returned an invalid transaction list.');
|
|
305
|
+
}
|
|
306
|
+
const purchases = [];
|
|
307
|
+
const seen = new Set();
|
|
308
|
+
for (const transaction of response.transactions) {
|
|
309
|
+
if (!isValidStoreKitTransaction(transaction)) {
|
|
310
|
+
// One malformed transaction is reported on its own and never blocks
|
|
311
|
+
// a valid sibling.
|
|
312
|
+
malformed(transaction);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
// Available means unfinished: a current entitlement the native side
|
|
316
|
+
// marks finished is not listed. A build without the flag lists every
|
|
317
|
+
// transaction, which the backend answers idempotently.
|
|
318
|
+
if (transaction.unfinished === false || seen.has(transaction.transactionId)) {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
seen.add(transaction.transactionId);
|
|
322
|
+
if (admit(transaction, appAccountToken)) {
|
|
323
|
+
purchases.push(toSdkPurchase(transaction));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return purchases;
|
|
327
|
+
},
|
|
328
|
+
async finishTransaction(request) {
|
|
329
|
+
requireIos();
|
|
330
|
+
const { transactionId, ledgerEntryId } = request;
|
|
331
|
+
if (typeof transactionId !== 'string' || !transactionPattern.test(transactionId)
|
|
332
|
+
|| typeof ledgerEntryId !== 'string' || ledgerEntryId.trim() === '') {
|
|
333
|
+
throw storeKitError('STOREKIT_FINISH_REQUIRES_GRANT', 'StoreKit finish requires a transaction id and the ledger entry that granted it.', false);
|
|
334
|
+
}
|
|
335
|
+
const active = inFlightFinishes.get(transactionId);
|
|
336
|
+
if (active !== undefined && active.ledgerEntryId === ledgerEntryId) {
|
|
337
|
+
return active.promise;
|
|
338
|
+
}
|
|
339
|
+
const promise = (async () => {
|
|
340
|
+
const reported = await sdk.finishTransaction({ transactionId, ledgerEntryId });
|
|
341
|
+
if (!isRecord(reported) || typeof reported.finished !== 'boolean') {
|
|
342
|
+
throw evidenceInvalid('StoreKit returned an invalid finish result.');
|
|
343
|
+
}
|
|
344
|
+
return { finished: reported.finished };
|
|
345
|
+
})();
|
|
346
|
+
inFlightFinishes.set(transactionId, { ledgerEntryId, promise });
|
|
347
|
+
try {
|
|
348
|
+
return await promise;
|
|
349
|
+
}
|
|
350
|
+
finally {
|
|
351
|
+
if (inFlightFinishes.get(transactionId)?.promise === promise) {
|
|
352
|
+
inFlightFinishes.delete(transactionId);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
async restorePurchases() {
|
|
357
|
+
requireIos();
|
|
358
|
+
const reported = await sdk.sync();
|
|
359
|
+
if (!isRecord(reported) || reported.synced !== true) {
|
|
360
|
+
throw storeKitError('STOREKIT_SYNC_FAILED', 'StoreKit account synchronization did not complete.', true);
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
addListener(eventName, listener) {
|
|
364
|
+
const set = listeners.get(eventName) ?? new Set();
|
|
365
|
+
listeners.set(eventName, set);
|
|
366
|
+
set.add(listener);
|
|
367
|
+
// Both events derive from native transaction updates (a malformed update
|
|
368
|
+
// is a purchaseError), so either subscriber needs the native listener.
|
|
369
|
+
if (transactionSubscribers() > 0) {
|
|
370
|
+
attachNative();
|
|
371
|
+
}
|
|
372
|
+
return () => {
|
|
373
|
+
set.delete(listener);
|
|
374
|
+
if (transactionSubscribers() === 0) {
|
|
375
|
+
detachNative();
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
},
|
|
379
|
+
async getAvailability() {
|
|
380
|
+
if (!isIos()) {
|
|
381
|
+
return 'unsupported';
|
|
382
|
+
}
|
|
383
|
+
if (productIds.length === 0) {
|
|
384
|
+
return 'configuration-required';
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
// Probe batch by batch until one configured product is sold; only a
|
|
388
|
+
// catalog with no sellable product at all is a configuration problem.
|
|
389
|
+
for (let index = 0; index < productIds.length; index += maxProductIdsPerLookup) {
|
|
390
|
+
const products = await lookupProducts(productIds.slice(index, index + maxProductIdsPerLookup));
|
|
391
|
+
if (products.some((product) => kinds.has(product.productId))) {
|
|
392
|
+
return 'available';
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return 'configuration-required';
|
|
396
|
+
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
const code = isRecord(error) ? error.code : undefined;
|
|
399
|
+
return code === 'STOREKIT_INVALID_PRODUCTS' || code === 'STOREKIT_PRODUCT_UNAVAILABLE'
|
|
400
|
+
? 'configuration-required'
|
|
401
|
+
: 'temporarily-unavailable';
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
}
|
|
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
|
+
});
|
|
419
|
+
/**
|
|
420
|
+
* StoreKit plugin codes merged over `defaultStoreSdkErrorCodeMap`. Codes the
|
|
421
|
+
* default table already carries (`STOREKIT_PURCHASE_UNCERTAIN`,
|
|
422
|
+
* `STOREKIT_UNAVAILABLE`, `STOREKIT_PRODUCT_UNKNOWN`,
|
|
423
|
+
* `STOREKIT_PRODUCT_UNAVAILABLE`, `STOREKIT_CONFIGURATION_ERROR`,
|
|
424
|
+
* `STOREKIT_EVIDENCE_INVALID`) are repeated here so the table reads complete.
|
|
425
|
+
*/
|
|
426
|
+
export const storeKitStoreSdkErrorCodeMap = Object.freeze({
|
|
427
|
+
STOREKIT_PURCHASE_UNCERTAIN: 'OUTCOME_UNKNOWN',
|
|
428
|
+
STOREKIT_UNKNOWN_RESULT: 'OUTCOME_UNKNOWN',
|
|
429
|
+
STOREKIT_UNAVAILABLE: 'STORE_UNAVAILABLE',
|
|
430
|
+
STOREKIT_IOS_REQUIRED: 'STORE_UNAVAILABLE',
|
|
431
|
+
STOREKIT_PRODUCT_LOOKUP_FAILED: 'STORE_UNAVAILABLE',
|
|
432
|
+
STOREKIT_SYNC_FAILED: 'STORE_UNAVAILABLE',
|
|
433
|
+
STOREKIT_PRODUCT_UNKNOWN: 'ITEM_UNAVAILABLE',
|
|
434
|
+
STOREKIT_PRODUCT_UNAVAILABLE: 'ITEM_UNAVAILABLE',
|
|
435
|
+
STOREKIT_CONFIGURATION_ERROR: 'CONFIGURATION_REQUIRED',
|
|
436
|
+
STOREKIT_INVALID_PRODUCTS: 'CONFIGURATION_REQUIRED',
|
|
437
|
+
STOREKIT_INVALID_PURCHASE: 'CONFIGURATION_REQUIRED',
|
|
438
|
+
STOREKIT_FINISH_REQUIRES_GRANT: 'CONFIGURATION_REQUIRED',
|
|
439
|
+
STOREKIT_EVIDENCE_INVALID: 'INVALID_RESPONSE',
|
|
440
|
+
STOREKIT_TRANSACTION_UNVERIFIED: 'INVALID_RESPONSE',
|
|
441
|
+
});
|
|
442
|
+
/** The default table plus every StoreKit code; what the provider uses. */
|
|
443
|
+
export const capacitorStoreKitErrorCodeMap = Object.freeze({
|
|
444
|
+
...defaultStoreSdkErrorCodeMap,
|
|
445
|
+
...storeKitStoreSdkErrorCodeMap,
|
|
446
|
+
});
|
|
447
|
+
const maxProductIdsPerLookup = 100;
|
|
448
|
+
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;
|
|
449
|
+
const nilUuid = '00000000-0000-0000-0000-000000000000';
|
|
450
|
+
const productPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
451
|
+
// Shape only: the native side parses the UInt64 id and verified the JWS; the
|
|
452
|
+
// backend verifies the JWS again. The patterns stay open so a fake plugin in
|
|
453
|
+
// the conformance vectors can use the vector ids.
|
|
454
|
+
const transactionPattern = /^[A-Za-z0-9._:-]{1,64}$/u;
|
|
455
|
+
const maxSignedTransactionLength = 128 * 1024;
|
|
456
|
+
/** Normalizes a required `app-account-token` binding to the lowercase UUID StoreKit expects. */
|
|
457
|
+
export function readAppAccountToken(binding) {
|
|
458
|
+
if (binding === undefined) {
|
|
459
|
+
throw storeKitError('ACCOUNT_BINDING_REQUIRED', 'StoreKit requires an app account token before a purchase.', false);
|
|
460
|
+
}
|
|
461
|
+
if (binding.kind !== 'app-account-token') {
|
|
462
|
+
throw storeKitError('STOREKIT_CONFIGURATION_ERROR', `StoreKit requires an app-account-token binding, not ${binding.kind}.`, false);
|
|
463
|
+
}
|
|
464
|
+
const value = typeof binding.value === 'string' ? binding.value.toLowerCase() : '';
|
|
465
|
+
if (!uuidPattern.test(value) || value === nilUuid) {
|
|
466
|
+
throw storeKitError('STOREKIT_CONFIGURATION_ERROR', 'StoreKit requires a non-nil UUID app account token.', false);
|
|
467
|
+
}
|
|
468
|
+
return value;
|
|
469
|
+
}
|
|
470
|
+
/** Maps a verified StoreKit transaction to the SDK purchase: the JWS is the token. */
|
|
471
|
+
export function toSdkPurchase(transaction) {
|
|
472
|
+
return {
|
|
473
|
+
sku: transaction.productId,
|
|
474
|
+
transactionId: transaction.transactionId,
|
|
475
|
+
originalTransactionId: transaction.originalTransactionId,
|
|
476
|
+
state: transaction.revokedAt === undefined ? 'purchased' : 'revoked',
|
|
477
|
+
purchasedAt: transaction.purchasedAt,
|
|
478
|
+
token: transaction.signedTransaction,
|
|
479
|
+
quantity: 1,
|
|
480
|
+
...(transaction.appAccountToken === undefined
|
|
481
|
+
? {}
|
|
482
|
+
: {
|
|
483
|
+
accountBinding: {
|
|
484
|
+
kind: 'app-account-token',
|
|
485
|
+
value: transaction.appAccountToken.toLowerCase(),
|
|
486
|
+
},
|
|
487
|
+
}),
|
|
488
|
+
raw: {
|
|
489
|
+
type: transaction.type,
|
|
490
|
+
...(transaction.revokedAt === undefined ? {} : { revokedAt: transaction.revokedAt }),
|
|
491
|
+
...(transaction.unfinished === undefined ? {} : { unfinished: transaction.unfinished }),
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
/** Shape check of a transaction the native side reported; the backend verifies the JWS. */
|
|
496
|
+
export function isValidStoreKitTransaction(value) {
|
|
497
|
+
if (!isRecord(value)) {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
return typeof value.transactionId === 'string' && transactionPattern.test(value.transactionId)
|
|
501
|
+
&& typeof value.originalTransactionId === 'string'
|
|
502
|
+
&& transactionPattern.test(value.originalTransactionId)
|
|
503
|
+
&& typeof value.productId === 'string' && productPattern.test(value.productId)
|
|
504
|
+
&& (value.type === 'consumable' || value.type === 'non_consumable')
|
|
505
|
+
&& typeof value.purchasedAt === 'string' && Number.isFinite(Date.parse(value.purchasedAt))
|
|
506
|
+
&& typeof value.signedTransaction === 'string'
|
|
507
|
+
&& value.signedTransaction.length > 0
|
|
508
|
+
&& value.signedTransaction.length <= maxSignedTransactionLength
|
|
509
|
+
&& (value.revokedAt === undefined
|
|
510
|
+
|| (typeof value.revokedAt === 'string' && Number.isFinite(Date.parse(value.revokedAt))))
|
|
511
|
+
&& (value.appAccountToken === undefined
|
|
512
|
+
|| (typeof value.appAccountToken === 'string' && uuidPattern.test(value.appAccountToken)))
|
|
513
|
+
&& (value.unfinished === undefined || typeof value.unfinished === 'boolean');
|
|
514
|
+
}
|
|
515
|
+
function kindOf(type) {
|
|
516
|
+
return type === 'consumable' ? 'consumable' : 'durable';
|
|
517
|
+
}
|
|
518
|
+
function isValidStoreKitProduct(value) {
|
|
519
|
+
return isRecord(value)
|
|
520
|
+
&& typeof value.productId === 'string' && productPattern.test(value.productId)
|
|
521
|
+
&& (value.type === 'consumable' || value.type === 'non_consumable')
|
|
522
|
+
&& typeof value.title === 'string'
|
|
523
|
+
&& typeof value.description === 'string'
|
|
524
|
+
&& typeof value.formattedPrice === 'string'
|
|
525
|
+
&& typeof value.currencyCode === 'string' && value.currencyCode.length > 0;
|
|
526
|
+
}
|
|
527
|
+
function storeKitError(code, message, retryable) {
|
|
528
|
+
return Object.assign(new Error(message), { code, retryable });
|
|
529
|
+
}
|
|
530
|
+
function evidenceInvalid(message = 'StoreKit transaction evidence is invalid.') {
|
|
531
|
+
return storeKitError('STOREKIT_EVIDENCE_INVALID', message, false);
|
|
532
|
+
}
|
|
533
|
+
function isPromiseLike(value) {
|
|
534
|
+
return (typeof value === 'object' || typeof value === 'function')
|
|
535
|
+
&& value !== null
|
|
536
|
+
&& typeof value.then === 'function';
|
|
537
|
+
}
|
|
538
|
+
function isRecord(value) {
|
|
539
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
540
|
+
}
|
|
@@ -114,15 +114,20 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
114
114
|
Task {
|
|
115
115
|
var byId: [String: [String: Any]] = [:]
|
|
116
116
|
for await result in Transaction.unfinished {
|
|
117
|
-
if
|
|
117
|
+
if var payload = Self.verifiedPayload(result),
|
|
118
118
|
let id = payload["transactionId"] as? String {
|
|
119
|
+
payload["unfinished"] = true
|
|
119
120
|
byId[id] = payload
|
|
120
121
|
}
|
|
121
122
|
}
|
|
123
|
+
// Current non-consumable entitlements are listed for the v1 restore
|
|
124
|
+
// flow; `unfinished: false` lets the commerce v2 SDK exclude them.
|
|
122
125
|
for await result in Transaction.currentEntitlements {
|
|
123
|
-
if
|
|
126
|
+
if var payload = Self.verifiedPayload(result),
|
|
124
127
|
payload["type"] as? String == "non_consumable",
|
|
125
|
-
let id = payload["transactionId"] as? String
|
|
128
|
+
let id = payload["transactionId"] as? String,
|
|
129
|
+
byId[id] == nil {
|
|
130
|
+
payload["unfinished"] = false
|
|
126
131
|
byId[id] = payload
|
|
127
132
|
}
|
|
128
133
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/capacitor-storekit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Opt-in StoreKit 2 one-time purchase collector for mpgd Capacitor games.",
|
|
3
|
+
"version": "0.2.0",
|
|
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": {
|
|
7
7
|
"type": "git",
|
|
@@ -38,17 +38,18 @@
|
|
|
38
38
|
],
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"@capacitor/core": "^8.5.1",
|
|
41
|
-
"@mpgd/
|
|
42
|
-
"@mpgd/
|
|
41
|
+
"@mpgd/adapter-capacitor": "^0.9.0",
|
|
42
|
+
"@mpgd/bridge": "^0.12.1",
|
|
43
|
+
"@mpgd/platform": "^0.20.0"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
|
45
46
|
"@capacitor/core": "8.5.2",
|
|
46
47
|
"ttsc": "0.30.4",
|
|
47
48
|
"typescript": "7.0.2",
|
|
48
|
-
"@mpgd/
|
|
49
|
-
"@mpgd/
|
|
50
|
-
"@mpgd/game-services": "0.
|
|
51
|
-
"@mpgd/platform": "0.
|
|
49
|
+
"@mpgd/bridge": "0.12.1",
|
|
50
|
+
"@mpgd/adapter-capacitor": "0.9.0",
|
|
51
|
+
"@mpgd/game-services": "0.21.0",
|
|
52
|
+
"@mpgd/platform": "0.20.0"
|
|
52
53
|
},
|
|
53
54
|
"main": "./dist/index.js",
|
|
54
55
|
"types": "./dist/index.d.ts",
|
|
@@ -58,6 +59,6 @@
|
|
|
58
59
|
"scripts": {
|
|
59
60
|
"check": "ttsc --noEmit",
|
|
60
61
|
"lint": "ttsc --noEmit",
|
|
61
|
-
"test": "cd ../.. && node tools/run-ttsx.mjs native-plugins/capacitor-storekit/src/provider.test.ts"
|
|
62
|
+
"test": "cd ../.. && node tools/run-ttsx.mjs native-plugins/capacitor-storekit/src/provider.test.ts && node tools/run-ttsx.mjs native-plugins/capacitor-storekit/src/store-sdk.test.ts && node tools/run-ttsx.mjs native-plugins/capacitor-storekit/src/commerce-provider.test.ts && node tools/run-ttsx.mjs native-plugins/capacitor-storekit/src/commerce-provider.conformance.test.ts"
|
|
62
63
|
}
|
|
63
64
|
}
|