@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
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { isAuthoritativeMicrosoftStoreCompletion } from './authoritative-purchase.js';
|
|
2
1
|
import { createGameServicesClient, } from './client.js';
|
|
3
2
|
import { resolveClientRewardClaim } from './client-reward-evidence.js';
|
|
4
|
-
import { summarizeCommercePurchaseOperation,
|
|
3
|
+
import { summarizeCommercePurchaseOperation, } from './commerce-reconciler.js';
|
|
5
4
|
import { createDefaultClientRewardEvidenceRegistry } from './default-client-reward-evidence.js';
|
|
5
|
+
import { isRetiredPurchaseOpen } from './monetization-operation-store.js';
|
|
6
6
|
import { forwardGameServicesProgress, observeGameServicesOperation, } from './operation-progress.js';
|
|
7
7
|
/**
|
|
8
8
|
* Client-side recovery coordinates UI callbacks and authoritative backend calls.
|
|
@@ -15,24 +15,22 @@ export function createRecoverableMonetizationClient(input) {
|
|
|
15
15
|
const allowLegacyRewardCompletion = input.rewardEvidenceRegistry === undefined && input.gateway.ads.provider === undefined;
|
|
16
16
|
const observedAt = () => input.now?.() ?? new Date().toISOString();
|
|
17
17
|
const inFlight = new Map();
|
|
18
|
-
const unjournaledPurchases = new Map();
|
|
19
18
|
const unjournaledRewards = new Map();
|
|
20
|
-
const unjournaledPurchaseResponses = new Map();
|
|
21
19
|
const unjournaledRewardResponses = new Map();
|
|
22
|
-
const unjournaledPurchaseResults = new Map();
|
|
23
20
|
const unjournaledRewardResults = new Map();
|
|
24
|
-
// One
|
|
25
|
-
// purchase path
|
|
26
|
-
// per-operation client
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
//
|
|
21
|
+
// One purchase client over the durable journal: its reconciler is the only
|
|
22
|
+
// purchase path. A gateway without a commerce provider gets a client whose
|
|
23
|
+
// `purchase` answers `rejected`; rewarded ads keep the per-operation client
|
|
24
|
+
// below.
|
|
25
|
+
const commerceClient = createGameServicesClient({
|
|
26
|
+
...input,
|
|
27
|
+
operationStore: store,
|
|
28
|
+
});
|
|
29
|
+
const commerceReconciler = commerceClient.commerceReconciler;
|
|
30
|
+
// The per-operation rewarded-ad clients see a gateway without the commerce
|
|
31
|
+
// provider, so they never build a second reconciler over the journal.
|
|
34
32
|
const { commerceReconciler: _sharedReconciler, operationStore: _journal, onCommerceEvent: _onCommerceEvent, commerceProducts: _commerceProducts, ...legacyClientInput } = input;
|
|
35
|
-
const {
|
|
33
|
+
const { commerce: _commerce, ...legacyGateway } = input.gateway;
|
|
36
34
|
async function serializeOperation(key, task) {
|
|
37
35
|
const previous = inFlight.get(key) ?? Promise.resolve();
|
|
38
36
|
const running = previous.catch(() => undefined).then(task);
|
|
@@ -57,35 +55,12 @@ export function createRecoverableMonetizationClient(input) {
|
|
|
57
55
|
|| record.target !== input.target
|
|
58
56
|
|| record.deploymentTarget !== (input.deploymentTarget ?? input.target)
|
|
59
57
|
|| record.input.idempotencyKey !== idempotencyKey
|
|
60
|
-
||
|
|
61
|
-
!== subjectId) {
|
|
58
|
+
|| record.input.placementId !== subjectId) {
|
|
62
59
|
throw new Error('Monetization operation key is bound to another player or subject.');
|
|
63
60
|
}
|
|
64
61
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return commerceClient.purchase(operation, options);
|
|
68
|
-
}
|
|
69
|
-
const key = operationKey(input.target, 'purchase', operation.idempotencyKey);
|
|
70
|
-
return serializeOperation(key, async () => {
|
|
71
|
-
const reserved = await store.reserve({
|
|
72
|
-
key,
|
|
73
|
-
kind: 'purchase',
|
|
74
|
-
playerId: input.playerId,
|
|
75
|
-
target: input.target,
|
|
76
|
-
deploymentTarget: input.deploymentTarget ?? input.target,
|
|
77
|
-
revision: 0,
|
|
78
|
-
input: operation,
|
|
79
|
-
});
|
|
80
|
-
if (reserved.record.kind !== 'purchase') {
|
|
81
|
-
throw new Error('Monetization operation kind conflicts with its journal key.');
|
|
82
|
-
}
|
|
83
|
-
assertOwner(reserved.record, 'purchase', operation.idempotencyKey, operation.productId);
|
|
84
|
-
if (reserved.record.input.source !== operation.source) {
|
|
85
|
-
throw new Error('Purchase idempotency key was reused for another source.');
|
|
86
|
-
}
|
|
87
|
-
return resumePurchase(reserved.record, reserved.created, options);
|
|
88
|
-
});
|
|
62
|
+
function purchase(operation, options) {
|
|
63
|
+
return commerceClient.purchase(operation, options);
|
|
89
64
|
}
|
|
90
65
|
async function claimRewardedAd(operation, options) {
|
|
91
66
|
const key = operationKey(input.target, 'rewarded-ad', operation.idempotencyKey);
|
|
@@ -106,163 +81,6 @@ export function createRecoverableMonetizationClient(input) {
|
|
|
106
81
|
return resumeReward(reserved.record, reserved.created, options);
|
|
107
82
|
});
|
|
108
83
|
}
|
|
109
|
-
async function resumePurchase(initial, mayOpenPlatform = false, options) {
|
|
110
|
-
// Observe recovery once, so a journal failure resolved into a result still completes.
|
|
111
|
-
return observeGameServicesOperation('purchase', options, (progress) => {
|
|
112
|
-
return settlePurchase(initial, mayOpenPlatform, progress);
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
async function settlePurchase(initial, mayOpenPlatform, progress) {
|
|
116
|
-
let record = initial;
|
|
117
|
-
const unsavedResponse = unjournaledPurchaseResponses.get(record.key);
|
|
118
|
-
if (unsavedResponse !== undefined && record.response?.verified !== true) {
|
|
119
|
-
// A result beside the non-grant response is stale, such as another runtime's rejection.
|
|
120
|
-
// Drop it in the same write so the client path derives this grant again.
|
|
121
|
-
const { result: staleResult, ...current } = record;
|
|
122
|
-
try {
|
|
123
|
-
record = await save(current, { response: unsavedResponse });
|
|
124
|
-
unjournaledPurchaseResponses.delete(record.key);
|
|
125
|
-
}
|
|
126
|
-
catch {
|
|
127
|
-
return recordedPurchaseGrant({ ...current, response: unsavedResponse })
|
|
128
|
-
?? pendingPurchase(record.platform);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
else if (record.response?.verified === true) {
|
|
132
|
-
unjournaledPurchaseResponses.delete(record.key);
|
|
133
|
-
}
|
|
134
|
-
const unsavedResult = unjournaledPurchaseResults.get(record.key);
|
|
135
|
-
if (unsavedResult !== undefined && record.result?.status !== 'granted') {
|
|
136
|
-
try {
|
|
137
|
-
record = await save(record, { result: unsavedResult });
|
|
138
|
-
unjournaledPurchaseResults.delete(record.key);
|
|
139
|
-
}
|
|
140
|
-
catch {
|
|
141
|
-
return unsavedResult;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
else if (record.result?.status === 'granted') {
|
|
145
|
-
unjournaledPurchaseResults.delete(record.key);
|
|
146
|
-
}
|
|
147
|
-
const unjournaled = unjournaledPurchases.get(record.key);
|
|
148
|
-
if (unjournaled !== undefined && (record.platform === undefined
|
|
149
|
-
|| record.platform.status === 'pending' && unjournaled.platform.status !== 'pending')) {
|
|
150
|
-
try {
|
|
151
|
-
record = await save(record, {
|
|
152
|
-
platform: unjournaled.platform,
|
|
153
|
-
platformCompletedAt: unjournaled.completedAt,
|
|
154
|
-
});
|
|
155
|
-
unjournaledPurchases.delete(record.key);
|
|
156
|
-
}
|
|
157
|
-
catch {
|
|
158
|
-
return recordedPurchaseGrant({ ...record, platform: unjournaled.platform })
|
|
159
|
-
?? pendingPurchase(unjournaled.platform);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
else if (record.platform !== undefined) {
|
|
163
|
-
if (unjournaled !== undefined && !samePlatformResult(record.platform, unjournaled.platform)) {
|
|
164
|
-
throw new Error('A platform purchase callback conflicts with the recorded operation.');
|
|
165
|
-
}
|
|
166
|
-
unjournaledPurchases.delete(record.key);
|
|
167
|
-
}
|
|
168
|
-
const completed = record.result;
|
|
169
|
-
if (completed !== undefined && !purchaseNeedsRetry(record)) {
|
|
170
|
-
return completed;
|
|
171
|
-
}
|
|
172
|
-
if (record.request !== undefined) {
|
|
173
|
-
assertPurchaseRequest(record, input);
|
|
174
|
-
}
|
|
175
|
-
if (record.platform === undefined && !mayOpenPlatform) {
|
|
176
|
-
return pendingPurchase();
|
|
177
|
-
}
|
|
178
|
-
const client = createGameServicesClient({
|
|
179
|
-
...legacyClientInput,
|
|
180
|
-
requestNow: () => record.platformCompletedAt ?? observedAt(),
|
|
181
|
-
gateway: {
|
|
182
|
-
...legacyGateway,
|
|
183
|
-
commerce: {
|
|
184
|
-
...input.gateway.commerce,
|
|
185
|
-
async purchase(request) {
|
|
186
|
-
if (record.platform !== undefined) {
|
|
187
|
-
return record.platform;
|
|
188
|
-
}
|
|
189
|
-
const platform = await input.gateway.commerce.purchase(request);
|
|
190
|
-
const completedAt = observedAt();
|
|
191
|
-
const prior = record;
|
|
192
|
-
record = { ...record, platform, platformCompletedAt: completedAt };
|
|
193
|
-
try {
|
|
194
|
-
record = await save(prior, { platform, platformCompletedAt: completedAt });
|
|
195
|
-
}
|
|
196
|
-
catch (error) {
|
|
197
|
-
unjournaledPurchases.set(record.key, { platform, completedAt });
|
|
198
|
-
throw error;
|
|
199
|
-
}
|
|
200
|
-
return platform;
|
|
201
|
-
},
|
|
202
|
-
},
|
|
203
|
-
},
|
|
204
|
-
backend: {
|
|
205
|
-
...input.backend,
|
|
206
|
-
purchases: {
|
|
207
|
-
async verifyPurchase(request) {
|
|
208
|
-
if (record.request === undefined) {
|
|
209
|
-
record = await save(record, { request });
|
|
210
|
-
}
|
|
211
|
-
const recordedRequest = record.request;
|
|
212
|
-
if (recordedRequest === undefined) {
|
|
213
|
-
throw new Error('Purchase request was not journaled before backend dispatch.');
|
|
214
|
-
}
|
|
215
|
-
let response;
|
|
216
|
-
try {
|
|
217
|
-
response = await input.backend.purchases.verifyPurchase(recordedRequest);
|
|
218
|
-
}
|
|
219
|
-
catch {
|
|
220
|
-
if (record.response?.verified === true) {
|
|
221
|
-
return record.response;
|
|
222
|
-
}
|
|
223
|
-
return {
|
|
224
|
-
verified: false,
|
|
225
|
-
alreadyProcessed: false,
|
|
226
|
-
disposition: 'pending',
|
|
227
|
-
reason: 'BACKEND_UNAVAILABLE',
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
if (record.response?.verified === true && !response.verified) {
|
|
231
|
-
return record.response;
|
|
232
|
-
}
|
|
233
|
-
const prior = record;
|
|
234
|
-
record = { ...record, response };
|
|
235
|
-
try {
|
|
236
|
-
record = await save(prior, { response });
|
|
237
|
-
unjournaledPurchaseResponses.delete(record.key);
|
|
238
|
-
}
|
|
239
|
-
catch (error) {
|
|
240
|
-
if (response.verified) {
|
|
241
|
-
unjournaledPurchaseResponses.set(record.key, response);
|
|
242
|
-
}
|
|
243
|
-
throw error;
|
|
244
|
-
}
|
|
245
|
-
return response;
|
|
246
|
-
},
|
|
247
|
-
},
|
|
248
|
-
},
|
|
249
|
-
});
|
|
250
|
-
try {
|
|
251
|
-
const result = await client.purchase(record.input, forwardGameServicesProgress(progress));
|
|
252
|
-
if (result.status === 'granted') {
|
|
253
|
-
unjournaledPurchaseResults.set(record.key, result);
|
|
254
|
-
}
|
|
255
|
-
record = await save(record, { result });
|
|
256
|
-
unjournaledPurchaseResults.delete(record.key);
|
|
257
|
-
return result;
|
|
258
|
-
}
|
|
259
|
-
catch {
|
|
260
|
-
// No UI retry is safe after a possibly completed platform callback.
|
|
261
|
-
return recordedPurchaseGrant(record)
|
|
262
|
-
?? unjournaledPurchaseResults.get(record.key)
|
|
263
|
-
?? pendingPurchase(record.platform);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
84
|
async function resumeReward(initial, mayOpenPlatform = false, options) {
|
|
267
85
|
return observeGameServicesOperation('rewarded-ad', options, (progress) => {
|
|
268
86
|
return settleReward(initial, mayOpenPlatform, progress);
|
|
@@ -417,50 +235,6 @@ export function createRecoverableMonetizationClient(input) {
|
|
|
417
235
|
?? pendingReward(record.platform);
|
|
418
236
|
}
|
|
419
237
|
}
|
|
420
|
-
async function recoverPurchaseResult(idempotencyKey, platform) {
|
|
421
|
-
const key = operationKey(input.target, 'purchase', idempotencyKey);
|
|
422
|
-
return serializeOperation(key, async () => {
|
|
423
|
-
const found = await store.read(key);
|
|
424
|
-
if (found?.kind !== 'purchase') {
|
|
425
|
-
if (commerceReconciler !== undefined) {
|
|
426
|
-
// Deprecated thin wrapper: the v2 reconciler learns the result from
|
|
427
|
-
// the provider, so the platform callback is not replayed.
|
|
428
|
-
await commerceReconciler.reconcile();
|
|
429
|
-
const journaled = await commerceReconciler.read(idempotencyKey);
|
|
430
|
-
if (journaled === undefined) {
|
|
431
|
-
throw new Error('No reserved purchase operation matches this platform callback.');
|
|
432
|
-
}
|
|
433
|
-
return toGameServicesPurchaseResult(journaled);
|
|
434
|
-
}
|
|
435
|
-
throw new Error('No reserved purchase operation matches this platform callback.');
|
|
436
|
-
}
|
|
437
|
-
assertOwner(found, 'purchase', idempotencyKey, found.input.productId);
|
|
438
|
-
const transient = unjournaledPurchases.get(key);
|
|
439
|
-
if (transient !== undefined && !samePlatformResult(transient.platform, platform)
|
|
440
|
-
&& !(transient.platform.status === 'pending' && platform.status !== 'pending')) {
|
|
441
|
-
throw new Error('A platform purchase callback conflicts with the unjournaled result.');
|
|
442
|
-
}
|
|
443
|
-
const canAdvance = found.platform?.status === 'pending'
|
|
444
|
-
&& platform.status !== 'pending' && found.request === undefined;
|
|
445
|
-
if (found.platform !== undefined && !samePlatformResult(found.platform, platform)
|
|
446
|
-
&& !canAdvance) {
|
|
447
|
-
throw new Error('A platform purchase callback conflicts with the recorded operation.');
|
|
448
|
-
}
|
|
449
|
-
let record = found;
|
|
450
|
-
if (found.platform === undefined || canAdvance) {
|
|
451
|
-
const completedAt = observedAt();
|
|
452
|
-
try {
|
|
453
|
-
record = await save(found, { platform, platformCompletedAt: completedAt });
|
|
454
|
-
}
|
|
455
|
-
catch {
|
|
456
|
-
unjournaledPurchases.set(key, { platform, completedAt });
|
|
457
|
-
return recordedPurchaseGrant({ ...found, platform }) ?? pendingPurchase(platform);
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
unjournaledPurchases.delete(key);
|
|
461
|
-
return resumePurchase(record);
|
|
462
|
-
});
|
|
463
|
-
}
|
|
464
238
|
async function recoverRewardResult(idempotencyKey, platform) {
|
|
465
239
|
const key = operationKey(input.target, 'rewarded-ad', idempotencyKey);
|
|
466
240
|
return serializeOperation(key, async () => {
|
|
@@ -585,6 +359,104 @@ export function createRecoverableMonetizationClient(input) {
|
|
|
585
359
|
})),
|
|
586
360
|
];
|
|
587
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* Finishes the backend verification a retired v1 purchase record was in the
|
|
364
|
+
* middle of. The grant is the backend's answer, never a local decision, and
|
|
365
|
+
* the store UI is never reopened. Backend or journal trouble leaves the
|
|
366
|
+
* record open for the next pass.
|
|
367
|
+
*/
|
|
368
|
+
async function drainRetiredPurchase(initial) {
|
|
369
|
+
const summarize = (latest, status, finalizationPending = false) => ({
|
|
370
|
+
kind: 'purchase',
|
|
371
|
+
idempotencyKey: latest.input.idempotencyKey,
|
|
372
|
+
status,
|
|
373
|
+
finalizationPending,
|
|
374
|
+
});
|
|
375
|
+
try {
|
|
376
|
+
return await serializeOperation(initial.key, async () => {
|
|
377
|
+
const latest = await store.read(initial.key) ?? initial;
|
|
378
|
+
if (latest.kind !== 'purchase' || !isRetiredPurchaseOpen(latest)) {
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
if (latest.key !== operationKey(input.target, 'purchase', latest.input.idempotencyKey)
|
|
382
|
+
|| !ownsRecord(latest)) {
|
|
383
|
+
return summarize(latest, 'action-required');
|
|
384
|
+
}
|
|
385
|
+
const platform = latest.platform;
|
|
386
|
+
if (platform?.status !== 'completed' || platform.transactionId === undefined) {
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
const request = latest.request ?? {
|
|
390
|
+
target: latest.target,
|
|
391
|
+
...(latest.deploymentTarget === latest.target
|
|
392
|
+
? {}
|
|
393
|
+
: { deploymentTarget: latest.deploymentTarget }),
|
|
394
|
+
playerId: latest.playerId,
|
|
395
|
+
productId: latest.input.productId,
|
|
396
|
+
platformTransactionId: platform.transactionId,
|
|
397
|
+
idempotencyKey: latest.input.idempotencyKey,
|
|
398
|
+
purchasedAt: latest.platformCompletedAt ?? observedAt(),
|
|
399
|
+
...(platform.evidence === undefined ? {} : { evidence: platform.evidence }),
|
|
400
|
+
};
|
|
401
|
+
if (request.playerId !== input.playerId
|
|
402
|
+
|| request.target !== latest.target
|
|
403
|
+
|| (request.deploymentTarget ?? request.target) !== latest.deploymentTarget
|
|
404
|
+
|| request.productId !== latest.input.productId
|
|
405
|
+
|| request.idempotencyKey !== latest.input.idempotencyKey
|
|
406
|
+
|| request.platformTransactionId !== platform.transactionId) {
|
|
407
|
+
return summarize(latest, 'action-required');
|
|
408
|
+
}
|
|
409
|
+
// A grant whose consume or acknowledge is still pending is sent once
|
|
410
|
+
// more as well: the backend retries the finalization of a ledger
|
|
411
|
+
// entry it already holds under the same key, as the v1 client did.
|
|
412
|
+
const saved = latest.response;
|
|
413
|
+
let response = saved;
|
|
414
|
+
if (saved?.verified !== true || saved.finalization?.status === 'pending') {
|
|
415
|
+
let answer;
|
|
416
|
+
try {
|
|
417
|
+
answer = await input.backend.purchases.verifyPurchase(request);
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
// Transport trouble stays retryable; nothing is decided here.
|
|
421
|
+
return saved?.verified === true
|
|
422
|
+
? summarize(latest, 'granted', true)
|
|
423
|
+
: summarize(latest, 'pending');
|
|
424
|
+
}
|
|
425
|
+
// A recorded grant is never replaced by a later non-grant answer.
|
|
426
|
+
response = saved?.verified === true && !answer.verified ? saved : answer;
|
|
427
|
+
}
|
|
428
|
+
if (response === undefined) {
|
|
429
|
+
return summarize(latest, 'pending');
|
|
430
|
+
}
|
|
431
|
+
const finalizationPending = response.verified
|
|
432
|
+
&& response.finalization?.status === 'pending';
|
|
433
|
+
const result = response.verified
|
|
434
|
+
? {
|
|
435
|
+
status: 'granted',
|
|
436
|
+
purchase: platform,
|
|
437
|
+
verification: response,
|
|
438
|
+
...(response.ledgerEntryId === undefined
|
|
439
|
+
? {}
|
|
440
|
+
: { ledgerEntryId: response.ledgerEntryId }),
|
|
441
|
+
}
|
|
442
|
+
: {
|
|
443
|
+
status: response.disposition === 'pending' ? 'pending' : 'rejected',
|
|
444
|
+
purchase: platform,
|
|
445
|
+
verification: response,
|
|
446
|
+
};
|
|
447
|
+
try {
|
|
448
|
+
await save(latest, { request, response, result });
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
return summarize(latest, result.status === 'granted' ? 'granted' : 'pending', finalizationPending);
|
|
452
|
+
}
|
|
453
|
+
return summarize(latest, result.status === 'granted' ? 'granted' : result.status, finalizationPending);
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
return summarize(initial, 'pending');
|
|
458
|
+
}
|
|
459
|
+
}
|
|
588
460
|
async function reconcileLegacy() {
|
|
589
461
|
const pending = await store.listRecoverable(input.playerId);
|
|
590
462
|
const summaries = [];
|
|
@@ -592,7 +464,14 @@ export function createRecoverableMonetizationClient(input) {
|
|
|
592
464
|
if (!ownsRecord(record)) {
|
|
593
465
|
continue;
|
|
594
466
|
}
|
|
595
|
-
if (record.kind
|
|
467
|
+
if (record.kind === 'purchase') {
|
|
468
|
+
const summary = await drainRetiredPurchase(record);
|
|
469
|
+
if (summary !== undefined) {
|
|
470
|
+
summaries.push(summary);
|
|
471
|
+
}
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
if (record.kind !== 'rewarded-ad') {
|
|
596
475
|
continue;
|
|
597
476
|
}
|
|
598
477
|
const idempotencyKey = typeof record.input?.idempotencyKey === 'string'
|
|
@@ -610,19 +489,10 @@ export function createRecoverableMonetizationClient(input) {
|
|
|
610
489
|
catch {
|
|
611
490
|
throw new OperationReadError();
|
|
612
491
|
}
|
|
613
|
-
if (latest.kind
|
|
492
|
+
if (latest.kind !== 'rewarded-ad') {
|
|
614
493
|
throw new Error('Monetization operation kind conflicts with its journal key.');
|
|
615
494
|
}
|
|
616
|
-
assertOwner(latest, latest.kind, latest.input.idempotencyKey, latest.
|
|
617
|
-
if (latest.kind === 'purchase') {
|
|
618
|
-
const result = await resumePurchase(latest);
|
|
619
|
-
return {
|
|
620
|
-
kind: latest.kind,
|
|
621
|
-
idempotencyKey: latest.input.idempotencyKey,
|
|
622
|
-
status: result.status,
|
|
623
|
-
finalizationPending: result.verification?.finalization?.status === 'pending',
|
|
624
|
-
};
|
|
625
|
-
}
|
|
495
|
+
assertOwner(latest, latest.kind, latest.input.idempotencyKey, latest.input.placementId);
|
|
626
496
|
const result = await resumeReward(latest);
|
|
627
497
|
return {
|
|
628
498
|
kind: latest.kind,
|
|
@@ -647,7 +517,6 @@ export function createRecoverableMonetizationClient(input) {
|
|
|
647
517
|
return {
|
|
648
518
|
purchase,
|
|
649
519
|
claimRewardedAd,
|
|
650
|
-
recoverPurchaseResult,
|
|
651
520
|
recoverRewardResult,
|
|
652
521
|
reconcile,
|
|
653
522
|
restorePurchases,
|
|
@@ -678,42 +547,12 @@ function canonicalJson(value) {
|
|
|
678
547
|
}
|
|
679
548
|
return value;
|
|
680
549
|
}
|
|
681
|
-
function pendingPurchase(platform) {
|
|
682
|
-
return {
|
|
683
|
-
status: 'pending',
|
|
684
|
-
purchase: platform ?? { status: 'pending', entitlementIds: [] },
|
|
685
|
-
};
|
|
686
|
-
}
|
|
687
550
|
function pendingReward(platform) {
|
|
688
551
|
return {
|
|
689
552
|
status: 'pending',
|
|
690
553
|
reward: platform ?? { status: 'pending', rewardGranted: false },
|
|
691
554
|
};
|
|
692
555
|
}
|
|
693
|
-
function recordedPurchaseGrant(record) {
|
|
694
|
-
if (record.result?.status === 'granted') {
|
|
695
|
-
return record.result;
|
|
696
|
-
}
|
|
697
|
-
if (record.platform !== undefined
|
|
698
|
-
&& isAuthoritativeMicrosoftStoreCompletion(record.target, record.platform)) {
|
|
699
|
-
// The adapter completed this ledger grant before returning, as the client path reports it.
|
|
700
|
-
return {
|
|
701
|
-
status: 'granted',
|
|
702
|
-
purchase: record.platform,
|
|
703
|
-
ledgerEntryId: record.platform.transactionId,
|
|
704
|
-
};
|
|
705
|
-
}
|
|
706
|
-
if (record.response?.verified !== true || record.platform === undefined) {
|
|
707
|
-
return undefined;
|
|
708
|
-
}
|
|
709
|
-
return {
|
|
710
|
-
status: 'granted',
|
|
711
|
-
purchase: record.platform,
|
|
712
|
-
verification: record.response,
|
|
713
|
-
...(record.response.ledgerEntryId === undefined
|
|
714
|
-
? {} : { ledgerEntryId: record.response.ledgerEntryId }),
|
|
715
|
-
};
|
|
716
|
-
}
|
|
717
556
|
function recordedRewardGrant(record) {
|
|
718
557
|
if (record.result?.status === 'granted') {
|
|
719
558
|
return record.result;
|
|
@@ -729,22 +568,6 @@ function recordedRewardGrant(record) {
|
|
|
729
568
|
? {} : { ledgerEntryId: record.response.ledgerEntryId }),
|
|
730
569
|
};
|
|
731
570
|
}
|
|
732
|
-
function purchaseNeedsRetry(record) {
|
|
733
|
-
return record.result?.status === 'pending'
|
|
734
|
-
|| record.response?.finalization?.status === 'pending';
|
|
735
|
-
}
|
|
736
|
-
function assertPurchaseRequest(record, input) {
|
|
737
|
-
const request = record.request;
|
|
738
|
-
if (request === undefined || record.platform?.status !== 'completed'
|
|
739
|
-
|| request.playerId !== record.playerId || request.playerId !== input.playerId
|
|
740
|
-
|| request.target !== record.target || request.productId !== record.input.productId
|
|
741
|
-
|| request.idempotencyKey !== record.input.idempotencyKey
|
|
742
|
-
|| request.platformTransactionId !== record.platform.transactionId
|
|
743
|
-
|| request.deploymentTarget !== (input.deploymentTarget === input.target
|
|
744
|
-
? undefined : input.deploymentTarget)) {
|
|
745
|
-
throw new Error('Journaled purchase verification is not bound to its platform result.');
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
571
|
function assertRewardRequest(record, input) {
|
|
749
572
|
const request = record.request;
|
|
750
573
|
const candidate = record.platform === undefined ? undefined : resolveClientRewardClaim(record.platform, input.rewardEvidenceRegistry ?? createDefaultClientRewardEvidenceRegistry(), { allowLegacyCompletion: input.rewardEvidenceRegistry === undefined && input.gateway.ads.provider === undefined });
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasNetworkPathPrefix, normalizeRootRelativeRoute } from '@mpgd/platform/routes';
|
|
1
2
|
import { assertOwnEnumerablePropertyLimit } from './validation.js';
|
|
2
3
|
const platformTargets = new Set([
|
|
3
4
|
'browser',
|
|
@@ -333,29 +334,14 @@ function normalizeTemplateData(input) {
|
|
|
333
334
|
return Object.fromEntries(entries);
|
|
334
335
|
}
|
|
335
336
|
function normalizeDeepLink(input, allowedOrigins) {
|
|
337
|
+
// normalizeIdentifier already refused control characters.
|
|
336
338
|
const deepLink = normalizeIdentifier(input, 'deepLink');
|
|
337
|
-
if (/[\x00-\x1F\x7F]/u.test(deepLink)) {
|
|
338
|
-
throw new Error('deepLink must not contain control characters.');
|
|
339
|
-
}
|
|
340
339
|
if (deepLink.startsWith('/') && !deepLink.startsWith('//')) {
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
// A backslash after the leading slash parses as an authority (`/\host`),
|
|
344
|
-
// so the link names a host, not a path on the game origin.
|
|
345
|
-
if (!deepLink.startsWith('/\\')) {
|
|
346
|
-
try {
|
|
347
|
-
parsed = new URL(deepLink, baseUrl);
|
|
348
|
-
}
|
|
349
|
-
catch {
|
|
350
|
-
// Rejected below.
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
if (parsed === undefined
|
|
354
|
-
|| parsed.origin !== baseUrl.origin
|
|
355
|
-
|| hasNetworkPathPrefix(parsed.pathname)) {
|
|
340
|
+
const route = normalizeRootRelativeRoute(deepLink);
|
|
341
|
+
if (route === undefined) {
|
|
356
342
|
throw new Error('deepLink must stay on the configured game origin.');
|
|
357
343
|
}
|
|
358
|
-
return requireNormalizedDeepLinkLength(
|
|
344
|
+
return requireNormalizedDeepLinkLength(route);
|
|
359
345
|
}
|
|
360
346
|
let parsed;
|
|
361
347
|
try {
|
|
@@ -378,22 +364,6 @@ function normalizeDeepLink(input, allowedOrigins) {
|
|
|
378
364
|
}
|
|
379
365
|
return requireNormalizedDeepLinkLength(parsed.toString());
|
|
380
366
|
}
|
|
381
|
-
/**
|
|
382
|
-
* Whether a normalized path could be read as a network-path reference to
|
|
383
|
-
* another host. Dot segments (also percent-encoded) can collapse `/.//host`
|
|
384
|
-
* into `//host`. A consumer that percent-decodes the path once and parses it
|
|
385
|
-
* again can reach the same shape from `/%2F/host`, `/%5Chost`, `/%09/host`,
|
|
386
|
-
* or `/.%2F/host`: URL parsing drops tabs and newlines, reads a backslash as
|
|
387
|
-
* a slash, and removes dot segments, so the decoded path is checked after
|
|
388
|
-
* the same steps.
|
|
389
|
-
*/
|
|
390
|
-
function hasNetworkPathPrefix(pathname) {
|
|
391
|
-
const decoded = pathname
|
|
392
|
-
.replace(/%([0-7][0-9a-f])/giu, (_escape, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
|
|
393
|
-
.replace(/[\t\n\r]/gu, '');
|
|
394
|
-
return /^[/\\]{2}/u.test(decoded)
|
|
395
|
-
|| new URL(decoded, 'https://mpgd.invalid').pathname.startsWith('//');
|
|
396
|
-
}
|
|
397
367
|
/** Normalization can grow a link; keep it within the input cap so it normalizes to itself. */
|
|
398
368
|
function requireNormalizedDeepLinkLength(deepLink) {
|
|
399
369
|
if (deepLink.length > maxIdentifierLength) {
|
package/dist/types.d.ts
CHANGED
|
@@ -5,15 +5,6 @@ import type { ClaimAdRewardResponse, PurchaseGrantFinalization, PurchaseLedgerSt
|
|
|
5
5
|
export type { ClaimAdRewardResponse, PurchaseGrantFinalization, PurchaseGrantFinalizationAction, PurchaseLedgerStatus, PurchaseLedgerStatusRequest, PurchaseLedgerStatusResponse, VerifyPurchaseResponse, } from './operation-results.js';
|
|
6
6
|
export type { PlatformEvidenceEnvelope } from '@mpgd/platform';
|
|
7
7
|
/**
|
|
8
|
-
* The closed v1 purchase target list. The deployment target of a v1 verify
|
|
9
|
-
* request names its store implicitly (`android` is Google Play, `ios` the App
|
|
10
|
-
* Store, `ait` Apps in Toss); `resolveLegacyCommerceStoreId` is that mapping.
|
|
11
|
-
*
|
|
12
|
-
* @deprecated `VerifyPurchaseRequest.target` is open since commerce v2 C2
|
|
13
|
-
* (`GameServicesStoreTarget` is `StoreId`); C4 removes this alias.
|
|
14
|
-
*/
|
|
15
|
-
export type GameServicesLegacyStoreTarget = Extract<PlatformTarget, 'microsoft-store' | 'android' | 'ios' | 'ait' | 'oneplay'>;
|
|
16
|
-
/**
|
|
17
8
|
* An open store or deployment target identifier (RFC "Open store ids"). The
|
|
18
9
|
* request validator bounds its shape; the backend's commerce evidence
|
|
19
10
|
* registry decides which stores it serves.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/game-services",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -198,13 +198,14 @@
|
|
|
198
198
|
"dist"
|
|
199
199
|
],
|
|
200
200
|
"dependencies": {
|
|
201
|
+
"@ait-kit/api-core": "0.6.0",
|
|
201
202
|
"@noble/secp256k1": "3.1.0",
|
|
202
203
|
"@orpc/client": "2.0.0-beta.14",
|
|
203
204
|
"@orpc/contract": "2.0.0-beta.14",
|
|
204
205
|
"@orpc/server": "2.0.0-beta.14",
|
|
205
|
-
"@mpgd/
|
|
206
|
-
"@mpgd/
|
|
207
|
-
"@mpgd/
|
|
206
|
+
"@mpgd/analytics": "0.4.9",
|
|
207
|
+
"@mpgd/catalog": "0.10.0",
|
|
208
|
+
"@mpgd/platform": "0.23.0"
|
|
208
209
|
},
|
|
209
210
|
"peerDependencies": {
|
|
210
211
|
"@apple/app-store-server-library": "^3.1.0"
|
|
@@ -233,7 +234,7 @@
|
|
|
233
234
|
"fix": "ttsc fix",
|
|
234
235
|
"build": "cd ../.. && node tools/run-ttsx.mjs tools/package/build-packages.ts @mpgd/game-services",
|
|
235
236
|
"test:packed-transport": "pnpm build && node test/runtime-transport-package-import.mjs && node test/reward-evidence-import.mjs",
|
|
236
|
-
"test:unit": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-progress.test.ts && node tools/run-ttsx.mjs packages/game-services/src/runtime.test.ts && node tools/run-ttsx.mjs packages/game-services/src/types.test.ts && node tools/run-ttsx.mjs packages/game-services/src/server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-publisher-client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-identity.test.ts && node tools/run-ttsx.mjs packages/game-services/src/platform-order.test.ts && node tools/run-ttsx.mjs packages/game-services/src/progress-link.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notification-delivery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/admob-ssv.test.ts && node tools/run-ttsx.mjs packages/game-services/src/app-store-verifier.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-evidence-verification-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-partner-api.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-durability-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-transport.test.ts && node tools/run-ttsx.mjs packages/game-services/src/guest-session.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-reward-evidence.test.ts && node tools/run-ttsx.mjs packages/game-services/src/ad-reward-verifier-registry.test.ts && node tools/run-ttsx.mjs packages/game-services/src/oneplay-reward.test.ts && node tools/run-ttsx.mjs packages/game-services/src/oneplay-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-registry-routing.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-registry-server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-ledger-revocation.test.ts",
|
|
237
|
+
"test:unit": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-progress.test.ts && node tools/run-ttsx.mjs packages/game-services/src/runtime.test.ts && node tools/run-ttsx.mjs packages/game-services/src/types.test.ts && node tools/run-ttsx.mjs packages/game-services/src/server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-publisher-client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-identity.test.ts && node tools/run-ttsx.mjs packages/game-services/src/platform-order.test.ts && node tools/run-ttsx.mjs packages/game-services/src/progress-link.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notification-delivery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/admob-ssv.test.ts && node tools/run-ttsx.mjs packages/game-services/src/app-store-verifier.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-evidence-verification-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-partner-api.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-durability-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-transport.test.ts && node tools/run-ttsx.mjs packages/game-services/src/guest-session.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-reward-evidence.test.ts && node tools/run-ttsx.mjs packages/game-services/src/ad-reward-verifier-registry.test.ts && node tools/run-ttsx.mjs packages/game-services/src/oneplay-reward.test.ts && node tools/run-ttsx.mjs packages/game-services/src/oneplay-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-registry-routing.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-registry-server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-ledger-revocation.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-anonymous-auth.test.ts",
|
|
237
238
|
"test:recovery": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/monetization-recovery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/app-store-recovery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-reconciler.test.ts && node tools/run-ttsx.mjs packages/game-services/src/commerce-reconciler-conformance.test.ts",
|
|
238
239
|
"test": "pnpm test:unit && pnpm test:app-store-server && pnpm test:recovery && pnpm test:packed-transport",
|
|
239
240
|
"test:app-store-server": "vitest run"
|