@mpgd/game-services 0.21.1 → 0.22.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 +30 -0
- package/dist/admob-ssv-conformance.js +3 -1
- package/dist/app-store-recovery.js +5 -0
- package/dist/client.d.ts +39 -1
- package/dist/client.js +198 -0
- package/dist/commerce-evidence-registry.d.ts +406 -0
- package/dist/commerce-evidence-registry.js +773 -0
- package/dist/commerce-ledger.d.ts +64 -0
- package/dist/commerce-ledger.js +146 -0
- package/dist/commerce-reconciler.d.ts +191 -0
- package/dist/commerce-reconciler.js +1251 -0
- package/dist/contract.d.ts +4 -1
- package/dist/contract.js +8 -0
- package/dist/evidence-verification.d.ts +14 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/monetization-operation-store.d.ts +20 -0
- package/dist/monetization-operation-store.js +80 -0
- package/dist/monetization-recovery.d.ts +57 -2
- package/dist/monetization-recovery.js +102 -8
- package/dist/operation-results.d.ts +14 -0
- package/dist/operations.d.ts +2 -1
- package/dist/platform-order.d.ts +47 -0
- package/dist/platform-order.js +86 -0
- package/dist/runtime.js +12 -1
- package/dist/server.d.ts +43 -1
- package/dist/server.js +305 -7
- package/dist/types.d.ts +79 -3
- package/dist/types.js +97 -6
- package/package.json +18 -6
package/dist/contract.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type RouterContractClient } from '@orpc/contract';
|
|
2
|
-
import type {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
179
|
+
...legacyClientInput,
|
|
164
180
|
requestNow: () => record.platformCompletedAt ?? observedAt(),
|
|
165
181
|
gateway: {
|
|
166
|
-
...
|
|
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
|
-
...
|
|
334
|
+
...legacyClientInput,
|
|
319
335
|
requestNow: () => record.platformCompletedAt ?? observedAt(),
|
|
320
336
|
gateway: {
|
|
321
|
-
...
|
|
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
|
|
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 {
|
|
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
|
}
|
|
@@ -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. */
|
package/dist/operations.d.ts
CHANGED
|
@@ -13,7 +13,8 @@ export interface GameServicesPurchaseInput {
|
|
|
13
13
|
readonly idempotencyKey: string;
|
|
14
14
|
}
|
|
15
15
|
export interface GameServicesPurchaseResult {
|
|
16
|
-
|
|
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;
|
package/dist/platform-order.d.ts
CHANGED
|
@@ -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>;
|
package/dist/platform-order.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createServerPushLedgerGrant, devvitCommerceStoreId, findPurchaseGrantByPlatformEvidence, } from './commerce-evidence-registry.js';
|
|
2
|
+
import { revokeEntitlementGrant } from './commerce-ledger.js';
|
|
1
3
|
export function createPlatformOrderIdempotencyKey(input) {
|
|
2
4
|
assertPlatformOrderTarget(input.target);
|
|
3
5
|
assertIdentifier(input.orderId, 'orderId');
|
|
@@ -67,3 +69,87 @@ function assertRecord(input, label) {
|
|
|
67
69
|
throw new TypeError(`${label} must be an object.`);
|
|
68
70
|
}
|
|
69
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* The ledger grant a Devvit fulfillment handler writes for a paid order
|
|
74
|
+
* (`normalizeDevvitFulfillmentOrder`), under the platform order key
|
|
75
|
+
* `reddit:<orderId>` and in the shape the `devvit.order.v1` lookup verifier
|
|
76
|
+
* reads (target `reddit`, store `devvit`, the order id as the platform
|
|
77
|
+
* transaction id). Its evidence identity equals the one
|
|
78
|
+
* `createDevvitPaymentOrderEvidenceVerifier` issues, so a fulfillment that
|
|
79
|
+
* went through `verifyPurchase` and one that wrote the ledger directly
|
|
80
|
+
* dedupe on the same order.
|
|
81
|
+
*/
|
|
82
|
+
export function createPlatformOrderLedgerGrant(input) {
|
|
83
|
+
const order = assertFulfillPlatformOrderInput(input.order);
|
|
84
|
+
return createServerPushLedgerGrant({
|
|
85
|
+
store: devvitCommerceStoreId,
|
|
86
|
+
target: order.target,
|
|
87
|
+
...(input.deploymentTarget === undefined ? {} : { deploymentTarget: input.deploymentTarget }),
|
|
88
|
+
playerId: order.playerId,
|
|
89
|
+
product: input.product,
|
|
90
|
+
platformProductId: order.platformSku,
|
|
91
|
+
transactionId: order.orderId,
|
|
92
|
+
purchasedAt: order.paidAt,
|
|
93
|
+
grantedAt: input.grantedAt,
|
|
94
|
+
evidence: order.evidence,
|
|
95
|
+
idempotencyKey: createPlatformOrderIdempotencyKey(order),
|
|
96
|
+
evidenceVerificationId: createPlatformOrderEvidenceVerificationId(order),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The verify request a fulfillment handler sends when the backend verifies
|
|
101
|
+
* the order through the `devvit.payment-order.v1` registry entry instead of
|
|
102
|
+
* writing the ledger directly: the platform order key, the order id as the
|
|
103
|
+
* platform transaction id, and the handler's own envelope as evidence.
|
|
104
|
+
*/
|
|
105
|
+
export function createPlatformOrderVerifyRequest(input) {
|
|
106
|
+
const order = assertFulfillPlatformOrderInput(input.order);
|
|
107
|
+
return {
|
|
108
|
+
target: order.target,
|
|
109
|
+
store: devvitCommerceStoreId,
|
|
110
|
+
...(input.deploymentTarget === undefined ? {} : { deploymentTarget: input.deploymentTarget }),
|
|
111
|
+
playerId: order.playerId,
|
|
112
|
+
productId: input.productId,
|
|
113
|
+
platformTransactionId: order.orderId,
|
|
114
|
+
idempotencyKey: createPlatformOrderIdempotencyKey(order),
|
|
115
|
+
purchasedAt: order.paidAt,
|
|
116
|
+
evidence: order.evidence,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Writes the `revoke` entry for a refunded order (`normalizeDevvitRefundOrder`):
|
|
121
|
+
* the grant written for the same order id under the order target is
|
|
122
|
+
* reversed once, under the key `reddit:<orderId>:refund`. Answers
|
|
123
|
+
* `grant_not_found` when no grant for that order exists (the refund arrived
|
|
124
|
+
* before, or without, a fulfillment) and `grant_mismatch` when the grant
|
|
125
|
+
* belongs to another player.
|
|
126
|
+
*/
|
|
127
|
+
export async function revokePlatformOrder(input) {
|
|
128
|
+
const order = assertRefundPlatformOrderInput(input.order);
|
|
129
|
+
const grant = await findPurchaseGrantByPlatformEvidence(input.store, {
|
|
130
|
+
target: order.target,
|
|
131
|
+
platformEvidenceId: order.orderId,
|
|
132
|
+
});
|
|
133
|
+
if (grant === undefined) {
|
|
134
|
+
return { status: 'grant_not_found' };
|
|
135
|
+
}
|
|
136
|
+
return revokeEntitlementGrant(input.store, {
|
|
137
|
+
playerId: order.playerId,
|
|
138
|
+
revokesLedgerEntryId: grant.ledgerEntryId,
|
|
139
|
+
idempotencyKey: `${createPlatformOrderIdempotencyKey(order)}:refund`,
|
|
140
|
+
reason: 'refund',
|
|
141
|
+
revokedAt: input.revokedAt ?? order.refundedAt,
|
|
142
|
+
evidenceVerificationId: `${createPlatformOrderEvidenceVerificationId(order)}:refund`,
|
|
143
|
+
payload: {
|
|
144
|
+
target: order.target,
|
|
145
|
+
store: devvitCommerceStoreId,
|
|
146
|
+
platformTransactionId: order.orderId,
|
|
147
|
+
platformProductId: order.platformSku,
|
|
148
|
+
refundedAt: order.refundedAt,
|
|
149
|
+
evidenceSchema: order.evidence.schema,
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
function createPlatformOrderEvidenceVerificationId(order) {
|
|
154
|
+
return `devvit:payment-order:${order.orderId}`;
|
|
155
|
+
}
|