@mpgd/capacitor-storekit 0.3.5 → 0.3.7
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
|
|
58
|
-
non-consumable entitlement the native side marks
|
|
59
|
-
listed
|
|
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 })`
|
|
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
|
-
|
|
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.
|
|
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
|
package/dist/definitions.d.ts
CHANGED
|
@@ -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.
|
|
24
|
-
*
|
|
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
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* the
|
|
57
|
-
*
|
|
58
|
-
* call this plugin method directly.
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.3.7",
|
|
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": {
|
|
@@ -39,17 +39,17 @@
|
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"@capacitor/core": "^8.5.1",
|
|
41
41
|
"@mpgd/adapter-capacitor": "^0.12.0",
|
|
42
|
-
"@mpgd/bridge": "^0.
|
|
42
|
+
"@mpgd/bridge": "^0.15.0",
|
|
43
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/
|
|
50
|
-
"@mpgd/
|
|
51
|
-
"@mpgd/
|
|
52
|
-
"@mpgd/
|
|
49
|
+
"@mpgd/game-services": "0.27.2",
|
|
50
|
+
"@mpgd/platform": "0.27.2",
|
|
51
|
+
"@mpgd/adapter-capacitor": "0.12.3",
|
|
52
|
+
"@mpgd/bridge": "0.15.0"
|
|
53
53
|
},
|
|
54
54
|
"main": "./dist/index.js",
|
|
55
55
|
"types": "./dist/index.d.ts",
|