@mpgd/game-services 0.30.5 → 0.31.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.
@@ -54,7 +54,9 @@ function isRetryableBeforeLaunch(record) {
54
54
  * finish allow-list of its own (the removed v1 driver paired transactions in
55
55
  * `finishGrantedTransaction`), so this is the finish gate for every `client`
56
56
  * and `host` owner: the ledger entry id comes from the journal revision the
57
- * backend grant wrote.
57
+ * backend grant wrote. The one other finish call is a `finished` consumable a
58
+ * `client` store reports again, finished with the same journaled ledger entry
59
+ * (see `finishReportedAgain` in `createCommerceReconciler`).
58
60
  */
59
61
  export function assertCommerceFinishAllowed(record) {
60
62
  if (record.state !== 'granted' && record.state !== 'finishing') {
@@ -258,6 +260,30 @@ export function createCommerceReconciler(input) {
258
260
  const trigger = () => {
259
261
  triggerPending = true;
260
262
  };
263
+ /** The session check's refusal, if any; a check that throws refuses too. */
264
+ const sessionRefusal = () => {
265
+ if (input.sessionCheck === undefined) {
266
+ return undefined;
267
+ }
268
+ try {
269
+ return input.sessionCheck();
270
+ }
271
+ catch (error) {
272
+ return new CommerceError('ACCOUNT_BINDING_REQUIRED', {
273
+ retryable: true,
274
+ message: `The session check failed: ${error instanceof Error ? error.message : String(error)}`,
275
+ });
276
+ }
277
+ };
278
+ /** True (and reported) when the reconciler must not act for its player now. */
279
+ const sessionBlocked = (idempotencyKey) => {
280
+ const refusal = sessionRefusal();
281
+ if (refusal === undefined) {
282
+ return false;
283
+ }
284
+ fail('session', refusal, idempotencyKey);
285
+ return true;
286
+ };
261
287
  const ownsRecord = (record) => record.playerId === playerId
262
288
  && record.target === target
263
289
  && record.deploymentTarget === deploymentTarget
@@ -551,6 +577,11 @@ export function createCommerceReconciler(input) {
551
577
  if (!capabilitiesNow.lifecycle.availablePurchases) {
552
578
  return true;
553
579
  }
580
+ if (sessionBlocked()) {
581
+ // Another player's store account may be current: nothing is read into
582
+ // this player's journal, and the next permitted pass reads again.
583
+ return false;
584
+ }
554
585
  const read = async (withRestore) => {
555
586
  const available = await provider.getAvailablePurchases({
556
587
  includePending: capabilitiesNow.lifecycle.pending,
@@ -609,6 +640,83 @@ export function createCommerceReconciler(input) {
609
640
  ...(record.origin === 'discovered' ? { intent: 'restore' } : {}),
610
641
  };
611
642
  };
643
+ /** Whether a store purchase is bound to another account than this player's (`accountBinding`). */
644
+ const accountOf = async (purchase) => {
645
+ const bound = purchase.accountBinding;
646
+ if (input.accountBinding === undefined || bound?.value === undefined) {
647
+ return 'own';
648
+ }
649
+ let own;
650
+ try {
651
+ own = await input.accountBinding();
652
+ }
653
+ catch {
654
+ own = undefined;
655
+ }
656
+ if (own?.value === undefined) {
657
+ // This player's binding cannot be read now: the purchase cannot be shown to be theirs yet.
658
+ return 'unknown';
659
+ }
660
+ if (own.kind !== bound.kind) {
661
+ return 'own';
662
+ }
663
+ const normalize = (value) => bound.kind === 'app-account-token' ? value.toLowerCase() : value;
664
+ return normalize(own.value) === normalize(bound.value) ? 'own' : 'another';
665
+ };
666
+ /** Transactions `finishReportedAgain` already finished again this session. */
667
+ const finishedAgain = new Set();
668
+ /**
669
+ * A client-finish store reports a consumable only while it is unfinished,
670
+ * so one reported again after its record finished was never finished in
671
+ * the store: its finish was reported done without a native call (a lost
672
+ * acknowledgement the store's list could not tell apart from an
673
+ * unverifiable entry or a list read too early), or the store re-delivered
674
+ * it. It is finished again with the ledger entry the grant wrote, once per
675
+ * session (a call that failed in transport is tried again on the next
676
+ * report); it is never verified or granted again (the ledger replays a key
677
+ * and refuses the same evidence under another one). Durables, which a
678
+ * store keeps listing after their finish, and other finish owners are left
679
+ * alone. It needs the finished record: past the journal's retention the
680
+ * transaction takes the derived-key path, where the reference backend
681
+ * answers a `client`-finish consumable with this player's recorded grant
682
+ * (never a second one), and the record finishes it like any grant.
683
+ */
684
+ const finishReportedAgain = async (record, kind) => {
685
+ const ledgerEntryId = record.ledgerEntryId;
686
+ const transactionId = record.purchase?.transactionId;
687
+ if ((record.productKind ?? kind) !== 'consumable'
688
+ || capabilities?.lifecycle.finish !== 'client'
689
+ || provider.finishTransaction === undefined
690
+ || ledgerEntryId === undefined
691
+ || ledgerEntryId.trim() === ''
692
+ || transactionId === undefined
693
+ || transactionId.trim() === ''
694
+ || finishedAgain.has(transactionId)) {
695
+ return;
696
+ }
697
+ if (sessionBlocked(record.idempotencyKey)) {
698
+ // The session changed while the report was matched: the next report asks again.
699
+ return;
700
+ }
701
+ finishedAgain.add(transactionId);
702
+ try {
703
+ const { finished } = await provider.finishTransaction({
704
+ transactionId,
705
+ ledgerEntryId,
706
+ isConsumable: true,
707
+ });
708
+ if (!finished) {
709
+ // The store refused it (for StoreKit: revoked or another token); the
710
+ // next session asks again. The record stays finished.
711
+ fail('finish', new Error(`The store did not finish ${transactionId} it reported again.`), record.idempotencyKey);
712
+ }
713
+ }
714
+ catch (error) {
715
+ // Transport-class failure: the next report of the transaction tries again.
716
+ finishedAgain.delete(transactionId);
717
+ fail('finish', error, record.idempotencyKey);
718
+ }
719
+ };
612
720
  // Reconciler step 2: match a reported purchase to a journal record.
613
721
  const ingestPurchase = async (purchase, restoring) => {
614
722
  if (purchase.store !== storeId) {
@@ -638,6 +746,22 @@ export function createCommerceReconciler(input) {
638
746
  record = remember(stored);
639
747
  }
640
748
  }
749
+ const account = record !== undefined || (restoring && product.kind === 'durable')
750
+ ? 'own'
751
+ : await accountOf(purchase);
752
+ if (account === 'unknown') {
753
+ // Not decided yet: the report waits for the next pass, which asks again.
754
+ defer({ type: 'purchaseUpdated', purchase });
755
+ fail('match', new Error(`Purchase ${purchase.transactionId} waits: this player's store account binding could not be read.`));
756
+ return;
757
+ }
758
+ if (account === 'another') {
759
+ // Another player's purchase (Play lists unacknowledged purchases of
760
+ // every app account on the device): neither attached to this player's
761
+ // open attempt nor journaled under a derived key.
762
+ fail('match', new Error(`Purchase ${purchase.transactionId} is bound to another store account; it is left for that player.`));
763
+ return;
764
+ }
641
765
  if (record === undefined) {
642
766
  const open = openRecordsFor(product.logicalId);
643
767
  if (open.length > 1) {
@@ -697,6 +821,10 @@ export function createCommerceReconciler(input) {
697
821
  return;
698
822
  }
699
823
  }
824
+ if (record.state === 'finished' && purchase.state === 'purchased') {
825
+ await finishReportedAgain(record, product.kind);
826
+ return;
827
+ }
700
828
  if (purchase.state === 'pending' && record.state === 'launched') {
701
829
  await write(record, 'pending', { purchase });
702
830
  return;
@@ -761,6 +889,10 @@ export function createCommerceReconciler(input) {
761
889
  }
762
890
  try {
763
891
  for (;;) {
892
+ if (sessionBlocked(record.idempotencyKey)) {
893
+ // The record stays as journaled for this player's next permitted pass.
894
+ return;
895
+ }
764
896
  switch (record.state) {
765
897
  case 'requested':
766
898
  if (isRetryableBeforeLaunch(record)) {
@@ -881,12 +1013,25 @@ export function createCommerceReconciler(input) {
881
1013
  retryLater(record.idempotencyKey);
882
1014
  return;
883
1015
  }
1016
+ if (sessionBlocked(record.idempotencyKey)) {
1017
+ return;
1018
+ }
884
1019
  let finished = false;
885
1020
  try {
886
1021
  finished = (await provider.finishTransaction({
887
1022
  transactionId: finish.transactionId,
888
1023
  ledgerEntryId: finish.ledgerEntryId,
889
1024
  isConsumable: kind === 'consumable',
1025
+ // A record already `finishing` when this drive began was asked
1026
+ // to finish before (an earlier attempt or session): a `client`
1027
+ // store may read a consumable gone from its unfinished list as
1028
+ // that finish's lost acknowledgement. A first finish never is,
1029
+ // and a provider that does not declare the field never gets it.
1030
+ ...(owner === 'client'
1031
+ && initial.state === 'finishing'
1032
+ && provider.acceptsFinishRetry === true
1033
+ ? { retry: true }
1034
+ : {}),
890
1035
  })).finished;
891
1036
  }
892
1037
  catch (error) {
@@ -961,6 +1106,10 @@ export function createCommerceReconciler(input) {
961
1106
  };
962
1107
  const eventPass = { explicit: false, refreshCapabilities: false };
963
1108
  const pass = async (options) => {
1109
+ if (sessionBlocked()) {
1110
+ // Queued events wait for a pass the session permits.
1111
+ return;
1112
+ }
964
1113
  let capabilitiesNow = await readCapabilities(options.refreshCapabilities);
965
1114
  if (options.refreshCapabilities) {
966
1115
  await readServiceAlignment();
@@ -978,10 +1127,14 @@ export function createCommerceReconciler(input) {
978
1127
  // call to journal the store answer: matched then, it is never journaled
979
1128
  // twice, and a cancel event ends the attempt the call launched.
980
1129
  const busy = inFlightProducts();
981
- for (const event of [
982
- ...deferred.splice(0, deferred.length),
983
- ...inbox.splice(0, inbox.length),
984
- ]) {
1130
+ const queued = [...deferred.splice(0, deferred.length), ...inbox.splice(0, inbox.length)];
1131
+ for (const [index, event] of queued.entries()) {
1132
+ if (sessionBlocked()) {
1133
+ // A purchase reported now may be another player's: it is not
1134
+ // journaled under this one; the rest wait, in order.
1135
+ inbox.unshift(...queued.slice(index));
1136
+ return;
1137
+ }
985
1138
  try {
986
1139
  if (event.type === 'purchaseUpdated') {
987
1140
  const product = busy.size === 0 ? undefined : await resolveLogicalId(event.purchase);
@@ -1004,6 +1157,9 @@ export function createCommerceReconciler(input) {
1004
1157
  }
1005
1158
  }
1006
1159
  for (const listed of await listRecoverable()) {
1160
+ if (sessionBlocked()) {
1161
+ return;
1162
+ }
1007
1163
  const record = records.get(listed.idempotencyKey) ?? listed;
1008
1164
  if (options.restoreTrigger !== true && waitsForRestoreTrigger(record)) {
1009
1165
  // At the holder limit only the player's Restore Purchases asks again;
@@ -1012,6 +1168,9 @@ export function createCommerceReconciler(input) {
1012
1168
  }
1013
1169
  await drive(record, capabilitiesNow, options.explicit);
1014
1170
  }
1171
+ if (sessionBlocked()) {
1172
+ return;
1173
+ }
1015
1174
  await revokeReversed();
1016
1175
  };
1017
1176
  const runUntilIdle = async () => {
@@ -1158,6 +1317,12 @@ export function createCommerceReconciler(input) {
1158
1317
  })),
1159
1318
  };
1160
1319
  }
1320
+ const lateRefusal = sessionRefusal();
1321
+ if (lateRefusal !== undefined) {
1322
+ // The session changed while the purchase was prepared: nothing is journaled.
1323
+ fail('session', lateRefusal, request.idempotencyKey);
1324
+ return { kind: 'answered', result: failedOutcome(lateRefusal) };
1325
+ }
1161
1326
  const { created, record } = existing === undefined ? await reserve({
1162
1327
  idempotencyKey: request.idempotencyKey,
1163
1328
  store: storeId,
@@ -1263,6 +1428,12 @@ export function createCommerceReconciler(input) {
1263
1428
  // Only the call that reserved the record owns its in-flight entry: a
1264
1429
  // same-key replay must not release it while the store sheet is open.
1265
1430
  let owned = false;
1431
+ const refusal = sessionRefusal();
1432
+ if (refusal !== undefined) {
1433
+ // Nothing is journaled and no store UI opens; the key stays usable.
1434
+ fail('session', refusal, request.idempotencyKey);
1435
+ return failedOutcome(refusal);
1436
+ }
1266
1437
  try {
1267
1438
  const prepared = await withLock(() => prepareRequest(request));
1268
1439
  if (prepared.kind === 'answered') {
@@ -1281,14 +1452,39 @@ export function createCommerceReconciler(input) {
1281
1452
  notStarted = event.state === 'not-started';
1282
1453
  }
1283
1454
  });
1284
- try {
1285
- answer = await provider.requestPurchase(request);
1286
- }
1287
- catch (error) {
1288
- answer = failedOutcome(error);
1289
- }
1290
- finally {
1455
+ // Checked again with nothing awaited before the store call: the
1456
+ // provider binds the purchase to the account current now, which must
1457
+ // still be the player this attempt is journaled under.
1458
+ const lateRefusal = sessionRefusal();
1459
+ if (lateRefusal !== undefined) {
1460
+ fail('session', lateRefusal, request.idempotencyKey);
1291
1461
  unsubscribePresentation?.();
1462
+ // The store never opened: the reserved attempt stays retryable.
1463
+ answer = failedOutcome(lateRefusal);
1464
+ notStarted = true;
1465
+ }
1466
+ else {
1467
+ try {
1468
+ answer = await provider.requestPurchase(request);
1469
+ }
1470
+ catch (error) {
1471
+ answer = failedOutcome(error);
1472
+ }
1473
+ finally {
1474
+ unsubscribePresentation?.();
1475
+ }
1476
+ // The provider refuses a missing or foreign account binding before
1477
+ // its store UI opens. Refused because the session changed meanwhile,
1478
+ // the attempt stays retryable instead of ending.
1479
+ const bindingRefusal = answer.outcome === 'failed'
1480
+ && answer.error.code === 'ACCOUNT_BINDING_REQUIRED'
1481
+ ? sessionRefusal()
1482
+ : undefined;
1483
+ if (bindingRefusal !== undefined) {
1484
+ fail('session', bindingRefusal, request.idempotencyKey);
1485
+ answer = failedOutcome(bindingRefusal);
1486
+ notStarted = true;
1487
+ }
1292
1488
  }
1293
1489
  result = await withLock(() => recordAnswer(request, prepared.record, prepared.product, answer, notStarted));
1294
1490
  }
@@ -1330,13 +1526,27 @@ export function createCommerceReconciler(input) {
1330
1526
  if (started) {
1331
1527
  return;
1332
1528
  }
1529
+ if (input.closed?.() === true) {
1530
+ // Closed for good: nothing subscribes, and nothing awaits before this
1531
+ // check, so a start under way already set `starting` for `stop()`.
1532
+ return;
1533
+ }
1333
1534
  if (starting !== undefined) {
1334
1535
  return starting;
1335
1536
  }
1336
1537
  starting = (async () => {
1337
1538
  try {
1338
- await withLock(async () => {
1539
+ const subscribed = await withLock(async () => {
1339
1540
  await loadRecords();
1541
+ // The owner closed while this start waited for the lock or the
1542
+ // journal (its `stop()` may have stopped waiting already): leave
1543
+ // as a refused start does, with nothing subscribed or started and
1544
+ // no error. The records read stay cached, as after a `reconcile()`
1545
+ // before any start. Nothing awaits between this check and the
1546
+ // subscription.
1547
+ if (input.closed?.() === true) {
1548
+ return false;
1549
+ }
1340
1550
  // Subscribe first, then read: an event delivered before the listener is found either way.
1341
1551
  unsubscribe = provider.subscribe(listener);
1342
1552
  started = true;
@@ -1347,8 +1557,11 @@ export function createCommerceReconciler(input) {
1347
1557
  initialScanPending = capabilitiesNow === undefined
1348
1558
  || (capabilitiesNow.lifecycle.events && !(await readAvailable(capabilitiesNow)));
1349
1559
  await pass({ explicit: true, refreshCapabilities: false });
1560
+ return true;
1350
1561
  });
1351
- await runUntilIdle();
1562
+ if (subscribed) {
1563
+ await runUntilIdle();
1564
+ }
1352
1565
  }
1353
1566
  catch (error) {
1354
1567
  // A failed start is retryable: nothing stays subscribed or started.
@@ -1410,6 +1623,13 @@ export function createCommerceReconciler(input) {
1410
1623
  },
1411
1624
  runUntilIdle,
1412
1625
  async restorePurchases() {
1626
+ const refusal = sessionRefusal();
1627
+ if (refusal !== undefined) {
1628
+ // A store restore (StoreKit's sync, an account sign-in) would run for
1629
+ // the account current now, not this reconciler's player.
1630
+ fail('session', refusal);
1631
+ throw refusal;
1632
+ }
1413
1633
  if (provider.restorePurchases !== undefined) {
1414
1634
  try {
1415
1635
  await provider.restorePurchases();
@@ -1442,6 +1662,12 @@ export function createCommerceReconciler(input) {
1442
1662
  });
1443
1663
  });
1444
1664
  await runUntilIdle();
1665
+ const lateRefusal = sessionRefusal();
1666
+ if (lateRefusal !== undefined) {
1667
+ // The session changed during the restore: the passes stopped early,
1668
+ // so it did not finish for this player.
1669
+ throw lateRefusal;
1670
+ }
1445
1671
  },
1446
1672
  requestPurchase,
1447
1673
  async fetchProducts(request = {}) {
@@ -7,17 +7,17 @@ export interface GameServicesHealthResponse {
7
7
  readonly version: string;
8
8
  }
9
9
  export declare const gameServicesContract: {
10
- health: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<GameServicesHealthResponse, GameServicesHealthResponse>, object>;
10
+ health: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<GameServicesHealthResponse, GameServicesHealthResponse>, Record<never, never>>;
11
11
  commerce: {
12
- verifyPurchase: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<VerifyPurchaseRequest, VerifyPurchaseRequest>, import("@orpc/contract").Schema<VerifyPurchaseResponse, VerifyPurchaseResponse>, object>;
13
- getPurchaseLedgerStatus: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<PurchaseLedgerStatusRequest, PurchaseLedgerStatusRequest>, import("@orpc/contract").Schema<PurchaseLedgerStatusResponse, PurchaseLedgerStatusResponse>, object>;
14
- getCapabilities: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<CommerceServiceCapabilities, CommerceServiceCapabilities>, object>;
12
+ verifyPurchase: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<VerifyPurchaseRequest, VerifyPurchaseRequest>, import("@orpc/contract").Schema<VerifyPurchaseResponse, VerifyPurchaseResponse>, Record<never, never>>;
13
+ getPurchaseLedgerStatus: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<PurchaseLedgerStatusRequest, PurchaseLedgerStatusRequest>, import("@orpc/contract").Schema<PurchaseLedgerStatusResponse, PurchaseLedgerStatusResponse>, Record<never, never>>;
14
+ getCapabilities: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<CommerceServiceCapabilities, CommerceServiceCapabilities>, Record<never, never>>;
15
15
  };
16
16
  ads: {
17
- claimReward: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<ClaimAdRewardRequest, ClaimAdRewardRequest>, import("@orpc/contract").Schema<ClaimAdRewardResponse, ClaimAdRewardResponse>, object>;
17
+ claimReward: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<ClaimAdRewardRequest, ClaimAdRewardRequest>, import("@orpc/contract").Schema<ClaimAdRewardResponse, ClaimAdRewardResponse>, Record<never, never>>;
18
18
  };
19
19
  leaderboard: {
20
- recordScore: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<RecordLeaderboardScoreRequest, RecordLeaderboardScoreRequest>, import("@orpc/contract").Schema<RecordLeaderboardScoreResponse, RecordLeaderboardScoreResponse>, object>;
20
+ recordScore: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<RecordLeaderboardScoreRequest, RecordLeaderboardScoreRequest>, import("@orpc/contract").Schema<RecordLeaderboardScoreResponse, RecordLeaderboardScoreResponse>, Record<never, never>>;
21
21
  };
22
22
  };
23
23
  export type GameServicesContract = typeof gameServicesContract;
@@ -29,7 +29,7 @@ export function createRecoverableMonetizationClient(input) {
29
29
  const commerceReconciler = commerceClient.commerceReconciler;
30
30
  // The per-operation rewarded-ad clients see a gateway without the commerce
31
31
  // provider, so they never build a second reconciler over the journal.
32
- const { commerceReconciler: _sharedReconciler, operationStore: _journal, onCommerceEvent: _onCommerceEvent, commerceProducts: _commerceProducts, ...legacyClientInput } = input;
32
+ const { commerceReconciler: _sharedReconciler, operationStore: _journal, onCommerceEvent: _onCommerceEvent, commerceProducts: _commerceProducts, commerceStaleLaunchedAfterMs: _commerceStaleLaunchedAfterMs, ...legacyClientInput } = input;
33
33
  const { commerce: _commerce, ...legacyGateway } = input.gateway;
34
34
  async function serializeOperation(key, task) {
35
35
  const previous = inFlight.get(key) ?? Promise.resolve();
@@ -0,0 +1,42 @@
1
+ /**
2
+ * oRPC error-body compatibility across the 2.0.0-beta.34 `inferable` removal.
3
+ *
4
+ * oRPC 2.0.0-beta.14 writes RPC error bodies as
5
+ * `{ json: { defined, inferable, code, message, data? } }` and its client rejects a body
6
+ * without a boolean `inferable`. Since 2.0.0-beta.34 (middleapi/orpc#1987) the server
7
+ * omits `inferable` and the client rejects a body that has it. Either way a client on
8
+ * the other version reports a malformed response instead of the error code, so a game
9
+ * built against one kit release and a Worker built against another would lose
10
+ * `BAD_REQUEST` and `NOT_FOUND` (and with it the commerce reconciler's "v1 backend"
11
+ * detection) whenever they are deployed separately.
12
+ *
13
+ * The game-services oRPC transport therefore bridges both directions:
14
+ * - the server adds `inferable: false` to error bodies, so older clients still decode them;
15
+ * - the client removes a boolean `inferable` before oRPC validates, so it still decodes
16
+ * older servers' errors. Nothing else in the body changes, so a body that is malformed
17
+ * for any other reason is still rejected by oRPC.
18
+ *
19
+ * Success responses are passed through untouched in both directions.
20
+ *
21
+ * Remove this module when the oldest supported game-services client and every supported
22
+ * Worker come from `@mpgd/game-services` 0.31.0 or later (oRPC 2.0.0-beta.46), that is, once
23
+ * no client or Worker from 0.30.5 or older (oRPC 2.0.0-beta.14) is supported.
24
+ */
25
+ /**
26
+ * Server side: adds `inferable: false` to an oRPC error body that lacks it.
27
+ * Responses that are not oRPC JSON error bodies are returned unchanged.
28
+ */
29
+ export declare function addLegacyOrpcErrorFlag(response: Response): Promise<Response>;
30
+ /**
31
+ * Client side: removes a boolean `inferable` from an oRPC error body so a current oRPC
32
+ * client accepts errors from a pre-beta.34 server. Only that key is removed; oRPC still
33
+ * validates the rest of the body as usual.
34
+ */
35
+ export declare function stripLegacyOrpcErrorFlag(response: Response): Promise<Response>;
36
+ /**
37
+ * Wraps an oRPC link `fetch` so every response passes through
38
+ * {@link stripLegacyOrpcErrorFlag}. Any oRPC 2.0.0-beta.34+ client that talks to a
39
+ * game-services Worker must use it while the Worker writes the legacy flag. Extra arguments
40
+ * oRPC passes (client options, procedure path) are forwarded unchanged.
41
+ */
42
+ export declare function withLegacyOrpcErrorCompat<Rest extends unknown[]>(fetcher: (url: string, init: RequestInit, ...rest: Rest) => Promise<Response>): (url: string, init: RequestInit, ...rest: Rest) => Promise<Response>;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * oRPC error-body compatibility across the 2.0.0-beta.34 `inferable` removal.
3
+ *
4
+ * oRPC 2.0.0-beta.14 writes RPC error bodies as
5
+ * `{ json: { defined, inferable, code, message, data? } }` and its client rejects a body
6
+ * without a boolean `inferable`. Since 2.0.0-beta.34 (middleapi/orpc#1987) the server
7
+ * omits `inferable` and the client rejects a body that has it. Either way a client on
8
+ * the other version reports a malformed response instead of the error code, so a game
9
+ * built against one kit release and a Worker built against another would lose
10
+ * `BAD_REQUEST` and `NOT_FOUND` (and with it the commerce reconciler's "v1 backend"
11
+ * detection) whenever they are deployed separately.
12
+ *
13
+ * The game-services oRPC transport therefore bridges both directions:
14
+ * - the server adds `inferable: false` to error bodies, so older clients still decode them;
15
+ * - the client removes a boolean `inferable` before oRPC validates, so it still decodes
16
+ * older servers' errors. Nothing else in the body changes, so a body that is malformed
17
+ * for any other reason is still rejected by oRPC.
18
+ *
19
+ * Success responses are passed through untouched in both directions.
20
+ *
21
+ * Remove this module when the oldest supported game-services client and every supported
22
+ * Worker come from `@mpgd/game-services` 0.31.0 or later (oRPC 2.0.0-beta.46), that is, once
23
+ * no client or Worker from 0.30.5 or older (oRPC 2.0.0-beta.14) is supported.
24
+ */
25
+ const legacyErrorFlag = 'inferable';
26
+ /**
27
+ * Server side: adds `inferable: false` to an oRPC error body that lacks it.
28
+ * Responses that are not oRPC JSON error bodies are returned unchanged.
29
+ */
30
+ export async function addLegacyOrpcErrorFlag(response) {
31
+ if (!isJsonErrorResponse(response)) {
32
+ return response;
33
+ }
34
+ const text = await response.text();
35
+ const envelope = parseErrorEnvelope(text);
36
+ if (envelope === undefined || Object.hasOwn(envelope.json, legacyErrorFlag)) {
37
+ return rebuild(response, text);
38
+ }
39
+ return rebuild(response, JSON.stringify({ ...envelope.root, json: { ...envelope.json, [legacyErrorFlag]: false } }));
40
+ }
41
+ /**
42
+ * Client side: removes a boolean `inferable` from an oRPC error body so a current oRPC
43
+ * client accepts errors from a pre-beta.34 server. Only that key is removed; oRPC still
44
+ * validates the rest of the body as usual.
45
+ */
46
+ export async function stripLegacyOrpcErrorFlag(response) {
47
+ if (!isJsonErrorResponse(response)) {
48
+ return response;
49
+ }
50
+ const text = await response.text();
51
+ const envelope = parseErrorEnvelope(text);
52
+ if (envelope === undefined || typeof envelope.json[legacyErrorFlag] !== 'boolean') {
53
+ return rebuild(response, text);
54
+ }
55
+ const { [legacyErrorFlag]: _legacy, ...json } = envelope.json;
56
+ return rebuild(response, JSON.stringify({ ...envelope.root, json }));
57
+ }
58
+ /**
59
+ * Wraps an oRPC link `fetch` so every response passes through
60
+ * {@link stripLegacyOrpcErrorFlag}. Any oRPC 2.0.0-beta.34+ client that talks to a
61
+ * game-services Worker must use it while the Worker writes the legacy flag. Extra arguments
62
+ * oRPC passes (client options, procedure path) are forwarded unchanged.
63
+ */
64
+ export function withLegacyOrpcErrorCompat(fetcher) {
65
+ return async (url, init, ...rest) => stripLegacyOrpcErrorFlag(await fetcher(url, init, ...rest));
66
+ }
67
+ function isJsonErrorResponse(response) {
68
+ return (response.status >= 400
69
+ && response.body !== null
70
+ && (response.headers.get('content-type') ?? '').toLowerCase().startsWith('application/json'));
71
+ }
72
+ function parseErrorEnvelope(text) {
73
+ let root;
74
+ try {
75
+ root = JSON.parse(text);
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ if (!isPlainObject(root) || !isPlainObject(root.json)) {
81
+ return undefined;
82
+ }
83
+ return { root, json: root.json };
84
+ }
85
+ function isPlainObject(value) {
86
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
87
+ return false;
88
+ }
89
+ const prototype = Object.getPrototypeOf(value);
90
+ return prototype === Object.prototype || prototype === null;
91
+ }
92
+ function rebuild(response, body) {
93
+ const headers = new Headers(response.headers);
94
+ // The body is re-encoded as plain text: any transfer encoding or length no longer applies.
95
+ headers.delete('content-encoding');
96
+ headers.delete('content-length');
97
+ return new Response(body, {
98
+ status: response.status,
99
+ statusText: response.statusText,
100
+ headers,
101
+ });
102
+ }
@@ -4,6 +4,28 @@
4
4
  * client and the notifications client so a game gives both one resolver.
5
5
  */
6
6
  export type GameServicesHeaderResolver = () => Readonly<Record<string, string>> | Promise<Readonly<Record<string, string>>>;
7
+ /** A runtime refused an operation because it was disposed (see `GameServicesRuntime.dispose`). */
8
+ export declare const gameServicesRuntimeDisposedCode = "GAME_SERVICES_RUNTIME_DISPOSED";
9
+ /**
10
+ * A runtime refused an operation because `sessionPlayerId` names another
11
+ * player: it closed itself, as `dispose()` does, and the host creates a
12
+ * runtime for the signed-in player (retryable there).
13
+ */
14
+ export declare const gameServicesPlayerChangedCode = "GAME_SERVICES_PLAYER_CHANGED";
15
+ /** A runtime refused an operation because `sessionPlayerId` names no player (signed out); it stays open. */
16
+ export declare const gameServicesSessionRequiredCode = "GAME_SERVICES_SESSION_REQUIRED";
17
+ export type GameServicesRuntimeClosedCode = typeof gameServicesRuntimeDisposedCode | typeof gameServicesPlayerChangedCode | typeof gameServicesSessionRequiredCode;
18
+ /**
19
+ * An operation the runtime refused before it reached the backend or the
20
+ * store: it was disposed, its player is no longer the session's, or the
21
+ * session is signed out. Always retryable (with a runtime for the signed-in
22
+ * player); nothing was sent and no record was ended.
23
+ */
24
+ export declare class GameServicesRuntimeClosedError extends Error {
25
+ readonly code: GameServicesRuntimeClosedCode;
26
+ readonly retryable = true;
27
+ constructor(code: GameServicesRuntimeClosedCode);
28
+ }
7
29
  /** Credentials could not be resolved; no backend request was dispatched. */
8
30
  export declare class GameServicesHeaderResolutionError extends Error {
9
31
  constructor();
@@ -3,6 +3,35 @@
3
3
  * for rotating credentials, then per-request headers. Shared by the commerce
4
4
  * client and the notifications client so a game gives both one resolver.
5
5
  */
6
+ /** A runtime refused an operation because it was disposed (see `GameServicesRuntime.dispose`). */
7
+ export const gameServicesRuntimeDisposedCode = 'GAME_SERVICES_RUNTIME_DISPOSED';
8
+ /**
9
+ * A runtime refused an operation because `sessionPlayerId` names another
10
+ * player: it closed itself, as `dispose()` does, and the host creates a
11
+ * runtime for the signed-in player (retryable there).
12
+ */
13
+ export const gameServicesPlayerChangedCode = 'GAME_SERVICES_PLAYER_CHANGED';
14
+ /** A runtime refused an operation because `sessionPlayerId` names no player (signed out); it stays open. */
15
+ export const gameServicesSessionRequiredCode = 'GAME_SERVICES_SESSION_REQUIRED';
16
+ /**
17
+ * An operation the runtime refused before it reached the backend or the
18
+ * store: it was disposed, its player is no longer the session's, or the
19
+ * session is signed out. Always retryable (with a runtime for the signed-in
20
+ * player); nothing was sent and no record was ended.
21
+ */
22
+ export class GameServicesRuntimeClosedError extends Error {
23
+ code;
24
+ retryable = true;
25
+ constructor(code) {
26
+ super(code === gameServicesPlayerChangedCode
27
+ ? 'The session now names another player; this game services runtime is closed.'
28
+ : code === gameServicesSessionRequiredCode
29
+ ? 'The session names no player; sign in before using game services.'
30
+ : 'This game services runtime was disposed.');
31
+ this.name = 'GameServicesRuntimeClosedError';
32
+ this.code = code;
33
+ }
34
+ }
6
35
  /** Credentials could not be resolved; no backend request was dispatched. */
7
36
  export class GameServicesHeaderResolutionError extends Error {
8
37
  constructor() {
@@ -16,7 +45,11 @@ export async function resolveGameServicesRequestHeaders(input) {
16
45
  try {
17
46
  dynamicHeaders = await input.getHeaders?.();
18
47
  }
19
- catch {
48
+ catch (error) {
49
+ if (error instanceof GameServicesRuntimeClosedError) {
50
+ // The runtime's own refusal carries no credentials; its code says why.
51
+ throw error;
52
+ }
20
53
  // Resolver exceptions can contain credentials; expose a stable outcome.
21
54
  throw new GameServicesHeaderResolutionError();
22
55
  }