@mpgd/capacitor-storekit 0.1.0 → 0.1.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 imjlk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @mpgd/capacitor-storekit
2
2
 
3
- Private, opt-in StoreKit 2 one-time purchase collector for Capacitor 8 on iOS.
3
+ Opt-in StoreKit 2 one-time purchase collector for Capacitor 8 on iOS.
4
4
  It does not enable native IAP in the base Capacitor shell. Install it only in a
5
5
  game-owned iOS project and register `createCapacitorStoreKitProvider(...)` in
6
6
  that game's provider list.
@@ -25,6 +25,19 @@ bundle, product, environment and account binding before the ledger grant.
25
25
  The recovery helper calls native `finishTransaction` only after a
26
26
  verified response with a ledger entry ID. If finish fails, it reports
27
27
  `finishPending: true`; repeat recovery with the original purchase identity.
28
+ `provider.finishGrantedTransaction` accepts only a transaction and ledger
29
+ entry pair that `recoverStoreKitPurchases` recorded from a backend answer in
30
+ the current session. The pair is consumed before the native call, so
31
+ overlapping finishes (for example startup recovery and a `transactionUpdated`
32
+ recovery) share one native call, and a failed native finish restores the pair
33
+ for a retry. Calling it, or the native `finishTransaction` plugin method, with
34
+ any other value is rejected so an unfinished purchase keeps its recovery
35
+ signal. This guard prevents arbitrary or accidental finish calls with
36
+ unverified ledger IDs and binds finishing to the recovery flow; it is **not**
37
+ a trust boundary against code that controls the `backend` object it passes to
38
+ `recoverStoreKitPurchases`. The real control is server-side: the authenticated
39
+ `recoverPurchase` implementation must verify Apple evidence before it grants,
40
+ and finishing a transaction never grants anything by itself.
28
41
  An unknown backend result stays pending and never triggers a
29
42
  new purchase sheet automatically.
30
43
 
