@mpgd/capacitor-storekit 0.1.1 → 0.1.3

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
@@ -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.1",
3
+ "version": "0.1.3",
4
4
  "description": "Opt-in StoreKit 2 one-time purchase collector for mpgd Capacitor games.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -45,10 +45,10 @@
45
45
  "@capacitor/core": "8.5.2",
46
46
  "ttsc": "0.30.4",
47
47
  "typescript": "7.0.2",
48
- "@mpgd/adapter-capacitor": "0.6.0",
48
+ "@mpgd/adapter-capacitor": "0.6.2",
49
49
  "@mpgd/bridge": "0.10.0",
50
- "@mpgd/game-services": "0.17.0",
51
- "@mpgd/platform": "0.14.0"
50
+ "@mpgd/platform": "0.14.0",
51
+ "@mpgd/game-services": "0.17.2"
52
52
  },
53
53
  "main": "./dist/index.js",
54
54
  "types": "./dist/index.d.ts",