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