@@ -42,7 +42,16 @@ export interface CapacitorStoreKitPlugin {
42
42
  sync(): Promise<{
43
43
  readonly synced: boolean;
44
44
  }>;
45
- /** Call only after the authenticated backend confirms the ledger grant. */
45
+ /**
46
+ * Call only after the authenticated backend confirms the ledger grant.
47
+ * Native validation is intentionally shallow and finishing never grants
48
+ * anything. The TypeScript provider's finishGrantedTransaction binds this
49
+ * call to the recoverStoreKitPurchases flow so games cannot finish a
50
+ * purchase accidentally or with an arbitrary ledger ID; games must not call
51
+ * this plugin method directly. That binding is not a trust boundary against
52
+ * code that controls the recovery backend object: the authenticated
53
+ * server-side recoverPurchase verification is the real control.
54
+ */
46
55
  finishTransaction(input: {
47
56
  readonly transactionId: string;
48
57
  readonly ledgerEntryId: string;
@@ -47,6 +47,19 @@ export interface CapacitorStoreKitProvider {
47
47
  }>>;
48
48
  getAppAccountToken(): Promise<string>;
49
49
  getRecoverableTransactions(): Promise<StoreKitRecoverySnapshot>;
50
+ /**
51
+ * Finish a transaction the backend already granted. The provider accepts
52
+ * only a transaction and ledger entry pair that recoverStoreKitPurchases
53
+ * recorded from a backend answer in this session, and consumes the pair
54
+ * before the native call so overlapping finishes share one native call;
55
+ * any other pair is rejected so an unfinished purchase keeps its recovery
56
+ * signal. A failed native finish restores the pair for a retry.
57
+ *
58
+ * The guard prevents arbitrary or accidental finish calls with unverified
59
+ * ledger IDs. It is not a trust boundary against code that controls the
60
+ * backend object given to recoverStoreKitPurchases; the authenticated
61
+ * server-side recoverPurchase implementation is the real control.
62
+ */
50
63
  finishGrantedTransaction(input: {
51
64
  readonly transactionId: string;
52
65
  readonly ledgerEntryId: string;
package/dist/provider.js CHANGED
@@ -3,6 +3,20 @@ import { CapacitorStoreKit } from './plugin.js';
3
3
  const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
4
4
  const productPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
5
5
  const transactionPattern = /^[1-9][0-9]{0,19}$/u;
6
+ /**
7
+ * Grants recorded by recoverStoreKitPurchases per provider instance:
8
+ * transaction ID to the ledger entry ID the recovery backend returned. The map
9
+ * binds finishGrantedTransaction to that recovery flow so a game cannot finish
10
+ * an unfinished purchase by accident, or with an arbitrary ledger string, and
11
+ * so each recorded pair reaches the native finish call at most once.
12
+ *
13
+ * This is not a trust boundary against a caller who controls the backend
14
+ * object passed to recoverStoreKitPurchases: such a caller can record any
15
+ * pair. The real control stays server-side, in the authenticated
16
+ * recoverPurchase implementation that verifies Apple evidence before it
17
+ * grants, and in the native plugin never granting anything on finish.
18
+ */
19
+ const verifiedGrants = new WeakMap();
6
20
  /** Submit unfinished purchases to the backend before asking StoreKit to finish. */
7
21
  export async function recoverStoreKitPurchases(input) {
8
22
  if (input.playerId.trim() === '') {
@@ -43,6 +57,7 @@ export async function recoverStoreKitPurchases(input) {
43
57
  if (verification.ledgerEntryId === undefined || verification.ledgerEntryId.trim() === '') {
44
58
  return { productId: item.productId, transactionId, status: 'pending', verification };
45
59
  }
60
+ verifiedGrants.get(input.provider)?.set(transactionId, verification.ledgerEntryId);
46
61
  try {
47
62
  const finished = await input.provider.finishGrantedTransaction({
48
63
  transactionId,
@@ -209,7 +224,23 @@ export function createCapacitorStoreKitProvider(options) {
209
224
  return failure(input.id, 'STOREKIT_UNAVAILABLE', true);
210
225
  }
211
226
  }
212
- return {
227
+ const grants = new Map();
228
+ const inFlight = new Map();
229
+ async function finishReserved(transactionId, ledgerEntryId) {
230
+ let finished = false;
231
+ try {
232
+ finished = (await sdk.finishTransaction({ transactionId, ledgerEntryId })).finished;
233
+ }
234
+ finally {
235
+ // Restore the pair after a rejected or failed native finish so recovery
236
+ // can retry it, unless a newer recovery recorded the transaction again.
237
+ if (!finished && !grants.has(transactionId)) {
238
+ grants.set(transactionId, ledgerEntryId);
239
+ }
240
+ }
241
+ return finished;
242
+ }
243
+ const provider = {
213
244
  id: 'apple-storekit',
214
245
  features: ['nativeIap'],
215
246
  methods: [
@@ -247,10 +278,31 @@ export function createCapacitorStoreKitProvider(options) {
247
278
  || input.ledgerEntryId.trim() === '') {
248
279
  throw new TypeError('StoreKit finish requires a verified grant and transaction ID.');
249
280
  }
250
- const response = await sdk.finishTransaction(input);
251
- return response.finished;
281
+ const { transactionId, ledgerEntryId } = input;
282
+ const active = inFlight.get(transactionId);
283
+ if (active !== undefined && active.ledgerEntryId === ledgerEntryId) {
284
+ return active.promise;
285
+ }
286
+ if (grants.get(transactionId) !== ledgerEntryId) {
287
+ throw new TypeError('StoreKit finish requires the ledger entry the backend returned for this transaction via recoverStoreKitPurchases.');
288
+ }
289
+ // Consume the pair synchronously, before the first await, so an
290
+ // overlapping finish for the same transaction cannot reach native twice.
291
+ grants.delete(transactionId);
292
+ const promise = finishReserved(transactionId, ledgerEntryId);
293
+ inFlight.set(transactionId, { ledgerEntryId, promise });
294
+ try {
295
+ return await promise;
296
+ }
297
+ finally {
298
+ if (inFlight.get(transactionId)?.promise === promise) {
299
+ inFlight.delete(transactionId);
300
+ }
301
+ }
252
302
  },
253
303
  };
304
+ verifiedGrants.set(provider, grants);
305
+ return provider;
254
306
  }
255
307
  function convertOutcome(outcome, mapping, expectedAccountToken) {
256
308
  if (outcome.status !== 'purchased') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/capacitor-storekit",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Opt-in StoreKit 2 one-time purchase collector for mpgd Capacitor games.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -8,6 +8,16 @@
8
8
  "url": "git+https://github.com/imjlk/mpgd-kit.git",
9
9
  "directory": "native-plugins/capacitor-storekit"
10
10
  },
11
+ "bugs": {
12
+ "url": "https://github.com/imjlk/mpgd-kit/issues"
13
+ },
14
+ "homepage": "https://github.com/imjlk/mpgd-kit#readme",
15
+ "keywords": [
16
+ "mpgd",
17
+ "capacitor",
18
+ "storekit",
19
+ "game-development"
20
+ ],
11
21
  "type": "module",
12
22
  "sideEffects": false,
13
23
  "exports": {
@@ -21,29 +31,33 @@
21
31
  "src": "ios"
22
32
  }
23
33
  },
24
- "files": ["dist", "ios", "Package.swift"],
25
- "scripts": {
26
- "check": "ttsc --noEmit",
27
- "lint": "ttsc --noEmit",
28
- "test": "cd ../.. && node tools/run-ttsx.mjs native-plugins/capacitor-storekit/src/provider.test.ts"
29
- },
34
+ "files": [
35
+ "dist",
36
+ "ios",
37
+ "Package.swift"
38
+ ],
30
39
  "peerDependencies": {
31
40
  "@capacitor/core": "^8.5.1",
32
41
  "@mpgd/bridge": "^0.10.0",
33
- "@mpgd/platform": "^0.13.0 || ^0.14.0"
42
+ "@mpgd/platform": "^0.14.0"
34
43
  },
35
44
  "devDependencies": {
36
45
  "@capacitor/core": "8.5.2",
37
- "@mpgd/adapter-capacitor": "workspace:*",
38
- "@mpgd/bridge": "workspace:*",
39
- "@mpgd/game-services": "workspace:*",
40
- "@mpgd/platform": "workspace:*",
41
46
  "ttsc": "0.30.4",
42
- "typescript": "7.0.2"
47
+ "typescript": "7.0.2",
48
+ "@mpgd/adapter-capacitor": "0.6.1",
49
+ "@mpgd/bridge": "0.10.0",
50
+ "@mpgd/game-services": "0.17.1",
51
+ "@mpgd/platform": "0.14.0"
43
52
  },
44
53
  "main": "./dist/index.js",
45
54
  "types": "./dist/index.d.ts",
46
55
  "publishConfig": {
47
56
  "access": "public"
57
+ },
58
+ "scripts": {
59
+ "check": "ttsc --noEmit",
60
+ "lint": "ttsc --noEmit",
61
+ "test": "cd ../.. && node tools/run-ttsx.mjs native-plugins/capacitor-storekit/src/provider.test.ts"
48
62
  }
49
- }
63
+ }