@mpgd/capacitor-storekit 0.3.4 → 0.3.6

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
@@ -54,11 +54,15 @@ over `getProducts` (subscription lookups are empty without a native call);
54
54
  a non-UUID one with `STOREKIT_CONFIGURATION_ERROR`, and the store never
55
55
  opens), answering `purchased` with the transaction, `launched` for a pending
56
56
  Ask to Buy, and `cancelled` for a dismissed sheet; `getAvailablePurchases`
57
- over `getTransactions`, listing unfinished transactions once (a current
58
- non-consumable entitlement the native side marks `unfinished: false` is not
59
- listed, a `revokedAt` maps to `state: 'revoked'`, and one malformed
57
+ over `getTransactions`, listing unfinished transactions and finished durables
58
+ once (a current non-consumable entitlement the native side marks
59
+ `unfinished: false` stays listed so a finish that raced the ledger grant can
60
+ still be verified; the server ledger dedupes. A finished consumable is not
61
+ listed. A `revokedAt` maps to `state: 'revoked'`, and one malformed
60
62
  transaction is reported as a `purchaseError` without blocking its siblings);
61
- `finishTransaction({ transactionId, ledgerEntryId })` over the native finish;
63
+ `finishTransaction({ transactionId, ledgerEntryId })` resolves the session
64
+ app account token and finishes only a listed, non-revoked transaction bound
65
+ to that token;
62
66
  `restorePurchases` over `sync`; `transactionUpdated` as `purchaseUpdated`;
63
67
  and an availability probe as `getAvailability`, probing the configured
64
68
  SKUs batch by batch until one is sold. Every purchase carries the
@@ -161,12 +165,17 @@ Call the reconciler (`monetizationRecovery.reconcile()` from
161
165
  `@mpgd/game-services`) on startup, on resume, and after the iOS plugin emits
162
166
  `transactionUpdated` for a delayed transaction (Ask to Buy, an interrupted
163
167
  checkout, a transaction finished on another device): the events can be missed
164
- while the game is closed, so `getAvailablePurchases` (`Transaction.unfinished`)
165
- is the source of truth. The reconciler sends only transactions whose
168
+ while the game is closed, so `getAvailablePurchases` (`Transaction.unfinished`,
169
+ plus finished non-consumables still in `currentEntitlements`) is the source
170
+ of truth. The reconciler sends only transactions whose
166
171
  `appAccountToken` matches the current authenticated game account, verifies
167
172
  each one on the backend under its original idempotency key or returns the
168
173
  existing ledger grant, and calls `finishTransaction` only after a verified
169
- response with a ledger entry id. Permanently rejected and account-mismatched
174
+ response with a ledger entry id. The StoreKit SDK and the native plugin both
175
+ refuse to finish a transaction whose app account token is missing or does
176
+ not match that session. A caller who can read the token from the plugin can
177
+ still present it with a forged ledger entry id; that remaining hole needs a
178
+ signed server finish capability. Permanently rejected and account-mismatched
170
179
  transactions are not finished: the client cannot prove content delivery for
171
180
  them, so persist that terminal result in the game-owned operation journal and
172
181
  do not run an automatic retry loop for it. An unknown backend result stays
@@ -20,8 +20,9 @@ export interface StoreKitTransaction {
20
20
  /**
21
21
  * Present on `getTransactions` results: true for `Transaction.unfinished`
22
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.
23
+ * finished. The SDK lists `unfinished: false` durables so a finish that
24
+ * raced the ledger grant can still be verified. Omitted by native builds
25
+ * predating the field and by `purchase` and `transactionUpdated` payloads.
25
26
  */
26
27
  readonly unfinished?: boolean;
27
28
  }
@@ -51,17 +52,19 @@ export interface CapacitorStoreKitPlugin {
51
52
  }>;
52
53
  /**
53
54
  * Call only after the authenticated backend confirms the ledger grant.
54
- * Native validation is intentionally shallow and finishing never grants
55
- * anything. The commerce provider's `finishTransaction` is the only caller:
56
- * the reconciler in `@mpgd/game-services` reaches it through
57
- * `assertCommerceFinishAllowed` after the ledger grant, so games must not
58
- * call this plugin method directly. That gate is not a trust boundary
59
- * against code that controls the backend object given to the reconciler:
60
- * the authenticated server-side verification is the real control.
55
+ * Finishing never grants. Native finishes only a verified, non-revoked,
56
+ * one-time transaction whose app account token matches `appAccountToken`.
57
+ * The StoreKit SDK injects the session token and applies the same match
58
+ * before this call, including for `commerce.v2.finishTransaction`. Games
59
+ * must not call this plugin method directly. The token match is not a
60
+ * signed server capability: a caller who can read `getTransactions` can
61
+ * still supply that token with a forged ledger entry id.
61
62
  */
62
63
  finishTransaction(input: {
63
64
  readonly transactionId: string;
64
65
  readonly ledgerEntryId: string;
66
+ /** Session app account token; must match the verified transaction. */
67
+ readonly appAccountToken: string;
65
68
  }): Promise<{
66
69
  readonly finished: boolean;
67
70
  }>;
package/dist/store-sdk.js CHANGED
@@ -244,6 +244,21 @@ export function createCapacitorStoreKitStoreSdk(input) {
244
244
  // Overlapping finishes for the same transaction and ledger entry share one
245
245
  // native call; the v2 reconciler is the gate that authorizes the call.
246
246
  const inFlightFinishes = new Map();
247
+ /**
248
+ * Mirrors the native finish gate: the transaction must be listed, not
249
+ * revoked, and bound to the session app account token. A missing list is
250
+ * an invalid response, not a quiet "not finished".
251
+ */
252
+ const finishPermitted = async (transactionId, appAccountToken) => {
253
+ const response = await sdk.getTransactions();
254
+ if (!isRecord(response) || !Array.isArray(response.transactions)) {
255
+ throw evidenceInvalid('StoreKit returned an invalid transaction list.');
256
+ }
257
+ const transaction = response.transactions.find((candidate) => isValidStoreKitTransaction(candidate) && candidate.transactionId === transactionId);
258
+ return transaction !== undefined
259
+ && transaction.revokedAt === undefined
260
+ && transaction.appAccountToken?.toLowerCase() === appAccountToken;
261
+ };
247
262
  return {
248
263
  store: appStoreStoreId,
249
264
  capabilities: storeKitStoreSdkCapabilities,
@@ -313,10 +328,16 @@ export function createCapacitorStoreKitStoreSdk(input) {
313
328
  malformed(transaction);
314
329
  continue;
315
330
  }
316
- // Available means unfinished: a current entitlement the native side
317
- // marks finished is not listed. A build without the flag lists every
318
- // transaction, which the backend answers idempotently.
319
- if (transaction.unfinished === false || seen.has(transaction.transactionId)) {
331
+ // A finished durable stays in currentEntitlements with
332
+ // `unfinished: false`. Listing it lets the reconciler verify a finish
333
+ // that raced the ledger grant; the server ledger dedupes. A finished
334
+ // consumable is not an entitlement and is not listed. A build that
335
+ // omits the flag lists every transaction, which the backend answers
336
+ // idempotently.
337
+ if (seen.has(transaction.transactionId)) {
338
+ continue;
339
+ }
340
+ if (transaction.unfinished === false && transaction.type !== 'non_consumable') {
320
341
  continue;
321
342
  }
322
343
  seen.add(transaction.transactionId);
@@ -328,6 +349,9 @@ export function createCapacitorStoreKitStoreSdk(input) {
328
349
  },
329
350
  async finishTransaction(request) {
330
351
  requireIos();
352
+ // The session token is the binding. A bridge or plugin caller cannot
353
+ // substitute another account's token on this path.
354
+ const appAccountToken = await Promise.resolve(resolveAppAccountToken());
331
355
  const { transactionId, ledgerEntryId } = request;
332
356
  if (typeof transactionId !== 'string' || !transactionPattern.test(transactionId)
333
357
  || typeof ledgerEntryId !== 'string' || ledgerEntryId.trim() === '') {
@@ -338,7 +362,16 @@ export function createCapacitorStoreKitStoreSdk(input) {
338
362
  return active.promise;
339
363
  }
340
364
  const promise = (async () => {
341
- const reported = await sdk.finishTransaction({ transactionId, ledgerEntryId });
365
+ // Same match the native plugin applies: missing, revoked, unbound, or
366
+ // another account never reaches `transaction.finish()`.
367
+ if (!(await finishPermitted(transactionId, appAccountToken))) {
368
+ return { finished: false };
369
+ }
370
+ const reported = await sdk.finishTransaction({
371
+ transactionId,
372
+ ledgerEntryId,
373
+ appAccountToken,
374
+ });
342
375
  if (!isRecord(reported) || typeof reported.finished !== 'boolean') {
343
376
  throw evidenceInvalid('StoreKit returned an invalid finish result.');
344
377
  }
@@ -120,8 +120,10 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
120
120
  byId[id] = payload
121
121
  }
122
122
  }
123
- // Current non-consumable entitlements are listed too;
124
- // `unfinished: false` lets the commerce SDK exclude them.
123
+ // Current non-consumable entitlements are listed too.
124
+ // `unfinished: false` means StoreKit already finished them; the SDK
125
+ // still lists those durables so a finish that raced the ledger
126
+ // grant can be verified. The server ledger dedupes.
125
127
  for await result in Transaction.currentEntitlements {
126
128
  if var payload = Self.verifiedPayload(result),
127
129
  payload["type"] as? String == "non_consumable",
@@ -147,19 +149,34 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
147
149
  }
148
150
 
149
151
  @objc func finishTransaction(_ call: CAPPluginCall) {
152
+ // The ledger id is still a client string. The token match is the local
153
+ // gate: finish only a verified, non-revoked, one-time transaction whose
154
+ // app account token equals the one the caller supplies. The StoreKit
155
+ // SDK supplies the session token and applies the same match before
156
+ // calling. A signed server finish capability is still required to stop
157
+ // a webview caller who read the token from `getTransactions`.
150
158
  guard let rawId = call.getString("transactionId"), let id = UInt64(rawId),
151
159
  let ledgerEntryId = call.getString("ledgerEntryId"),
152
- !ledgerEntryId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
153
- call.reject("A transaction ID and confirmed ledger entry are required.",
154
- "STOREKIT_FINISH_REQUIRES_GRANT")
160
+ !ledgerEntryId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
161
+ let rawAccountToken = call.getString("appAccountToken"),
162
+ let accountToken = UUID(uuidString: rawAccountToken),
163
+ accountToken.uuidString.lowercased()
164
+ != "00000000-0000-0000-0000-000000000000" else {
165
+ call.reject(
166
+ "A transaction ID, confirmed ledger entry, and app account token are required.",
167
+ "STOREKIT_FINISH_REQUIRES_GRANT"
168
+ )
155
169
  return
156
170
  }
171
+ let expectedToken = accountToken.uuidString.lowercased()
157
172
  Task {
158
173
  for await result in Transaction.all {
159
174
  guard case .verified(let transaction) = result,
160
175
  transaction.id == id,
161
176
  transaction.revocationDate == nil,
162
- Self.oneTimeType(transaction.productType) != nil else { continue }
177
+ Self.oneTimeType(transaction.productType) != nil,
178
+ transaction.appAccountToken?.uuidString.lowercased() == expectedToken
179
+ else { continue }
163
180
  await transaction.finish()
164
181
  call.resolve(["finished": true])
165
182
  return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/capacitor-storekit",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
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": {
@@ -40,16 +40,16 @@
40
40
  "@capacitor/core": "^8.5.1",
41
41
  "@mpgd/adapter-capacitor": "^0.12.0",
42
42
  "@mpgd/bridge": "^0.14.0",
43
- "@mpgd/platform": "^0.26.0"
43
+ "@mpgd/platform": "^0.27.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@capacitor/core": "8.5.2",
47
47
  "ttsc": "0.32.1",
48
48
  "typescript": "7.0.2",
49
- "@mpgd/adapter-capacitor": "0.12.0",
50
- "@mpgd/bridge": "0.14.1",
51
- "@mpgd/platform": "0.26.0",
52
- "@mpgd/game-services": "0.26.0"
49
+ "@mpgd/game-services": "0.27.1",
50
+ "@mpgd/adapter-capacitor": "0.12.2",
51
+ "@mpgd/bridge": "0.14.3",
52
+ "@mpgd/platform": "0.27.1"
53
53
  },
54
54
  "main": "./dist/index.js",
55
55
  "types": "./dist/index.d.ts",