@mpgd/capacitor-storekit 0.4.2 → 0.4.4

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
@@ -63,7 +63,8 @@ listed. A `revokedAt` maps to `state: 'revoked'`, and one malformed
63
63
  transaction is reported as a `purchaseError` without blocking its siblings);
64
64
  `finishTransaction({ transactionId, ledgerEntryId })` resolves the session
65
65
  app account token and finishes only a listed, non-revoked transaction bound
66
- to that token;
66
+ to that token, or a durable of another token a restore shared to this
67
+ player (see Recovery);
67
68
  `restorePurchases` over `sync`; `transactionUpdated` as `purchaseUpdated`;
68
69
  and an availability probe as `getAvailability`, probing the configured
69
70
  SKUs batch by batch until one is sold. Every purchase carries the
@@ -182,12 +183,25 @@ each one on the backend under its original idempotency key or returns the
182
183
  existing ledger grant, and calls `finishTransaction` only after a verified
183
184
  response with a ledger entry id. The StoreKit SDK and the native plugin both
184
185
  refuse to call `transaction.finish()` on a transaction whose app account
185
- token is missing or does not match that session. A caller who can read the token from the plugin can
186
+ token is missing or does not match the token they are given; the SDK gives the
187
+ session's, except for a durable of another token (below). A caller who can read the token from the plugin can
186
188
  still present it with a forged ledger entry id; that remaining hole needs a
187
189
  signed server finish capability. A listed, non-revoked durable StoreKit
188
190
  already finished (`unfinished: false`) answers `{ finished: true }` without a
189
191
  native call whatever its token, so a purchase shared to this guest from an
190
- earlier guest's restore ends there. Permanently rejected and account-mismatched
192
+ earlier guest's restore ends there. A durable of the configured catalog still
193
+ unfinished under another token (the earlier guest crashed between its grant
194
+ and its finish, then the app was reinstalled; or a native build omits
195
+ `unfinished`) is finished with that transaction's own token, which the native
196
+ token match accepts, so the shared grant's record does not retry forever. The
197
+ reconciler calls it only for this player's ledger grant, and finishing a
198
+ durable takes nothing from the earlier guest: StoreKit keeps it in
199
+ `Transaction.currentEntitlements`, where that guest's own read still lists it
200
+ (`unfinished: false`). The native plugin is unchanged and still filters
201
+ nothing by token on reads. A webview caller can therefore finish another
202
+ token's unfinished durable, which, for the same reason, grants or removes
203
+ nothing. A consumable of another token and a transaction bound to no token
204
+ are never finished. Permanently rejected and account-mismatched
191
205
  transactions are not finished: the client cannot prove content delivery for
192
206
  them, so persist that terminal result in the game-owned operation journal and
193
207
  do not run an automatic retry loop for it. An unknown backend result stays
