@mpgd/game-services 0.23.0 → 0.24.0
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 +46 -12
- package/dist/apps-in-toss-partner-api.d.ts +7 -0
- package/dist/apps-in-toss-partner-api.js +34 -0
- package/dist/client.d.ts +8 -6
- package/dist/client.js +16 -129
- package/dist/commerce-reconciler.d.ts +9 -5
- package/dist/commerce-reconciler.js +88 -23
- package/dist/google-play-purchase.d.ts +0 -9
- package/dist/google-play-purchase.js +0 -9
- package/dist/monetization-operation-store.d.ts +15 -6
- package/dist/monetization-operation-store.js +23 -8
- package/dist/monetization-recovery.d.ts +30 -25
- package/dist/monetization-recovery.js +125 -302
- package/dist/notification-delivery.js +5 -35
- package/dist/types.d.ts +0 -9
- package/package.json +6 -5
- package/dist/authoritative-purchase.d.ts +0 -32
- package/dist/authoritative-purchase.js +0 -62
package/README.md
CHANGED
|
@@ -17,22 +17,38 @@ root certificates, the bundle ID, environment, and production App Apple ID,
|
|
|
17
17
|
and pass the result as `signedTransactionVerifier` to
|
|
18
18
|
`createAppStoreGameServicesEvidenceVerifier`. Backend only: the peer is not
|
|
19
19
|
installed by game clients, and no other subpath imports it. It moved here from
|
|
20
|
-
`@mpgd/adapter-capacitor/app-store-server`,
|
|
21
|
-
|
|
20
|
+
`@mpgd/adapter-capacitor/app-store-server`, whose deprecated re-export was
|
|
21
|
+
removed in commerce v2 PR C4.
|
|
22
22
|
|
|
23
|
-
## Commerce
|
|
23
|
+
## Commerce reconciler
|
|
24
24
|
|
|
25
25
|
`createCommerceReconciler` from `@mpgd/game-services/commerce-reconciler` is
|
|
26
|
-
the RFC "Reconciler" over
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
purchases through it whenever `gateway.
|
|
31
|
-
`commerceReconciler`;
|
|
32
|
-
`createGameServicesRuntime` run it
|
|
33
|
-
`restorePurchases()` over the
|
|
26
|
+
the RFC "Reconciler" over a `CommerceProvider`: it journals every purchase as
|
|
27
|
+
a `commerce-purchase` record of the `MonetizationOperationStore`, sends the
|
|
28
|
+
saved verify request to the backend, finishes a purchase only after the ledger
|
|
29
|
+
grant, and never grants on the client. It is the only purchase path.
|
|
30
|
+
`createGameServicesClient` purchases through it whenever `gateway.commerce`
|
|
31
|
+
exists and exposes it as `commerceReconciler`;
|
|
32
|
+
`createRecoverableMonetizationClient` and `createGameServicesRuntime` run it
|
|
33
|
+
from `monetizationRecovery.reconcile()` and `restorePurchases()` over the
|
|
34
|
+
durable journal. See
|
|
34
35
|
[docs/MONETIZATION_RECOVERY.md](../../docs/MONETIZATION_RECOVERY.md).
|
|
35
36
|
|
|
37
|
+
A gateway without `gateway.commerce` sells nothing: `purchase()` answers
|
|
38
|
+
`rejected` with a failed platform result and the analytics reason
|
|
39
|
+
`commerce_unavailable`, opens no store UI, and never reaches the backend. The
|
|
40
|
+
v1 purchase path is gone (commerce v2 PR C4): there is no `recoverPurchaseResult`
|
|
41
|
+
(the reconciler learns results from the provider's `getAvailablePurchases` and
|
|
42
|
+
`purchaseUpdated` events), no target-name shortcut for adapter-settled
|
|
43
|
+
purchases, and no `createGooglePlayTokenTransactionId` (use
|
|
44
|
+
`createGooglePlayTokenPurchaseKey` from `@mpgd/platform/commerce`). A leftover
|
|
45
|
+
v1 `purchase` journal record is retired: it stays readable and never reopens the
|
|
46
|
+
store UI or grants locally, but a record that was mid-verification has its saved
|
|
47
|
+
verify request sent to the backend once more by `reconcile()` (the backend's
|
|
48
|
+
answer closes it). Entitlements are not read from the
|
|
49
|
+
gateway: read the backend ledger on the server with
|
|
50
|
+
`listActiveEntitlementTransactions` from `@mpgd/game-services/commerce-ledger`.
|
|
51
|
+
|
|
36
52
|
## Commerce evidence registry
|
|
37
53
|
|
|
38
54
|
`createCommerceEvidenceRegistry` from
|
|
@@ -126,7 +142,11 @@ await client.purchase(input, {
|
|
|
126
142
|
| `completed` | The local invocation returned a normal result; `status` equals that result's status |
|
|
127
143
|
| `exception` | The invocation threw; `at` identifies platform, server, or local processing |
|
|
128
144
|
|
|
129
|
-
Purchase and rewarded-ad progress are discriminated by `kind` and `phase`.
|
|
145
|
+
Purchase and rewarded-ad progress are discriminated by `kind` and `phase`. A
|
|
146
|
+
purchase goes through the reconciler, which journals a store failure as a
|
|
147
|
+
`failed` result and a backend outage as `pending`, so those end with a
|
|
148
|
+
`completed` event rather than `exception`; `exception` marks a failure of the
|
|
149
|
+
call itself, such as a journal write that rejects before the store opens.
|
|
130
150
|
`sequence` starts at one per invocation and increases for each actual event.
|
|
131
151
|
Events are frozen and calls have independent observers, sequences, and optional
|
|
132
152
|
correlation IDs. Callback invocation order is deterministic; asynchronous callback
|
|
@@ -252,3 +272,17 @@ Headless consumers can import operation input/result/progress types and the two-
|
|
|
252
272
|
`GameServicesOperationClient` port from `@mpgd/game-services/operations`. This entrypoint
|
|
253
273
|
has no runtime implementation and its declarations require no DOM or fetch globals.
|
|
254
274
|
The full client continues to re-export the same operation types for compatibility.
|
|
275
|
+
|
|
276
|
+
## Apps in Toss anonymous authentication
|
|
277
|
+
|
|
278
|
+
`createAppsInTossPartnerApiClient({ mtls, timeoutMs })` exposes
|
|
279
|
+
`exchangeAnonymousAuthorizationCode({ code, signal })`, returning
|
|
280
|
+
`{ anonymousKey }` only after `@ait-kit/api-core` authenticates the SDK 3.6
|
|
281
|
+
single-use code through mTLS. The existing response-size limit, timeout, caller
|
|
282
|
+
cancellation, and configured proxy path are preserved. Redirects are rejected.
|
|
283
|
+
|
|
284
|
+
Create a game-scoped session from the verified key on the server; never accept
|
|
285
|
+
a client-supplied key as the account authority. `verifyAnonymousKey()` remains
|
|
286
|
+
available for existence checks, but does not authenticate the caller. Codes are
|
|
287
|
+
not retried automatically: an indeterminate request needs a fresh SDK code.
|
|
288
|
+
Purchase account binding and ledger idempotency remain the application's job.
|
|
@@ -59,6 +59,13 @@ export interface AppsInTossIapOrderStatusResult {
|
|
|
59
59
|
readonly reason?: string;
|
|
60
60
|
}
|
|
61
61
|
export interface AppsInTossPartnerApiClient {
|
|
62
|
+
/** Authenticate with a fresh SDK 3.6 code; key existence checks are not login. */
|
|
63
|
+
exchangeAnonymousAuthorizationCode(input: {
|
|
64
|
+
readonly code: string;
|
|
65
|
+
readonly signal?: AbortSignal;
|
|
66
|
+
}): Promise<{
|
|
67
|
+
readonly anonymousKey: string;
|
|
68
|
+
}>;
|
|
62
69
|
verifyAnonymousKey(input: {
|
|
63
70
|
readonly anonymousKey: string;
|
|
64
71
|
readonly signal?: AbortSignal;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { exchangeAnonAuthCode } from '@ait-kit/api-core';
|
|
1
2
|
export const appsInTossPartnerApiBaseUrl = 'https://apps-in-toss-api.toss.im';
|
|
2
3
|
export const defaultAppsInTossPartnerApiTimeoutMs = 10_000;
|
|
3
4
|
const verifyAnonymousKeyPath = 'api-partner/v1/apps-in-toss/users/anon-key/verify';
|
|
@@ -20,6 +21,39 @@ export function createAppsInTossPartnerApiClient(input) {
|
|
|
20
21
|
const baseUrl = normalizeBaseUrl(input.baseUrl ?? appsInTossPartnerApiBaseUrl);
|
|
21
22
|
const timeoutMs = normalizeTimeout(input.timeoutMs);
|
|
22
23
|
return {
|
|
24
|
+
async exchangeAnonymousAuthorizationCode(request) {
|
|
25
|
+
const code = normalizeOpaqueValue(request.code, 'code', 4096);
|
|
26
|
+
const timeout = createTimeoutSignal(request.signal, timeoutMs);
|
|
27
|
+
try {
|
|
28
|
+
const result = await exchangeAnonAuthCode({ code }, {
|
|
29
|
+
mode: 'forward',
|
|
30
|
+
upstreamBaseUrl: baseUrl.href,
|
|
31
|
+
signal: timeout.signal,
|
|
32
|
+
mtlsClient: {
|
|
33
|
+
async request(url, init) {
|
|
34
|
+
// ait-kit endpoints are origin-relative; retain this client's configured proxy prefix.
|
|
35
|
+
const upstream = new URL(url);
|
|
36
|
+
const target = new URL(upstream.pathname.slice(1) + upstream.search, baseUrl);
|
|
37
|
+
// Older workerd rejects redirect: 'error'; manual keeps codes on this origin.
|
|
38
|
+
// ait-kit rejects every non-2xx response, including redirects with SUCCESS bodies.
|
|
39
|
+
const response = await input.mtls.fetch(target.href, { ...init, redirect: 'manual' });
|
|
40
|
+
const text = await readBoundedResponseText(response);
|
|
41
|
+
return new Response(text || null, { status: response.status, headers: response.headers });
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
if (timeout.signal.aborted) {
|
|
46
|
+
throw normalizeTransportError(undefined, request.signal, timeout.signal);
|
|
47
|
+
}
|
|
48
|
+
if (!result.ok) {
|
|
49
|
+
throw new AppsInTossPartnerApiError('Apps in Toss anonymous authentication failed.', result.upstreamStatus ?? 0, result.error);
|
|
50
|
+
}
|
|
51
|
+
return { anonymousKey: result.anonKey };
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
timeout.cleanup();
|
|
55
|
+
}
|
|
56
|
+
},
|
|
23
57
|
async verifyAnonymousKey(request) {
|
|
24
58
|
const anonymousKey = normalizeIdentifier(request.anonymousKey, 'anonymousKey');
|
|
25
59
|
const response = await requestJson({
|
package/dist/client.d.ts
CHANGED
|
@@ -109,9 +109,11 @@ export declare function resolveGameServicesRequestHeaders(input: {
|
|
|
109
109
|
export interface GameServicesClient extends GameServicesOperationClient {
|
|
110
110
|
submitLeaderboardScore(input: GameServicesLeaderboardInput): Promise<GameServicesLeaderboardResult>;
|
|
111
111
|
/**
|
|
112
|
-
* The commerce
|
|
113
|
-
* `
|
|
114
|
-
* resume; `purchase` starts it on first use.
|
|
112
|
+
* The commerce reconciler behind `purchase`, present when the gateway
|
|
113
|
+
* exposes `commerce`. Call `start()` at runtime start and `reconcile()` on
|
|
114
|
+
* app resume; `purchase` starts it on first use. A gateway without a commerce
|
|
115
|
+
* provider has none, and `purchase` then answers `rejected` without opening
|
|
116
|
+
* any store UI.
|
|
115
117
|
*/
|
|
116
118
|
readonly commerceReconciler?: CommerceReconciler;
|
|
117
119
|
}
|
|
@@ -129,9 +131,9 @@ export interface CreateGameServicesClientInput {
|
|
|
129
131
|
/** Explicit client decoders only; server verification is configured independently. */
|
|
130
132
|
readonly rewardEvidenceRegistry?: ClientRewardEvidenceRegistry;
|
|
131
133
|
/**
|
|
132
|
-
* Journal of the commerce
|
|
133
|
-
*
|
|
134
|
-
*
|
|
134
|
+
* Journal of the commerce reconciler used when `gateway.commerce` exists.
|
|
135
|
+
* Without one the journal lives in memory for this session only: duplicates
|
|
136
|
+
* are then caught by the backend ledger, not by the client.
|
|
135
137
|
*/
|
|
136
138
|
readonly operationStore?: MonetizationOperationStore;
|
|
137
139
|
/** A reconciler shared with another client, for example the recovery client's. */
|
package/dist/client.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createORPCClient } from '@orpc/client';
|
|
2
2
|
import { RPCLink } from '@orpc/client/fetch';
|
|
3
3
|
import { createAnalyticsReporter } from '@mpgd/analytics';
|
|
4
|
-
import { findAdapterSettledGrant } from './authoritative-purchase.js';
|
|
5
4
|
import { resolveClientRewardClaim, } from './client-reward-evidence.js';
|
|
6
5
|
import { createCommerceReconciler, toGameServicesPurchaseResult, } from './commerce-reconciler.js';
|
|
7
6
|
import { createDefaultClientRewardEvidenceRegistry } from './default-client-reward-evidence.js';
|
|
@@ -70,11 +69,11 @@ export function createGameServicesClient(input) {
|
|
|
70
69
|
now,
|
|
71
70
|
...(input.analytics === undefined ? {} : { sink: input.analytics }),
|
|
72
71
|
});
|
|
73
|
-
const commerceProvider = input.gateway.
|
|
72
|
+
const commerceProvider = input.gateway.commerce;
|
|
74
73
|
let commerceReconciler;
|
|
75
74
|
if (commerceProvider !== undefined) {
|
|
76
75
|
if (input.commerceReconciler === undefined && input.operationStore === undefined) {
|
|
77
|
-
(input.warn ?? console.warn)('@mpgd/game-services: the commerce
|
|
76
|
+
(input.warn ?? console.warn)('@mpgd/game-services: the commerce reconciler is journaling in memory for this session '
|
|
78
77
|
+ 'because no operationStore was given; purchases are not recoverable across restarts and '
|
|
79
78
|
+ 'the backend ledger is the only duplicate backstop. Pass a durable MonetizationOperationStore.');
|
|
80
79
|
}
|
|
@@ -254,121 +253,26 @@ export function createGameServicesClient(input) {
|
|
|
254
253
|
}
|
|
255
254
|
});
|
|
256
255
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
await analytics.track({
|
|
265
|
-
name: 'purchase_rejected',
|
|
266
|
-
properties: {
|
|
267
|
-
productId: purchaseInput.productId,
|
|
268
|
-
status: purchase.status,
|
|
269
|
-
reason: 'unsupported_target',
|
|
270
|
-
},
|
|
271
|
-
});
|
|
272
|
-
return {
|
|
273
|
-
status: 'rejected',
|
|
274
|
-
purchase,
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
progress.platformRequested();
|
|
278
|
-
const purchase = await input.gateway.commerce.purchase(purchaseInput);
|
|
279
|
-
progress.platformResult(purchase.status);
|
|
280
|
-
if (target === 'verse8') {
|
|
281
|
-
const status = purchase.status === 'completed' ? 'rejected' : purchase.status;
|
|
282
|
-
await analytics.track({
|
|
283
|
-
name: purchase.status === 'pending' ? 'purchase_pending' : 'purchase_rejected',
|
|
284
|
-
properties: {
|
|
285
|
-
productId: purchaseInput.productId,
|
|
286
|
-
status,
|
|
287
|
-
reason: purchase.status === 'completed'
|
|
288
|
-
? 'verse8_grants_require_agent8_purchase_event'
|
|
289
|
-
: purchase.status === 'pending'
|
|
290
|
-
? 'agent8_purchase_event_pending'
|
|
291
|
-
: undefined,
|
|
292
|
-
},
|
|
293
|
-
});
|
|
294
|
-
return {
|
|
295
|
-
status,
|
|
296
|
-
purchase,
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
// A provider that declares adapter settlement already wrote the ledger
|
|
300
|
-
// grant before returning; the target-name rule remains only for
|
|
301
|
-
// gateways without a commerce v2 provider (removed in C4).
|
|
302
|
-
const settledGrant = await findAdapterSettledGrant({
|
|
303
|
-
gateway: input.gateway,
|
|
304
|
-
target,
|
|
305
|
-
purchase,
|
|
306
|
-
});
|
|
307
|
-
if (settledGrant !== undefined) {
|
|
308
|
-
const result = {
|
|
309
|
-
status: 'granted',
|
|
310
|
-
purchase,
|
|
311
|
-
ledgerEntryId: settledGrant.ledgerEntryId,
|
|
312
|
-
};
|
|
313
|
-
await analytics.track({
|
|
314
|
-
name: 'purchase_granted',
|
|
315
|
-
properties: {
|
|
316
|
-
productId: purchaseInput.productId,
|
|
317
|
-
status: result.status,
|
|
318
|
-
ledgerEntryId: settledGrant.ledgerEntryId,
|
|
319
|
-
alreadyProcessed: settledGrant.alreadyProcessed,
|
|
320
|
-
},
|
|
321
|
-
});
|
|
322
|
-
return result;
|
|
323
|
-
}
|
|
324
|
-
if (purchase.status !== 'completed' || purchase.transactionId === undefined) {
|
|
325
|
-
await analytics.track({
|
|
326
|
-
name: purchase.status === 'pending' ? 'purchase_pending' : 'purchase_rejected',
|
|
327
|
-
properties: {
|
|
328
|
-
productId: purchaseInput.productId,
|
|
329
|
-
status: purchase.status,
|
|
330
|
-
reason: purchaseRejectionReason(purchase),
|
|
331
|
-
},
|
|
332
|
-
});
|
|
333
|
-
return {
|
|
334
|
-
status: purchase.status === 'completed' ? 'rejected' : purchase.status,
|
|
335
|
-
purchase,
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
|
-
const verificationRequest = {
|
|
339
|
-
target,
|
|
340
|
-
...(input.deploymentTarget === undefined || input.deploymentTarget === target
|
|
341
|
-
? {}
|
|
342
|
-
: { deploymentTarget: input.deploymentTarget }),
|
|
343
|
-
playerId: input.playerId,
|
|
344
|
-
productId: purchaseInput.productId,
|
|
345
|
-
platformTransactionId: purchase.transactionId,
|
|
346
|
-
idempotencyKey: purchaseInput.idempotencyKey,
|
|
347
|
-
purchasedAt: requestNow(),
|
|
348
|
-
...(purchase.evidence === undefined ? {} : { evidence: purchase.evidence }),
|
|
349
|
-
};
|
|
350
|
-
progress.serverRequested();
|
|
351
|
-
const verification = await input.backend.purchases.verifyPurchase(verificationRequest);
|
|
352
|
-
progress.serverResult(verification.verified);
|
|
353
|
-
const result = {
|
|
354
|
-
status: verification.verified ? 'granted' : (verification.disposition ?? 'rejected'),
|
|
355
|
-
purchase,
|
|
356
|
-
verification,
|
|
357
|
-
...(verification.ledgerEntryId === undefined
|
|
358
|
-
? {}
|
|
359
|
-
: { ledgerEntryId: verification.ledgerEntryId }),
|
|
256
|
+
// No commerce provider: the target sells nothing through this gateway.
|
|
257
|
+
// The reconciler is the only purchase path, so there is no fallback that
|
|
258
|
+
// could grant from a platform callback; fail closed without store UI.
|
|
259
|
+
return observeGameServicesOperation('purchase', options, async () => {
|
|
260
|
+
const purchase = {
|
|
261
|
+
status: 'failed',
|
|
262
|
+
entitlementIds: [],
|
|
360
263
|
};
|
|
361
264
|
await analytics.track({
|
|
362
|
-
name:
|
|
265
|
+
name: 'purchase_rejected',
|
|
363
266
|
properties: {
|
|
364
267
|
productId: purchaseInput.productId,
|
|
365
|
-
status:
|
|
366
|
-
|
|
367
|
-
alreadyProcessed: verification.alreadyProcessed,
|
|
368
|
-
reason: verification.reason,
|
|
268
|
+
status: purchase.status,
|
|
269
|
+
reason: 'commerce_unavailable',
|
|
369
270
|
},
|
|
370
271
|
});
|
|
371
|
-
return
|
|
272
|
+
return {
|
|
273
|
+
status: 'rejected',
|
|
274
|
+
purchase,
|
|
275
|
+
};
|
|
372
276
|
});
|
|
373
277
|
},
|
|
374
278
|
async claimRewardedAd(rewardInput, options) {
|
|
@@ -591,23 +495,6 @@ export function createGameServicesIdempotencyKey(input) {
|
|
|
591
495
|
function normalizeSegment(value) {
|
|
592
496
|
return value.replaceAll(/[^a-zA-Z0-9_-]+/g, '-').replaceAll(/^-|-$/g, '').slice(0, 64);
|
|
593
497
|
}
|
|
594
|
-
function purchaseRejectionReason(purchase) {
|
|
595
|
-
if (purchase.status === 'completed' && purchase.transactionId === undefined) {
|
|
596
|
-
return 'missing_transaction_id';
|
|
597
|
-
}
|
|
598
|
-
if (purchase.status === 'pending') {
|
|
599
|
-
return 'purchase_pending';
|
|
600
|
-
}
|
|
601
|
-
return undefined;
|
|
602
|
-
}
|
|
603
|
-
function isGameServicesCommerceTarget(target) {
|
|
604
|
-
return target === 'microsoft-store'
|
|
605
|
-
|| target === 'android'
|
|
606
|
-
|| target === 'ios'
|
|
607
|
-
|| target === 'ait'
|
|
608
|
-
|| target === 'verse8'
|
|
609
|
-
|| target === 'oneplay';
|
|
610
|
-
}
|
|
611
498
|
function isGameServicesLeaderboardTarget(target) {
|
|
612
499
|
return target === 'browser'
|
|
613
500
|
|| target === 'microsoft-store'
|
|
@@ -44,11 +44,14 @@ export interface CommercePurchaseOperationRecord {
|
|
|
44
44
|
readonly updatedAt: string;
|
|
45
45
|
/** The game's purchase input; absent for a purchase discovered from the store. */
|
|
46
46
|
readonly input?: GameServicesPurchaseInput;
|
|
47
|
+
/** Full checkout identity, including an optional offer or quantity, bound before launch. */
|
|
48
|
+
readonly checkoutInput?: RequestPurchaseInput;
|
|
47
49
|
readonly purchase?: StorePurchase;
|
|
48
50
|
/** Saved before the first send and resent unchanged on later passes. */
|
|
49
51
|
readonly request?: VerifyPurchaseRequest;
|
|
50
52
|
readonly response?: VerifyPurchaseResponse;
|
|
51
53
|
readonly ledgerEntryId?: string;
|
|
54
|
+
/** A retryable error on `requested` means the provider proved checkout never started. */
|
|
52
55
|
readonly error?: CommerceErrorInfo;
|
|
53
56
|
readonly revokedBy?: string;
|
|
54
57
|
}
|
|
@@ -59,10 +62,11 @@ export declare function isCommercePurchaseOperationRecord(record: unknown): reco
|
|
|
59
62
|
export declare function summarizeCommercePurchaseOperation(record: CommercePurchaseOperationRecord): CommercePurchaseSummaryStatus;
|
|
60
63
|
/**
|
|
61
64
|
* Only `granted` and `finishing` records that hold the backend's ledger entry
|
|
62
|
-
* id and a store transaction may be finished. The StoreKit
|
|
63
|
-
* finish allow-list of its own (the v1 driver
|
|
64
|
-
*
|
|
65
|
-
* the ledger entry id comes from the journal revision the
|
|
65
|
+
* id and a store transaction may be finished. The StoreKit SDK keeps no
|
|
66
|
+
* finish allow-list of its own (the removed v1 driver paired transactions in
|
|
67
|
+
* `finishGrantedTransaction`), so this is the finish gate for every `client`
|
|
68
|
+
* and `host` owner: the ledger entry id comes from the journal revision the
|
|
69
|
+
* backend grant wrote.
|
|
66
70
|
*/
|
|
67
71
|
export declare function assertCommerceFinishAllowed(record: CommercePurchaseOperationRecord): {
|
|
68
72
|
readonly transactionId: string;
|
|
@@ -99,7 +103,7 @@ export interface CreateCommerceReconcilerInput {
|
|
|
99
103
|
readonly provider: CommerceProvider;
|
|
100
104
|
/** The game backend; `getPurchaseLedgerStatus` enables the revocation pass once the server exposes it (C2). */
|
|
101
105
|
readonly backend: PurchaseVerificationApi;
|
|
102
|
-
/** Durable, compare-and-swap journal shared with the
|
|
106
|
+
/** Durable, compare-and-swap journal shared with the recoverable monetization client's rewarded-ad records. */
|
|
103
107
|
readonly store: MonetizationOperationStore;
|
|
104
108
|
readonly playerId: string;
|
|
105
109
|
/** Ledger target sent as `VerifyPurchaseRequest.target`. */
|
|
@@ -27,14 +27,22 @@ export function isCommercePurchaseOperationRecord(record) {
|
|
|
27
27
|
}
|
|
28
28
|
/** Summary status of a record: the RFC mapping, with `revoked` kept distinct. */
|
|
29
29
|
export function summarizeCommercePurchaseOperation(record) {
|
|
30
|
+
if (isRetryableBeforeLaunch(record)) {
|
|
31
|
+
return 'failed';
|
|
32
|
+
}
|
|
30
33
|
return summarizeCommercePurchaseState(record.state);
|
|
31
34
|
}
|
|
35
|
+
function isRetryableBeforeLaunch(record) {
|
|
36
|
+
return record.state === 'requested' && record.error?.retryable === true
|
|
37
|
+
&& record.purchase === undefined && record.request === undefined;
|
|
38
|
+
}
|
|
32
39
|
/**
|
|
33
40
|
* Only `granted` and `finishing` records that hold the backend's ledger entry
|
|
34
|
-
* id and a store transaction may be finished. The StoreKit
|
|
35
|
-
* finish allow-list of its own (the v1 driver
|
|
36
|
-
*
|
|
37
|
-
* the ledger entry id comes from the journal revision the
|
|
41
|
+
* id and a store transaction may be finished. The StoreKit SDK keeps no
|
|
42
|
+
* finish allow-list of its own (the removed v1 driver paired transactions in
|
|
43
|
+
* `finishGrantedTransaction`), so this is the finish gate for every `client`
|
|
44
|
+
* and `host` owner: the ledger entry id comes from the journal revision the
|
|
45
|
+
* backend grant wrote.
|
|
38
46
|
*/
|
|
39
47
|
export function assertCommerceFinishAllowed(record) {
|
|
40
48
|
if (record.state !== 'granted' && record.state !== 'finishing') {
|
|
@@ -62,9 +70,9 @@ export function toGameServicesPurchaseResult(record) {
|
|
|
62
70
|
}
|
|
63
71
|
function toPurchaseResult(record) {
|
|
64
72
|
const purchase = record.purchase;
|
|
65
|
-
if (record.state === 'cancelled' || record.state === 'failed') {
|
|
73
|
+
if (record.state === 'cancelled' || record.state === 'failed' || isRetryableBeforeLaunch(record)) {
|
|
66
74
|
return {
|
|
67
|
-
status: record.state,
|
|
75
|
+
status: record.state === 'cancelled' ? 'cancelled' : 'failed',
|
|
68
76
|
entitlementIds: [],
|
|
69
77
|
...(record.error === undefined ? {} : { diagnostic: toDiagnostic(record.error) }),
|
|
70
78
|
};
|
|
@@ -233,7 +241,7 @@ export function createCommerceReconciler(input) {
|
|
|
233
241
|
};
|
|
234
242
|
/**
|
|
235
243
|
* The provider's capability block. Event-triggered passes use the last read;
|
|
236
|
-
* an explicit trigger (`start`, `reconcile`, `restorePurchases
|
|
244
|
+
* an explicit trigger (`start`, `reconcile`, `restorePurchases`, a new purchase) reads it
|
|
237
245
|
* again, since availability can change at runtime. A failed read keeps the
|
|
238
246
|
* last block.
|
|
239
247
|
*/
|
|
@@ -394,6 +402,21 @@ export function createCommerceReconciler(input) {
|
|
|
394
402
|
}
|
|
395
403
|
return { created: reserved.created, record: reserved.record };
|
|
396
404
|
};
|
|
405
|
+
// Revising a refused pre-launch attempt does not change its lifecycle state.
|
|
406
|
+
// Keep the identity durable, and clear the refusal before a deliberate retry.
|
|
407
|
+
const reviseRequestedError = async (record, error) => {
|
|
408
|
+
const { error: _previousError, ...rest } = record;
|
|
409
|
+
const next = {
|
|
410
|
+
...rest,
|
|
411
|
+
...(error === undefined ? {} : { error }),
|
|
412
|
+
revision: record.revision + 1,
|
|
413
|
+
updatedAt: now(),
|
|
414
|
+
};
|
|
415
|
+
await journal.replace(record.revision, next);
|
|
416
|
+
remember(next);
|
|
417
|
+
retryAt.delete(record.idempotencyKey);
|
|
418
|
+
return next;
|
|
419
|
+
};
|
|
397
420
|
const readRecord = async (idempotencyKey) => {
|
|
398
421
|
const found = await journal.read(createCommercePurchaseOperationKey(target, idempotencyKey));
|
|
399
422
|
if (found === undefined) {
|
|
@@ -616,6 +639,11 @@ export function createCommerceReconciler(input) {
|
|
|
616
639
|
for (;;) {
|
|
617
640
|
switch (record.state) {
|
|
618
641
|
case 'requested':
|
|
642
|
+
if (isRetryableBeforeLaunch(record)) {
|
|
643
|
+
// Only an explicit same-key purchase retries a proven unopened
|
|
644
|
+
// checkout; recovery must never open a payment surface itself.
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
619
647
|
if (isInFlight(record.idempotencyKey)) {
|
|
620
648
|
return;
|
|
621
649
|
}
|
|
@@ -895,6 +923,9 @@ export function createCommerceReconciler(input) {
|
|
|
895
923
|
});
|
|
896
924
|
}
|
|
897
925
|
const journaled = (record) => {
|
|
926
|
+
if (isRetryableBeforeLaunch(record) && record.error !== undefined) {
|
|
927
|
+
return { outcome: 'failed', error: record.error };
|
|
928
|
+
}
|
|
898
929
|
if ((record.state === 'cancelled' || record.state === 'failed') && record.error !== undefined) {
|
|
899
930
|
return { outcome: record.state, error: record.error };
|
|
900
931
|
}
|
|
@@ -923,26 +954,45 @@ export function createCommerceReconciler(input) {
|
|
|
923
954
|
const existing = await readRecord(request.idempotencyKey);
|
|
924
955
|
if (existing !== undefined) {
|
|
925
956
|
if (existing.logicalId !== request.logicalId
|
|
926
|
-
|| (existing.input !== undefined && existing.input.source !== request.source)
|
|
957
|
+
|| (existing.input !== undefined && existing.input.source !== request.source)
|
|
958
|
+
|| (existing.checkoutInput !== undefined
|
|
959
|
+
&& (existing.checkoutInput.offerId !== request.offerId
|
|
960
|
+
|| existing.checkoutInput.quantity !== request.quantity))) {
|
|
927
961
|
throw new Error('Purchase idempotency key is bound to another product or source.');
|
|
928
962
|
}
|
|
929
|
-
|
|
963
|
+
if (!isRetryableBeforeLaunch(existing) || isInFlight(request.idempotencyKey)) {
|
|
964
|
+
return { kind: 'answered', result: journaled(existing) };
|
|
965
|
+
}
|
|
930
966
|
}
|
|
931
|
-
const
|
|
932
|
-
if (
|
|
967
|
+
const capabilitiesNow = await readCapabilities(true);
|
|
968
|
+
if (capabilitiesNow === undefined) {
|
|
933
969
|
return {
|
|
934
970
|
kind: 'answered',
|
|
935
|
-
result: failedOutcome(new CommerceError('
|
|
936
|
-
message: `
|
|
971
|
+
result: failedOutcome(new CommerceError('NOT_PREPARED', {
|
|
972
|
+
message: `The ${storeId} provider capabilities could not be read.`,
|
|
937
973
|
})),
|
|
938
974
|
};
|
|
939
975
|
}
|
|
940
|
-
|
|
941
|
-
|
|
976
|
+
if (capabilitiesNow.availability !== 'available') {
|
|
977
|
+
const code = capabilitiesNow.availability === 'configuration-required'
|
|
978
|
+
? 'CONFIGURATION_REQUIRED'
|
|
979
|
+
: capabilitiesNow.availability === 'action-required'
|
|
980
|
+
? 'ACTION_REQUIRED'
|
|
981
|
+
: 'STORE_UNAVAILABLE';
|
|
942
982
|
return {
|
|
943
983
|
kind: 'answered',
|
|
944
|
-
result: failedOutcome(new CommerceError(
|
|
945
|
-
message: `
|
|
984
|
+
result: failedOutcome(new CommerceError(code, {
|
|
985
|
+
message: `Store ${storeId} is ${capabilitiesNow.availability}.`,
|
|
986
|
+
retryable: capabilitiesNow.availability === 'temporarily-unavailable',
|
|
987
|
+
})),
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
const product = await resolveProduct({ logicalId: request.logicalId });
|
|
991
|
+
if (product === undefined) {
|
|
992
|
+
return {
|
|
993
|
+
kind: 'answered',
|
|
994
|
+
result: failedOutcome(new CommerceError('ITEM_UNAVAILABLE', {
|
|
995
|
+
message: `Store ${storeId} does not sell ${request.logicalId}.`,
|
|
946
996
|
})),
|
|
947
997
|
};
|
|
948
998
|
}
|
|
@@ -977,19 +1027,20 @@ export function createCommerceReconciler(input) {
|
|
|
977
1027
|
})),
|
|
978
1028
|
};
|
|
979
1029
|
}
|
|
980
|
-
const { created, record } = await reserve({
|
|
1030
|
+
const { created, record } = existing === undefined ? await reserve({
|
|
981
1031
|
idempotencyKey: request.idempotencyKey,
|
|
982
1032
|
store: storeId,
|
|
983
1033
|
logicalId: product.logicalId,
|
|
984
1034
|
productKind: product.kind,
|
|
985
1035
|
origin: 'game',
|
|
986
1036
|
state: 'requested',
|
|
1037
|
+
checkoutInput: { ...request },
|
|
987
1038
|
input: {
|
|
988
1039
|
productId: product.logicalId,
|
|
989
1040
|
source: request.source,
|
|
990
1041
|
idempotencyKey: request.idempotencyKey,
|
|
991
1042
|
},
|
|
992
|
-
});
|
|
1043
|
+
}) : { created: true, record: await reviseRequestedError(existing, undefined) };
|
|
993
1044
|
if (!created) {
|
|
994
1045
|
return { kind: 'answered', result: journaled(record) };
|
|
995
1046
|
}
|
|
@@ -1012,16 +1063,16 @@ export function createCommerceReconciler(input) {
|
|
|
1012
1063
|
* fails here (a stale attempt another pass ended after a very long sheet)
|
|
1013
1064
|
* is reported; the store answer is still returned to the caller.
|
|
1014
1065
|
*/
|
|
1015
|
-
const recordAnswer = async (request, record, product, result) => {
|
|
1066
|
+
const recordAnswer = async (request, record, product, result, notStarted) => {
|
|
1016
1067
|
try {
|
|
1017
|
-
return await journalAnswer(request, record, product, result);
|
|
1068
|
+
return await journalAnswer(request, record, product, result, notStarted);
|
|
1018
1069
|
}
|
|
1019
1070
|
catch (error) {
|
|
1020
1071
|
fail('journal', error, record.idempotencyKey);
|
|
1021
1072
|
return result;
|
|
1022
1073
|
}
|
|
1023
1074
|
};
|
|
1024
|
-
const journalAnswer = async (request, reserved, product, result) => {
|
|
1075
|
+
const journalAnswer = async (request, reserved, product, result, notStarted) => {
|
|
1025
1076
|
// A sheet open past inFlightTimeoutMs may have been ended by a pass as a
|
|
1026
1077
|
// stale attempt; the journal's answer stands, and a purchase the store
|
|
1027
1078
|
// reported meanwhile is journaled under its own record.
|
|
@@ -1068,6 +1119,9 @@ export function createCommerceReconciler(input) {
|
|
|
1068
1119
|
await write(launched, 'purchased', { purchase });
|
|
1069
1120
|
}
|
|
1070
1121
|
}
|
|
1122
|
+
else if (result.outcome === 'failed' && result.error.retryable && notStarted) {
|
|
1123
|
+
await reviseRequestedError(record, result.error);
|
|
1124
|
+
}
|
|
1071
1125
|
else {
|
|
1072
1126
|
await write(record, result.outcome, { error: result.error });
|
|
1073
1127
|
}
|
|
@@ -1088,13 +1142,24 @@ export function createCommerceReconciler(input) {
|
|
|
1088
1142
|
// The store call runs outside the lock: the sheet may stay open for
|
|
1089
1143
|
// minutes, and events arriving meanwhile are reconciled right away.
|
|
1090
1144
|
let answer;
|
|
1145
|
+
let notStarted = false;
|
|
1146
|
+
let presentationSequence = 0;
|
|
1147
|
+
const unsubscribePresentation = provider.presentation?.subscribe((event) => {
|
|
1148
|
+
if (event.idempotencyKey === request.idempotencyKey && event.sequence > presentationSequence) {
|
|
1149
|
+
presentationSequence = event.sequence;
|
|
1150
|
+
notStarted = event.state === 'not-started';
|
|
1151
|
+
}
|
|
1152
|
+
});
|
|
1091
1153
|
try {
|
|
1092
1154
|
answer = await provider.requestPurchase(request);
|
|
1093
1155
|
}
|
|
1094
1156
|
catch (error) {
|
|
1095
1157
|
answer = failedOutcome(error);
|
|
1096
1158
|
}
|
|
1097
|
-
|
|
1159
|
+
finally {
|
|
1160
|
+
unsubscribePresentation?.();
|
|
1161
|
+
}
|
|
1162
|
+
result = await withLock(() => recordAnswer(request, prepared.record, prepared.product, answer, notStarted));
|
|
1098
1163
|
}
|
|
1099
1164
|
}
|
|
1100
1165
|
finally {
|
|
@@ -38,12 +38,3 @@ export interface GooglePlayProductPurchaseBoundary extends GameServicesPurchaseG
|
|
|
38
38
|
verifyPurchase(input: VerifyPurchaseEvidenceInput): ReturnType<GameServicesEvidenceVerifier['verifyPurchase']>;
|
|
39
39
|
}
|
|
40
40
|
export declare function createGooglePlayProductPurchaseBoundary(input: CreateGooglePlayProductPurchaseBoundaryInput): GooglePlayProductPurchaseBoundary;
|
|
41
|
-
/**
|
|
42
|
-
* A non-secret transaction ID for purchases whose Google order ID is absent or
|
|
43
|
-
* delayed: `play-token-sha256:<hex>` of the purchase token.
|
|
44
|
-
*
|
|
45
|
-
* @deprecated Use `createGooglePlayTokenPurchaseKey` from `@mpgd/platform/commerce`;
|
|
46
|
-
* this alias is byte-for-byte the same helper (same key, same `TypeError` for
|
|
47
|
-
* an invalid token) and will be removed in the next major version.
|
|
48
|
-
*/
|
|
49
|
-
export declare const createGooglePlayTokenTransactionId: (purchaseToken: string) => Promise<string>;
|
|
@@ -381,15 +381,6 @@ function createGooglePlayObservedOrderMatch(observedTransactionId, tokenTransact
|
|
|
381
381
|
? { mode: 'token-only' }
|
|
382
382
|
: { mode: 'if-present', orderId: observedTransactionId };
|
|
383
383
|
}
|
|
384
|
-
/**
|
|
385
|
-
* A non-secret transaction ID for purchases whose Google order ID is absent or
|
|
386
|
-
* delayed: `play-token-sha256:<hex>` of the purchase token.
|
|
387
|
-
*
|
|
388
|
-
* @deprecated Use `createGooglePlayTokenPurchaseKey` from `@mpgd/platform/commerce`;
|
|
389
|
-
* this alias is byte-for-byte the same helper (same key, same `TypeError` for
|
|
390
|
-
* an invalid token) and will be removed in the next major version.
|
|
391
|
-
*/
|
|
392
|
-
export const createGooglePlayTokenTransactionId = createGooglePlayTokenPurchaseKey;
|
|
393
384
|
function readGooglePlayVerifiedContext(payload, packageName, productId, purchaseTokenDigest) {
|
|
394
385
|
if (payload?.googlePlayPackageName !== packageName
|
|
395
386
|
|| payload.googlePlayProductId !== productId
|