@mpgd/game-services 0.22.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.d.ts +9 -1
- package/dist/notification-delivery.js +19 -9
- package/dist/runtime.d.ts +7 -0
- package/dist/runtime.js +1 -0
- 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 });
|
|
@@ -26,7 +26,15 @@ export declare class NotificationDeliveryNotSentError extends Error {
|
|
|
26
26
|
readonly name = "NotificationDeliveryNotSentError";
|
|
27
27
|
}
|
|
28
28
|
export interface NotificationDeepLinkPolicy {
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Exact HTTP(S) origins permitted for absolute links. Root-relative links are
|
|
31
|
+
* allowed when they stay a path on the game origin, so one that names a host
|
|
32
|
+
* after a backslash (`/\host`) is rejected. Either kind is rejected when its
|
|
33
|
+
* path would begin with an empty segment after dot segments collapse
|
|
34
|
+
* (`/.//host`), or after it is percent-decoded once and parsed again
|
|
35
|
+
* (`/%2F/host`, `/%5Chost`, `/%09/host`, `/.%2F/host`), or when it is longer
|
|
36
|
+
* than 2,048 characters after normalization.
|
|
37
|
+
*/
|
|
30
38
|
readonly allowedOrigins?: readonly string[];
|
|
31
39
|
}
|
|
32
40
|
export interface NotificationDeliveryClaimOptions extends NotificationDeepLinkPolicy {
|
|
@@ -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',
|
|
@@ -20,6 +21,8 @@ const notificationTopics = new Set([
|
|
|
20
21
|
const defaultClaimLeaseMs = 5 * 60 * 1_000;
|
|
21
22
|
const maxCompletionAttempts = 3;
|
|
22
23
|
const maxNotificationTemplateDataEntries = 128;
|
|
24
|
+
/** Caps identifiers, including a deep link after normalization so it normalizes to itself. */
|
|
25
|
+
const maxIdentifierLength = 2_048;
|
|
23
26
|
/** Throw only when the provider can prove that no external send was attempted. */
|
|
24
27
|
export class NotificationDeliveryNotSentError extends Error {
|
|
25
28
|
name = 'NotificationDeliveryNotSentError';
|
|
@@ -331,17 +334,14 @@ function normalizeTemplateData(input) {
|
|
|
331
334
|
return Object.fromEntries(entries);
|
|
332
335
|
}
|
|
333
336
|
function normalizeDeepLink(input, allowedOrigins) {
|
|
337
|
+
// normalizeIdentifier already refused control characters.
|
|
334
338
|
const deepLink = normalizeIdentifier(input, 'deepLink');
|
|
335
|
-
if (/[\x00-\x1F\x7F]/u.test(deepLink)) {
|
|
336
|
-
throw new Error('deepLink must not contain control characters.');
|
|
337
|
-
}
|
|
338
339
|
if (deepLink.startsWith('/') && !deepLink.startsWith('//')) {
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
if (parsed.origin !== baseUrl.origin) {
|
|
340
|
+
const route = normalizeRootRelativeRoute(deepLink);
|
|
341
|
+
if (route === undefined) {
|
|
342
342
|
throw new Error('deepLink must stay on the configured game origin.');
|
|
343
343
|
}
|
|
344
|
-
return
|
|
344
|
+
return requireNormalizedDeepLinkLength(route);
|
|
345
345
|
}
|
|
346
346
|
let parsed;
|
|
347
347
|
try {
|
|
@@ -359,7 +359,17 @@ function normalizeDeepLink(input, allowedOrigins) {
|
|
|
359
359
|
if (!allowedOrigins.has(parsed.origin)) {
|
|
360
360
|
throw new Error('deepLink origin is not allowed.');
|
|
361
361
|
}
|
|
362
|
-
|
|
362
|
+
if (hasNetworkPathPrefix(parsed.pathname)) {
|
|
363
|
+
throw new Error('deepLink path must not begin with an empty segment, even after percent-decoding.');
|
|
364
|
+
}
|
|
365
|
+
return requireNormalizedDeepLinkLength(parsed.toString());
|
|
366
|
+
}
|
|
367
|
+
/** Normalization can grow a link; keep it within the input cap so it normalizes to itself. */
|
|
368
|
+
function requireNormalizedDeepLinkLength(deepLink) {
|
|
369
|
+
if (deepLink.length > maxIdentifierLength) {
|
|
370
|
+
throw new Error(`deepLink must contain at most ${String(maxIdentifierLength)} characters after normalization.`);
|
|
371
|
+
}
|
|
372
|
+
return deepLink;
|
|
363
373
|
}
|
|
364
374
|
function normalizeAllowedDeepLinkOrigins(input) {
|
|
365
375
|
const origins = new Set();
|
|
@@ -427,7 +437,7 @@ function assertRecord(input, label) {
|
|
|
427
437
|
function normalizeIdentifier(input, label) {
|
|
428
438
|
if (typeof input !== 'string'
|
|
429
439
|
|| input.length === 0
|
|
430
|
-
|| input.length >
|
|
440
|
+
|| input.length > maxIdentifierLength
|
|
431
441
|
|| input.trim() !== input) {
|
|
432
442
|
throw new Error(`${label} must be a non-empty, trimmed string.`);
|
|
433
443
|
}
|
package/dist/runtime.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { PlatformGateway, PlatformTarget } from '@mpgd/platform';
|
|
|
3
3
|
import { type GameServicesBackendApi, type GameServicesBackendTransport, type GameServicesClient, type GameServicesHeaderResolver } from './client.js';
|
|
4
4
|
import type { GameServicesLedgerTarget } from './types.js';
|
|
5
5
|
import type { ClientRewardEvidenceRegistry } from './client-reward-evidence.js';
|
|
6
|
+
import type { CommerceReconcilerProduct } from './commerce-reconciler.js';
|
|
6
7
|
import { type MonetizationOperationStore, type RecoverableMonetizationClient } from './monetization-recovery.js';
|
|
7
8
|
export type GameServicesAuthorityMode = 'production' | 'non-production';
|
|
8
9
|
export type GameServicesRuntimeMode = 'disabled' | 'local' | 'http' | 'orpc';
|
|
@@ -34,6 +35,12 @@ interface CreateGameServicesRuntimeCommonInput {
|
|
|
34
35
|
/** Durable, compare-and-swap journal for purchase and rewarded-ad recovery. */
|
|
35
36
|
readonly operationStore?: MonetizationOperationStore;
|
|
36
37
|
readonly rewardEvidenceRegistry?: ClientRewardEvidenceRegistry;
|
|
38
|
+
/**
|
|
39
|
+
* Catalog kinds and store SKUs the commerce v2 reconciler resolves without a
|
|
40
|
+
* store read (`CommerceReconcilerProduct`), for example derived from the
|
|
41
|
+
* product catalog's `platformProductIds` for the gateway target.
|
|
42
|
+
*/
|
|
43
|
+
readonly commerceProducts?: readonly CommerceReconcilerProduct[];
|
|
37
44
|
}
|
|
38
45
|
export type CreateGameServicesRuntimeInput = CreateGameServicesRuntimeCommonInput & ({
|
|
39
46
|
readonly transport?: 'http';
|
package/dist/runtime.js
CHANGED
|
@@ -62,6 +62,7 @@ export function createGameServicesRuntime(input) {
|
|
|
62
62
|
: { analyticsSessionId: input.analyticsSessionId }),
|
|
63
63
|
...(input.now === undefined ? {} : { now: input.now }),
|
|
64
64
|
...(input.rewardEvidenceRegistry === undefined ? {} : { rewardEvidenceRegistry: input.rewardEvidenceRegistry }),
|
|
65
|
+
...(input.commerceProducts === undefined ? {} : { commerceProducts: input.commerceProducts }),
|
|
65
66
|
};
|
|
66
67
|
const monetizationRecovery = input.operationStore === undefined
|
|
67
68
|
? undefined
|
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.
|