package/dist/store-sdk.js CHANGED
@@ -259,11 +259,25 @@ export function createCapacitorStoreKitStoreSdk(input) {
259
259
  /**
260
260
  * Mirrors the native finish gate: the transaction must be listed, not
261
261
  * revoked, and bound to the session app account token. A missing list is
262
- * an invalid response, not a quiet "not finished". A listed, non-revoked
263
- * durable StoreKit already finished (`unfinished: false`) has nothing left
264
- * to finish whatever its token: a restore shared to this player from an
265
- * earlier guest's purchase ends there instead of retrying a finish the
266
- * native token match refuses.
262
+ * an invalid response, not a quiet "not finished". Two exceptions for a
263
+ * listed, non-revoked durable, the only kind the restore read admits from
264
+ * another token:
265
+ *
266
+ * - StoreKit already finished it (`unfinished: false`): nothing is left to
267
+ * finish whatever its token, so a restore shared to this player ends
268
+ * there without a native call.
269
+ * - It is unfinished (or the build omits the flag), the catalog declares
270
+ * its SKU durable, and it is bound to another token: an earlier guest
271
+ * crashed between its grant and its finish, then the app was
272
+ * reinstalled. The caller holds this player's ledger grant
273
+ * (the reconciler finishes only `granted` or `finishing` records), and a
274
+ * durable stays in `Transaction.currentEntitlements` after its finish, so
275
+ * finishing it takes nothing from any player: the earlier guest's next
276
+ * read still lists it (`unfinished: false`) for its own verification. It
277
+ * is finished with its own token, which the native gate matches.
278
+ *
279
+ * A consumable of another token, or a transaction bound to no token, is
280
+ * never finished here.
267
281
  */
268
282
  const finishPermitted = async (transactionId, appAccountToken) => {
269
283
  const response = await sdk.getTransactions();
@@ -272,12 +286,21 @@ export function createCapacitorStoreKitStoreSdk(input) {
272
286
  }
273
287
  const transaction = response.transactions.find((candidate) => isValidStoreKitTransaction(candidate) && candidate.transactionId === transactionId);
274
288
  if (transaction === undefined || transaction.revokedAt !== undefined) {
275
- return 'refused';
289
+ return { verdict: 'refused' };
276
290
  }
277
291
  if (transaction.type === 'non_consumable' && transaction.unfinished === false) {
278
- return 'finished';
292
+ return { verdict: 'finished' };
279
293
  }
280
- return transaction.appAccountToken?.toLowerCase() === appAccountToken ? 'finish' : 'refused';
294
+ const bound = transaction.appAccountToken?.toLowerCase();
295
+ if (bound === appAccountToken) {
296
+ return { verdict: 'finish', appAccountToken };
297
+ }
298
+ if (bound !== undefined
299
+ && transaction.type === 'non_consumable'
300
+ && kinds.get(transaction.productId) === 'durable') {
301
+ return { verdict: 'finish', appAccountToken: bound };
302
+ }
303
+ return { verdict: 'refused' };
281
304
  };
282
305
  return {
283
306
  store: appStoreStoreId,
@@ -371,7 +394,8 @@ export function createCapacitorStoreKitStoreSdk(input) {
371
394
  async finishTransaction(request) {
372
395
  requireIos();
373
396
  // The session token is the binding. A bridge or plugin caller cannot
374
- // substitute another account's token on this path.
397
+ // substitute another account's token on this path: the only other
398
+ // token used is a durable's own, read from the native list.
375
399
  const appAccountToken = await Promise.resolve(resolveAppAccountToken());
376
400
  const { transactionId, ledgerEntryId } = request;
377
401
  if (typeof transactionId !== 'string' || !transactionPattern.test(transactionId)
@@ -384,15 +408,16 @@ export function createCapacitorStoreKitStoreSdk(input) {
384
408
  }
385
409
  const promise = (async () => {
386
410
  // Same match the native plugin applies: missing, revoked, unbound, or
387
- // another account never reaches `transaction.finish()`.
411
+ // another account never reaches `transaction.finish()`, except a
412
+ // durable of another token (see `finishPermitted`).
388
413
  const permitted = await finishPermitted(transactionId, appAccountToken);
389
- if (permitted !== 'finish') {
390
- return { finished: permitted === 'finished' };
414
+ if (permitted.verdict !== 'finish') {
415
+ return { finished: permitted.verdict === 'finished' };
391
416
  }
392
417
  const reported = await sdk.finishTransaction({
393
418
  transactionId,
394
419
  ledgerEntryId,
395
- appAccountToken,
420
+ appAccountToken: permitted.appAccountToken,
396
421
  });
397
422
  if (!isRecord(reported) || typeof reported.finished !== 'boolean') {
398
423
  throw evidenceInvalid('StoreKit returned an invalid finish result.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/capacitor-storekit",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Opt-in StoreKit 2 one-time purchase collector and commerce v2 provider for mpgd Capacitor games.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -46,10 +46,10 @@
46
46
  "@capacitor/core": "8.5.2",
47
47
  "ttsc": "0.32.1",
48
48
  "typescript": "7.0.2",
49
- "@mpgd/adapter-capacitor": "0.16.0",
49
+ "@mpgd/adapter-capacitor": "0.16.2",
50
+ "@mpgd/game-services": "0.30.4",
50
51
  "@mpgd/bridge": "0.16.4",
51
- "@mpgd/platform": "0.32.0",
52
- "@mpgd/game-services": "0.30.2"
52
+ "@mpgd/platform": "0.32.0"
53
53
  },
54
54
  "main": "./dist/index.js",
55
55
  "types": "./dist/index.d.ts",