@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 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
+ }
@@ -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
@@ -1,3 +1,5 @@
1
+ export * from './commerce-provider.js';
1
2
  export * from './definitions.js';
2
3
  export * from './plugin.js';
3
4
  export * from './provider.js';
5
+ export * from './store-sdk.js';
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ export * from './commerce-provider.js';
1
2
  export * from './definitions.js';
2
3
  export * from './plugin.js';
3
4
  export * from './provider.js';
5
+ export * from './store-sdk.js';
@@ -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
- /** Submit unfinished purchases to the backend before asking StoreKit to finish. */
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
- /** Submit unfinished purchases to the backend before asking StoreKit to finish. */
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 let payload = Self.verifiedPayload(result),
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 let payload = Self.verifiedPayload(result),
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.1.9",
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/bridge": "^0.11.0",
42
- "@mpgd/platform": "^0.18.0"
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/adapter-capacitor": "0.7.5",
49
- "@mpgd/bridge": "0.11.0",
50
- "@mpgd/game-services": "0.19.1",
51
- "@mpgd/platform": "0.18.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
  }