@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.
@@ -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, toGameServicesPurchaseResult, } from './commerce-reconciler.js';
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 commerce v2 client over the durable journal: its reconciler is the
25
- // purchase path for a gateway with a v2 provider, and v1 records keep the
26
- // per-operation client below.
27
- const commerceClient = input.gateway.commerceV2 === undefined
28
- ? undefined
29
- : createGameServicesClient({ ...input, operationStore: store });
30
- const commerceReconciler = commerceClient?.commerceReconciler;
31
- // The per-operation clients below resume v1 records only: they see a gateway
32
- // without the v2 provider, so they never build a second reconciler over the
33
- // journal or reopen the store UI for a platform result already recorded.
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 { commerceV2: _commerceV2, ...legacyGateway } = input.gateway;
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
- || (record.kind === 'purchase' ? record.input.productId : record.input.placementId)
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
- async function purchase(operation, options) {
66
- if (commerceClient !== undefined) {
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 !== 'purchase' && record.kind !== 'rewarded-ad') {
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 === 'commerce-purchase') {
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.kind === 'purchase' ? latest.input.productId : latest.input.placementId);
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
- /** Exact HTTP(S) origins permitted for absolute links. Root-relative links are always allowed. */
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 baseUrl = new URL('https://mpgd.invalid');
340
- const parsed = new URL(deepLink, baseUrl);
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 `${parsed.pathname}${parsed.search}${parsed.hash}`;
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
- return parsed.toString();
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 > 2_048
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.