@mpgd/game-services 0.21.1 → 0.23.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,5 +1,6 @@
1
1
  import { type RouterContractClient } from '@orpc/contract';
2
- import type { ClaimAdRewardRequest, ClaimAdRewardResponse, RecordLeaderboardScoreRequest, RecordLeaderboardScoreResponse, VerifyPurchaseRequest, VerifyPurchaseResponse } from './types.js';
2
+ import type { CommerceServiceCapabilities } from '@mpgd/platform/commerce';
3
+ import type { ClaimAdRewardRequest, ClaimAdRewardResponse, PurchaseLedgerStatusRequest, PurchaseLedgerStatusResponse, RecordLeaderboardScoreRequest, RecordLeaderboardScoreResponse, VerifyPurchaseRequest, VerifyPurchaseResponse } from './types.js';
3
4
  export interface GameServicesHealthResponse {
4
5
  readonly ok: true;
5
6
  readonly service: 'game-services';
@@ -9,6 +10,8 @@ export declare const gameServicesContract: {
9
10
  health: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<GameServicesHealthResponse, GameServicesHealthResponse>, object>;
10
11
  commerce: {
11
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
15
  };
13
16
  ads: {
14
17
  claimReward: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<ClaimAdRewardRequest, ClaimAdRewardRequest>, import("@orpc/contract").Schema<ClaimAdRewardResponse, ClaimAdRewardResponse>, object>;
package/dist/contract.js CHANGED
@@ -7,6 +7,14 @@ export const gameServicesContract = oc.router({
7
7
  verifyPurchase: oc
8
8
  .input(orpcType())
9
9
  .output(orpcType()),
10
+ /** RFC "Revocation and expiry entries"; NOT_FOUND on a backend without the read. */
11
+ getPurchaseLedgerStatus: oc
12
+ .input(orpcType())
13
+ .output(orpcType()),
14
+ /** RFC "Provider capabilities endpoint"; NOT_FOUND on a backend without a registry. */
15
+ getCapabilities: oc
16
+ .input(orpcType())
17
+ .output(orpcType()),
10
18
  }),
11
19
  ads: oc.router({
12
20
  claimReward: oc
@@ -10,6 +10,20 @@ export type EvidenceVerificationDecision = {
10
10
  * `null` explicitly suppresses the client-reported platform identity.
11
11
  */
12
12
  readonly platformEvidenceId?: string | null;
13
+ } | {
14
+ /**
15
+ * An earlier ledger entry already grants this purchase: a server-push
16
+ * store's lookup verifier (RFC "Evidence registry") found the grant the
17
+ * host's fulfillment path wrote. The backend answers `verified` with
18
+ * that entry (`alreadyProcessed: true`) after checking it belongs to the
19
+ * requesting player, product, and target, and writes nothing new. Only
20
+ * purchase verification understands it; a rewarded-ad verifier that
21
+ * answers it is treated as a verifier error.
22
+ */
23
+ readonly status: 'settled';
24
+ readonly ledgerEntryId: string;
25
+ /** The ledger target the entry was written under when it is not the request's target. */
26
+ readonly ledgerTarget?: string;
13
27
  } | {
14
28
  readonly status: 'pending';
15
29
  readonly reason?: string;
package/dist/index.d.ts CHANGED
@@ -6,6 +6,9 @@ export * from './apps-in-toss-evidence-verification.js';
6
6
  export * from './apps-in-toss-partner-api.js';
7
7
  export * from './client.js';
8
8
  export * from './client-reward-evidence.js';
9
+ export * from './commerce-evidence-registry.js';
10
+ export * from './commerce-ledger.js';
11
+ export * from './commerce-reconciler.js';
9
12
  export * from './default-client-reward-evidence.js';
10
13
  export * from './admob-client-reward.js';
11
14
  export * from './contract.js';
@@ -15,6 +18,7 @@ export * from './google-play-purchase.js';
15
18
  export * from './google-play-publisher-client.js';
16
19
  export * from './microsoft-store-identity.js';
17
20
  export * from './microsoft-store-purchase.js';
21
+ export * from './monetization-operation-store.js';
18
22
  export * from './monetization-recovery.js';
19
23
  export * from './notification-delivery.js';
20
24
  export * from './platform-order.js';
package/dist/index.js CHANGED
@@ -6,6 +6,9 @@ export * from './apps-in-toss-evidence-verification.js';
6
6
  export * from './apps-in-toss-partner-api.js';
7
7
  export * from './client.js';
8
8
  export * from './client-reward-evidence.js';
9
+ export * from './commerce-evidence-registry.js';
10
+ export * from './commerce-ledger.js';
11
+ export * from './commerce-reconciler.js';
9
12
  export * from './default-client-reward-evidence.js';
10
13
  export * from './admob-client-reward.js';
11
14
  export * from './contract.js';
@@ -15,6 +18,7 @@ export * from './google-play-purchase.js';
15
18
  export * from './google-play-publisher-client.js';
16
19
  export * from './microsoft-store-identity.js';
17
20
  export * from './microsoft-store-purchase.js';
21
+ export * from './monetization-operation-store.js';
18
22
  export * from './monetization-recovery.js';
19
23
  export * from './notification-delivery.js';
20
24
  export * from './platform-order.js';
@@ -0,0 +1,20 @@
1
+ import type { MonetizationOperationRecord, MonetizationOperationStore } from './monetization-recovery.js';
2
+ /**
3
+ * Whether a journal record still needs a reconcile pass. A game's durable
4
+ * `MonetizationOperationStore.listRecoverable` applies this rule (or an
5
+ * equivalent index): a v1 purchase or rewarded-ad record is open until it
6
+ * holds a non-pending result with no pending finalization; a commerce v2
7
+ * purchase record is open until its state is terminal (`finished`,
8
+ * `cancelled`, `failed`, `rejected`, `revoked`). Terminal records stay
9
+ * addressable by `read` for duplicate protection.
10
+ */
11
+ export declare function isMonetizationOperationRecoverable(record: MonetizationOperationRecord): boolean;
12
+ /** Whether a commerce v2 record holds a ledger grant the revocation pass should keep checking. */
13
+ export declare function isMonetizationOperationGranted(record: MonetizationOperationRecord): boolean;
14
+ /**
15
+ * Session-only journal: the compare-and-swap semantics of a durable store
16
+ * without the durability. It backs the commerce reconciler of a client that
17
+ * was given no `operationStore`, and tests. It is not crash recovery: a game
18
+ * supplies a durable, encrypted, access-controlled implementation.
19
+ */
20
+ export declare function createInMemoryMonetizationOperationStore(): MonetizationOperationStore;
@@ -0,0 +1,80 @@
1
+ import { isTerminalCommercePurchaseState } from '@mpgd/platform/commerce';
2
+ /**
3
+ * Whether a journal record still needs a reconcile pass. A game's durable
4
+ * `MonetizationOperationStore.listRecoverable` applies this rule (or an
5
+ * equivalent index): a v1 purchase or rewarded-ad record is open until it
6
+ * holds a non-pending result with no pending finalization; a commerce v2
7
+ * purchase record is open until its state is terminal (`finished`,
8
+ * `cancelled`, `failed`, `rejected`, `revoked`). Terminal records stay
9
+ * addressable by `read` for duplicate protection.
10
+ */
11
+ export function isMonetizationOperationRecoverable(record) {
12
+ if (record.kind === 'commerce-purchase') {
13
+ return !isTerminalCommercePurchaseState(record.state);
14
+ }
15
+ if (record.result === undefined || record.result.status === 'pending') {
16
+ return true;
17
+ }
18
+ return record.kind === 'purchase' && record.response?.finalization?.status === 'pending';
19
+ }
20
+ /** Whether a commerce v2 record holds a ledger grant the revocation pass should keep checking. */
21
+ export function isMonetizationOperationGranted(record) {
22
+ return record.kind === 'commerce-purchase'
23
+ && record.ledgerEntryId !== undefined
24
+ && (record.state === 'granted' || record.state === 'finishing' || record.state === 'finished');
25
+ }
26
+ /**
27
+ * Session-only journal: the compare-and-swap semantics of a durable store
28
+ * without the durability. It backs the commerce reconciler of a client that
29
+ * was given no `operationStore`, and tests. It is not crash recovery: a game
30
+ * supplies a durable, encrypted, access-controlled implementation.
31
+ */
32
+ export function createInMemoryMonetizationOperationStore() {
33
+ const records = new Map();
34
+ const clone = (record) => structuredClone(record);
35
+ return {
36
+ async reserve(record) {
37
+ const existing = records.get(record.key);
38
+ if (existing !== undefined) {
39
+ return { created: false, record: clone(existing) };
40
+ }
41
+ records.set(record.key, clone(record));
42
+ return { created: true, record: clone(record) };
43
+ },
44
+ async read(key) {
45
+ const record = records.get(key);
46
+ return record === undefined ? undefined : clone(record);
47
+ },
48
+ async replace(expectedRevision, record) {
49
+ const existing = records.get(record.key);
50
+ if (existing === undefined
51
+ || existing.revision !== expectedRevision
52
+ || record.revision !== expectedRevision + 1
53
+ || existing.playerId !== record.playerId
54
+ || existing.target !== record.target
55
+ || existing.kind !== record.kind) {
56
+ throw new Error('Stale or conflicting monetization journal write.');
57
+ }
58
+ records.set(record.key, clone(record));
59
+ },
60
+ async listRecoverable(playerId) {
61
+ return [...records.values()]
62
+ .filter((record) => record.playerId === playerId && isMonetizationOperationRecoverable(record))
63
+ .map(clone);
64
+ },
65
+ async listGranted(playerId) {
66
+ return [...records.values()]
67
+ .filter((record) => record.playerId === playerId && isMonetizationOperationGranted(record))
68
+ .map(clone);
69
+ },
70
+ async findByTransaction(input) {
71
+ const found = [...records.values()].find((record) => record.kind === 'commerce-purchase'
72
+ && record.playerId === input.playerId
73
+ && record.target === input.target
74
+ && record.deploymentTarget === input.deploymentTarget
75
+ && record.store === input.store
76
+ && record.purchase?.transactionId === input.transactionId);
77
+ return found === undefined ? undefined : clone(found);
78
+ },
79
+ };
80
+ }
@@ -1,5 +1,6 @@
1
1
  import type { PurchaseResult, RewardedAdResult } from '@mpgd/platform';
2
2
  import { type CreateGameServicesClientInput } from './client.js';
3
+ import { type CommercePurchaseOperationRecord, type CommerceReconciler } from './commerce-reconciler.js';
3
4
  import type { GameServicesOperationClient, GameServicesPurchaseInput, GameServicesPurchaseResult, GameServicesRewardedAdInput, GameServicesRewardedAdResult } from './operations.js';
4
5
  import type { ClaimAdRewardRequest, ClaimAdRewardResponse, VerifyPurchaseRequest, VerifyPurchaseResponse } from './types.js';
5
6
  interface OperationIdentity {
@@ -12,7 +13,8 @@ interface OperationIdentity {
12
13
  /** Fixed when a platform result is first journaled, before backend dispatch. */
13
14
  readonly platformCompletedAt?: string;
14
15
  }
15
- export type MonetizationOperationRecord = (OperationIdentity & {
16
+ /** The v1 purchase and rewarded-ad journal records. */
17
+ export type LegacyMonetizationOperationRecord = (OperationIdentity & {
16
18
  readonly kind: 'purchase';
17
19
  readonly input: GameServicesPurchaseInput;
18
20
  readonly platform?: PurchaseResult;
@@ -27,6 +29,13 @@ export type MonetizationOperationRecord = (OperationIdentity & {
27
29
  readonly response?: ClaimAdRewardResponse;
28
30
  readonly result?: GameServicesRewardedAdResult;
29
31
  });
32
+ /**
33
+ * Every record kind the journal holds. `commerce-purchase` records are the
34
+ * commerce v2 reconciler's (`./commerce-reconciler`); their open state is
35
+ * `state`, not `result`, and `isMonetizationOperationRecoverable` applies the
36
+ * rule for every kind.
37
+ */
38
+ export type MonetizationOperationRecord = LegacyMonetizationOperationRecord | CommercePurchaseOperationRecord;
30
39
  /** Games must supply a durable, atomic implementation; an in-memory map is not recovery. */
31
40
  export interface MonetizationOperationStore {
32
41
  /** Atomically insert or return the existing operation for this global key. */
@@ -39,6 +48,25 @@ export interface MonetizationOperationStore {
39
48
  replace(expectedRevision: number, record: MonetizationOperationRecord): Promise<void>;
40
49
  /** Return only unresolved operations; keep terminal records addressable by read for deduplication. */
41
50
  listRecoverable(playerId: string): Promise<readonly MonetizationOperationRecord[]>;
51
+ /**
52
+ * Commerce v2 records in `granted`, `finishing`, or `finished` kept within
53
+ * the journal retention window, for the reconciler's revocation pass. A
54
+ * store without it only reports reversals of grants seen in this session.
55
+ */
56
+ listGranted?(playerId: string): Promise<readonly MonetizationOperationRecord[]>;
57
+ /**
58
+ * The commerce v2 record, terminal or not, whose `purchase.transactionId`
59
+ * is this store transaction. With it a transaction the store lists again
60
+ * after a restart (an owned durable, a rejected or finished purchase) meets
61
+ * its own record instead of being verified again under a derived key.
62
+ */
63
+ findByTransaction?(input: {
64
+ readonly playerId: string;
65
+ readonly target: string;
66
+ readonly deploymentTarget: string;
67
+ readonly store: string;
68
+ readonly transactionId: string;
69
+ }): Promise<MonetizationOperationRecord | undefined>;
42
70
  }
43
71
  export interface CreateRecoverableMonetizationClientInput extends CreateGameServicesClientInput {
44
72
  readonly operationStore: MonetizationOperationStore;
@@ -46,13 +74,40 @@ export interface CreateRecoverableMonetizationClientInput extends CreateGameServ
46
74
  export interface MonetizationOperationSummary {
47
75
  readonly kind: MonetizationOperationRecord['kind'];
48
76
  readonly idempotencyKey: string;
49
- readonly status: 'pending' | 'granted' | 'rejected' | 'cancelled' | 'failed' | 'skipped' | 'unavailable' | 'action-required';
77
+ /**
78
+ * RFC "Purchase lifecycle" mapping for a commerce v2 record: `granted`
79
+ * covers `granted`, `finishing`, and `finished`; `pending` covers
80
+ * `launched`, `pending`, `purchased`, and `verifying`; `revoked` is a
81
+ * reversed grant.
82
+ */
83
+ readonly status: 'pending' | 'granted' | 'rejected' | 'cancelled' | 'failed' | 'skipped' | 'unavailable' | 'action-required' | 'revoked';
50
84
  readonly finalizationPending: boolean;
51
85
  }
52
86
  export interface RecoverableMonetizationClient extends GameServicesOperationClient {
87
+ /**
88
+ * Replays a platform purchase callback into a v1 journal record.
89
+ *
90
+ * @deprecated For a gateway with a commerce v2 provider the reconciler
91
+ * learns the result from the provider itself: this call then runs one
92
+ * reconcile pass and returns the journaled result for the key, ignoring
93
+ * `platform`. Removed with the v1 adapter in C4.
94
+ */
53
95
  recoverPurchaseResult(idempotencyKey: string, platform: PurchaseResult): Promise<GameServicesPurchaseResult>;
54
96
  recoverRewardResult(idempotencyKey: string, platform: RewardedAdResult): Promise<GameServicesRewardedAdResult>;
97
+ /**
98
+ * Startup and app-resume recovery: resumes v1 records and, with a commerce
99
+ * v2 provider, starts or runs the reconciler (subscribe, read available
100
+ * purchases, send saved verify requests, finish granted purchases).
101
+ */
55
102
  reconcile(): Promise<readonly MonetizationOperationSummary[]>;
103
+ /**
104
+ * The manual restore trigger of the commerce v2 reconciler
105
+ * (`provider.restorePurchases` when offered, then a pass). Without a v2
106
+ * provider it is `reconcile()`.
107
+ */
108
+ restorePurchases(): Promise<readonly MonetizationOperationSummary[]>;
109
+ /** Present when the gateway exposes `commerceV2`. */
110
+ readonly commerceReconciler?: CommerceReconciler;
56
111
  }
57
112
  /**
58
113
  * Client-side recovery coordinates UI callbacks and authoritative backend calls.
@@ -1,6 +1,7 @@
1
1
  import { isAuthoritativeMicrosoftStoreCompletion } from './authoritative-purchase.js';
2
- import { createGameServicesClient } from './client.js';
2
+ import { createGameServicesClient, } from './client.js';
3
3
  import { resolveClientRewardClaim } from './client-reward-evidence.js';
4
+ import { summarizeCommercePurchaseOperation, toGameServicesPurchaseResult, } from './commerce-reconciler.js';
4
5
  import { createDefaultClientRewardEvidenceRegistry } from './default-client-reward-evidence.js';
5
6
  import { forwardGameServicesProgress, observeGameServicesOperation, } from './operation-progress.js';
6
7
  /**
@@ -20,6 +21,18 @@ export function createRecoverableMonetizationClient(input) {
20
21
  const unjournaledRewardResponses = new Map();
21
22
  const unjournaledPurchaseResults = new Map();
22
23
  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.
34
+ const { commerceReconciler: _sharedReconciler, operationStore: _journal, onCommerceEvent: _onCommerceEvent, commerceProducts: _commerceProducts, ...legacyClientInput } = input;
35
+ const { commerceV2: _commerceV2, ...legacyGateway } = input.gateway;
23
36
  async function serializeOperation(key, task) {
24
37
  const previous = inFlight.get(key) ?? Promise.resolve();
25
38
  const running = previous.catch(() => undefined).then(task);
@@ -50,6 +63,9 @@ export function createRecoverableMonetizationClient(input) {
50
63
  }
51
64
  }
52
65
  async function purchase(operation, options) {
66
+ if (commerceClient !== undefined) {
67
+ return commerceClient.purchase(operation, options);
68
+ }
53
69
  const key = operationKey(input.target, 'purchase', operation.idempotencyKey);
54
70
  return serializeOperation(key, async () => {
55
71
  const reserved = await store.reserve({
@@ -160,10 +176,10 @@ export function createRecoverableMonetizationClient(input) {
160
176
  return pendingPurchase();
161
177
  }
162
178
  const client = createGameServicesClient({
163
- ...input,
179
+ ...legacyClientInput,
164
180
  requestNow: () => record.platformCompletedAt ?? observedAt(),
165
181
  gateway: {
166
- ...input.gateway,
182
+ ...legacyGateway,
167
183
  commerce: {
168
184
  ...input.gateway.commerce,
169
185
  async purchase(request) {
@@ -315,10 +331,10 @@ export function createRecoverableMonetizationClient(input) {
315
331
  return pendingReward();
316
332
  }
317
333
  const client = createGameServicesClient({
318
- ...input,
334
+ ...legacyClientInput,
319
335
  requestNow: () => record.platformCompletedAt ?? observedAt(),
320
336
  gateway: {
321
- ...input.gateway,
337
+ ...legacyGateway,
322
338
  ads: {
323
339
  ...input.gateway.ads,
324
340
  async showRewarded(request) {
@@ -406,6 +422,16 @@ export function createRecoverableMonetizationClient(input) {
406
422
  return serializeOperation(key, async () => {
407
423
  const found = await store.read(key);
408
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
+ }
409
435
  throw new Error('No reserved purchase operation matches this platform callback.');
410
436
  }
411
437
  assertOwner(found, 'purchase', idempotencyKey, found.input.productId);
@@ -501,12 +527,69 @@ export function createRecoverableMonetizationClient(input) {
501
527
  }
502
528
  return !hasRequest || prior !== undefined;
503
529
  }
530
+ function ownsRecord(record) {
531
+ return record?.playerId === input.playerId && record.target === input.target
532
+ && record.deploymentTarget === (input.deploymentTarget ?? input.target);
533
+ }
534
+ function summarizeCommerceRecord(record) {
535
+ return {
536
+ kind: record.kind,
537
+ idempotencyKey: record.idempotencyKey,
538
+ status: summarizeCommercePurchaseOperation(record),
539
+ finalizationPending: record.state === 'finishing',
540
+ };
541
+ }
542
+ /** Runs the v2 trigger and reports every record it found open before the pass, at its state after it. */
543
+ async function reconcileCommerce(run) {
544
+ if (commerceReconciler === undefined) {
545
+ return [];
546
+ }
547
+ const openBefore = (await commerceReconciler.listRecoverable()).map((record) => record.idempotencyKey);
548
+ await run(commerceReconciler);
549
+ const seen = new Set();
550
+ const summaries = [];
551
+ for (const key of [
552
+ ...openBefore,
553
+ ...(await commerceReconciler.listRecoverable()).map((record) => record.idempotencyKey),
554
+ ]) {
555
+ if (seen.has(key)) {
556
+ continue;
557
+ }
558
+ seen.add(key);
559
+ const latest = await commerceReconciler.read(key);
560
+ if (latest !== undefined) {
561
+ summaries.push(summarizeCommerceRecord(latest));
562
+ }
563
+ }
564
+ return summaries;
565
+ }
566
+ async function restorePurchases() {
567
+ if (commerceReconciler === undefined) {
568
+ return reconcile();
569
+ }
570
+ return [
571
+ ...(await reconcileLegacy()),
572
+ ...(await reconcileCommerce((reconciler) => reconciler.restorePurchases())),
573
+ ];
574
+ }
504
575
  async function reconcile() {
576
+ return [
577
+ ...(await reconcileLegacy()),
578
+ ...(await reconcileCommerce(async (reconciler) => {
579
+ if (reconciler.started) {
580
+ await reconciler.reconcile();
581
+ }
582
+ else {
583
+ await reconciler.start();
584
+ }
585
+ })),
586
+ ];
587
+ }
588
+ async function reconcileLegacy() {
505
589
  const pending = await store.listRecoverable(input.playerId);
506
590
  const summaries = [];
507
591
  for (const record of pending) {
508
- if (record?.playerId !== input.playerId || record.target !== input.target
509
- || record.deploymentTarget !== (input.deploymentTarget ?? input.target)) {
592
+ if (!ownsRecord(record)) {
510
593
  continue;
511
594
  }
512
595
  if (record.kind !== 'purchase' && record.kind !== 'rewarded-ad') {
@@ -527,6 +610,9 @@ export function createRecoverableMonetizationClient(input) {
527
610
  catch {
528
611
  throw new OperationReadError();
529
612
  }
613
+ if (latest.kind === 'commerce-purchase') {
614
+ throw new Error('Monetization operation kind conflicts with its journal key.');
615
+ }
530
616
  assertOwner(latest, latest.kind, latest.input.idempotencyKey, latest.kind === 'purchase' ? latest.input.productId : latest.input.placementId);
531
617
  if (latest.kind === 'purchase') {
532
618
  const result = await resumePurchase(latest);
@@ -558,7 +644,15 @@ export function createRecoverableMonetizationClient(input) {
558
644
  }
559
645
  return summaries;
560
646
  }
561
- return { purchase, claimRewardedAd, recoverPurchaseResult, recoverRewardResult, reconcile };
647
+ return {
648
+ purchase,
649
+ claimRewardedAd,
650
+ recoverPurchaseResult,
651
+ recoverRewardResult,
652
+ reconcile,
653
+ restorePurchases,
654
+ ...(commerceReconciler === undefined ? {} : { commerceReconciler }),
655
+ };
562
656
  }
563
657
  class OperationReadError extends Error {
564
658
  }
@@ -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 {
@@ -20,6 +20,8 @@ const notificationTopics = new Set([
20
20
  const defaultClaimLeaseMs = 5 * 60 * 1_000;
21
21
  const maxCompletionAttempts = 3;
22
22
  const maxNotificationTemplateDataEntries = 128;
23
+ /** Caps identifiers, including a deep link after normalization so it normalizes to itself. */
24
+ const maxIdentifierLength = 2_048;
23
25
  /** Throw only when the provider can prove that no external send was attempted. */
24
26
  export class NotificationDeliveryNotSentError extends Error {
25
27
  name = 'NotificationDeliveryNotSentError';
@@ -337,11 +339,23 @@ function normalizeDeepLink(input, allowedOrigins) {
337
339
  }
338
340
  if (deepLink.startsWith('/') && !deepLink.startsWith('//')) {
339
341
  const baseUrl = new URL('https://mpgd.invalid');
340
- const parsed = new URL(deepLink, baseUrl);
341
- if (parsed.origin !== baseUrl.origin) {
342
+ let parsed;
343
+ // A backslash after the leading slash parses as an authority (`/\host`),
344
+ // so the link names a host, not a path on the game origin.
345
+ if (!deepLink.startsWith('/\\')) {
346
+ try {
347
+ parsed = new URL(deepLink, baseUrl);
348
+ }
349
+ catch {
350
+ // Rejected below.
351
+ }
352
+ }
353
+ if (parsed === undefined
354
+ || parsed.origin !== baseUrl.origin
355
+ || hasNetworkPathPrefix(parsed.pathname)) {
342
356
  throw new Error('deepLink must stay on the configured game origin.');
343
357
  }
344
- return `${parsed.pathname}${parsed.search}${parsed.hash}`;
358
+ return requireNormalizedDeepLinkLength(`${parsed.pathname}${parsed.search}${parsed.hash}`);
345
359
  }
346
360
  let parsed;
347
361
  try {
@@ -359,7 +373,33 @@ function normalizeDeepLink(input, allowedOrigins) {
359
373
  if (!allowedOrigins.has(parsed.origin)) {
360
374
  throw new Error('deepLink origin is not allowed.');
361
375
  }
362
- return parsed.toString();
376
+ if (hasNetworkPathPrefix(parsed.pathname)) {
377
+ throw new Error('deepLink path must not begin with an empty segment, even after percent-decoding.');
378
+ }
379
+ return requireNormalizedDeepLinkLength(parsed.toString());
380
+ }
381
+ /**
382
+ * Whether a normalized path could be read as a network-path reference to
383
+ * another host. Dot segments (also percent-encoded) can collapse `/.//host`
384
+ * into `//host`. A consumer that percent-decodes the path once and parses it
385
+ * again can reach the same shape from `/%2F/host`, `/%5Chost`, `/%09/host`,
386
+ * or `/.%2F/host`: URL parsing drops tabs and newlines, reads a backslash as
387
+ * a slash, and removes dot segments, so the decoded path is checked after
388
+ * the same steps.
389
+ */
390
+ function hasNetworkPathPrefix(pathname) {
391
+ const decoded = pathname
392
+ .replace(/%([0-7][0-9a-f])/giu, (_escape, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
393
+ .replace(/[\t\n\r]/gu, '');
394
+ return /^[/\\]{2}/u.test(decoded)
395
+ || new URL(decoded, 'https://mpgd.invalid').pathname.startsWith('//');
396
+ }
397
+ /** Normalization can grow a link; keep it within the input cap so it normalizes to itself. */
398
+ function requireNormalizedDeepLinkLength(deepLink) {
399
+ if (deepLink.length > maxIdentifierLength) {
400
+ throw new Error(`deepLink must contain at most ${String(maxIdentifierLength)} characters after normalization.`);
401
+ }
402
+ return deepLink;
363
403
  }
364
404
  function normalizeAllowedDeepLinkOrigins(input) {
365
405
  const origins = new Set();
@@ -427,7 +467,7 @@ function assertRecord(input, label) {
427
467
  function normalizeIdentifier(input, label) {
428
468
  if (typeof input !== 'string'
429
469
  || input.length === 0
430
- || input.length > 2_048
470
+ || input.length > maxIdentifierLength
431
471
  || input.trim() !== input) {
432
472
  throw new Error(`${label} must be a non-empty, trimmed string.`);
433
473
  }
@@ -15,6 +15,20 @@ export interface PurchaseGrantFinalization {
15
15
  readonly alreadyCompleted: boolean;
16
16
  readonly reason?: string;
17
17
  }
18
+ /** Whether a ledger grant is still active, or which entry reversed it (RFC "Revocation and expiry entries"). */
19
+ export interface PurchaseLedgerStatus {
20
+ readonly ledgerEntryId: string;
21
+ readonly status: 'active' | 'reversed' | 'unknown';
22
+ readonly reversedBy?: string;
23
+ }
24
+ export interface PurchaseLedgerStatusRequest {
25
+ readonly ledgerEntryIds: readonly string[];
26
+ /** The player whose grants are read; another player's entry answers `unknown`. */
27
+ readonly playerId: string;
28
+ }
29
+ export interface PurchaseLedgerStatusResponse {
30
+ readonly statuses: readonly PurchaseLedgerStatus[];
31
+ }
18
32
  export interface ClaimAdRewardResponse {
19
33
  readonly granted: boolean;
20
34
  /** Explicit non-grant decision; older servers may omit this field. */
@@ -13,7 +13,8 @@ export interface GameServicesPurchaseInput {
13
13
  readonly idempotencyKey: string;
14
14
  }
15
15
  export interface GameServicesPurchaseResult {
16
- readonly status: 'granted' | 'cancelled' | 'pending' | 'failed' | 'rejected';
16
+ /** `revoked` is reported only for a journaled commerce v2 purchase whose ledger grant was reversed. */
17
+ readonly status: 'granted' | 'cancelled' | 'pending' | 'failed' | 'rejected' | 'revoked';
17
18
  readonly purchase: PurchaseResult;
18
19
  readonly verification?: VerifyPurchaseResponse;
19
20
  readonly ledgerEntryId?: string;
@@ -1,4 +1,8 @@
1
+ import type { ProductCatalog } from '@mpgd/catalog';
1
2
  import type { PlatformEvidenceEnvelope } from '@mpgd/platform';
3
+ import { type RevokeEntitlementGrantResult } from './commerce-ledger.js';
4
+ import type { GameServicesStore } from './server.js';
5
+ import type { EntitlementLedgerGrant, VerifyPurchaseRequest } from './types.js';
2
6
  export type PlatformOrderTarget = 'reddit';
3
7
  export interface FulfillPlatformOrderInput {
4
8
  readonly target: PlatformOrderTarget;
@@ -20,3 +24,46 @@ export type PlatformOrderIdempotencyKey = `${PlatformOrderTarget}:${string}`;
20
24
  export declare function createPlatformOrderIdempotencyKey(input: Pick<FulfillPlatformOrderInput, 'target' | 'orderId'>): PlatformOrderIdempotencyKey;
21
25
  export declare function assertFulfillPlatformOrderInput(input: FulfillPlatformOrderInput): FulfillPlatformOrderInput;
22
26
  export declare function assertRefundPlatformOrderInput(input: RefundPlatformOrderInput): RefundPlatformOrderInput;
27
+ export interface CreatePlatformOrderLedgerGrantInput {
28
+ readonly order: FulfillPlatformOrderInput;
29
+ /** The catalog product the order's `platformSku` resolves to for the order target. */
30
+ readonly product: ProductCatalog['products'][number];
31
+ readonly grantedAt: string;
32
+ readonly deploymentTarget?: string;
33
+ }
34
+ /**
35
+ * The ledger grant a Devvit fulfillment handler writes for a paid order
36
+ * (`normalizeDevvitFulfillmentOrder`), under the platform order key
37
+ * `reddit:<orderId>` and in the shape the `devvit.order.v1` lookup verifier
38
+ * reads (target `reddit`, store `devvit`, the order id as the platform
39
+ * transaction id). Its evidence identity equals the one
40
+ * `createDevvitPaymentOrderEvidenceVerifier` issues, so a fulfillment that
41
+ * went through `verifyPurchase` and one that wrote the ledger directly
42
+ * dedupe on the same order.
43
+ */
44
+ export declare function createPlatformOrderLedgerGrant(input: CreatePlatformOrderLedgerGrantInput): EntitlementLedgerGrant;
45
+ /**
46
+ * The verify request a fulfillment handler sends when the backend verifies
47
+ * the order through the `devvit.payment-order.v1` registry entry instead of
48
+ * writing the ledger directly: the platform order key, the order id as the
49
+ * platform transaction id, and the handler's own envelope as evidence.
50
+ */
51
+ export declare function createPlatformOrderVerifyRequest(input: {
52
+ readonly order: FulfillPlatformOrderInput;
53
+ readonly productId: VerifyPurchaseRequest['productId'];
54
+ readonly deploymentTarget?: string;
55
+ }): VerifyPurchaseRequest;
56
+ export type RevokePlatformOrderResult = RevokeEntitlementGrantResult;
57
+ /**
58
+ * Writes the `revoke` entry for a refunded order (`normalizeDevvitRefundOrder`):
59
+ * the grant written for the same order id under the order target is
60
+ * reversed once, under the key `reddit:<orderId>:refund`. Answers
61
+ * `grant_not_found` when no grant for that order exists (the refund arrived
62
+ * before, or without, a fulfillment) and `grant_mismatch` when the grant
63
+ * belongs to another player.
64
+ */
65
+ export declare function revokePlatformOrder(input: {
66
+ readonly store: GameServicesStore;
67
+ readonly order: RefundPlatformOrderInput;
68
+ readonly revokedAt?: string;
69
+ }): Promise<RevokePlatformOrderResult>;