@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.
@@ -0,0 +1,64 @@
1
+ import type { GameServicesStore } from './server.js';
2
+ import { type EntitlementLedgerRevocation, type EntitlementRevocationTransaction, type ProductGrantTransaction, type PurchaseLedgerStatusRequest, type PurchaseLedgerStatusResponse } from './types.js';
3
+ /**
4
+ * Ledger reads and the revocation write path over any `GameServicesStore`
5
+ * (RFC "Revocation and expiry entries"). Kept apart from the transport module
6
+ * so a notification handler can import it without the HTTP and oRPC
7
+ * machinery.
8
+ */
9
+ /** The D1 bound-parameter cap: every ledger read is issued in chunks of at most this many ids. */
10
+ export declare const ledgerReadChunkSize = 100;
11
+ export declare function chunkLedgerEntryIds(ledgerEntryIds: readonly string[], size?: number): readonly (readonly string[])[];
12
+ /** The revocations reversing any of the given grants, read in chunks the store accepts. */
13
+ export declare function findEntitlementRevocationsChunked(store: GameServicesStore, ledgerEntryIds: readonly string[]): Promise<readonly EntitlementRevocationTransaction[]>;
14
+ /** The grants with the given ids, through the store's batched read when it has one. */
15
+ export declare function getEntitlementTransactionsChunked(store: GameServicesStore, ledgerEntryIds: readonly string[]): Promise<ReadonlyMap<string, ProductGrantTransaction>>;
16
+ /**
17
+ * Whether each grant is still active or which `revoke` entry reversed it
18
+ * (RFC "Revocation and expiry entries"): one status per requested id, in
19
+ * request order, for the grants of `playerId`. A store without revocation
20
+ * support answers `active` for every grant it holds; an id that names no
21
+ * grant, or a grant of another player, is `unknown`.
22
+ */
23
+ export declare function readPurchaseLedgerStatuses(store: GameServicesStore, input: PurchaseLedgerStatusRequest): Promise<PurchaseLedgerStatusResponse>;
24
+ /** A revocation key reused for another grant than the one it first reversed. */
25
+ export declare class RevocationKeyConflictError extends Error {
26
+ constructor();
27
+ }
28
+ export type RevokeEntitlementGrantResult = {
29
+ readonly status: 'revoked';
30
+ readonly ledgerEntryId: string;
31
+ readonly alreadyProcessed: boolean;
32
+ } | {
33
+ readonly status: 'grant_not_found';
34
+ } | {
35
+ readonly status: 'grant_mismatch';
36
+ }
37
+ /** The notification key already reversed another grant. */
38
+ | {
39
+ readonly status: 'key_conflict';
40
+ }
41
+ /** The notification's authority identity already reversed another grant. */
42
+ | {
43
+ readonly status: 'evidence_already_processed';
44
+ };
45
+ /**
46
+ * Appends a `revoke` entry for an earlier grant: the write path of store
47
+ * notification handlers (a Play voided purchase, an App Store refund, an Apps
48
+ * in Toss or Devvit refunded order). The grant must exist and belong to the
49
+ * revocation's player; a store without `recordEntitlementRevocation` throws.
50
+ * Replays of the same notification key, and a second revocation of one
51
+ * grant, answer the first entry with `alreadyProcessed: true`; a key or
52
+ * authority identity reused for another grant is a conflict, not a success.
53
+ */
54
+ export declare function revokeEntitlementGrant(store: GameServicesStore, input: EntitlementLedgerRevocation): Promise<RevokeEntitlementGrantResult>;
55
+ /** Whether a grant was reversed by a `revoke` entry; false on a store without revocations. */
56
+ export declare function isEntitlementGrantReversed(store: GameServicesStore, ledgerEntryId: string): Promise<boolean>;
57
+ /**
58
+ * The ledger's grants minus those a `revoke` entry reversed: the read an
59
+ * entitlement or balance computation starts from (RFC "Revocation and expiry
60
+ * entries": entitlement reads subtract reversed grants).
61
+ */
62
+ export declare function listActiveEntitlementTransactions(store: GameServicesStore, options?: {
63
+ readonly playerId?: string;
64
+ }): Promise<readonly ProductGrantTransaction[]>;
@@ -0,0 +1,146 @@
1
+ import { assertEntitlementLedgerRevocation, assertPurchaseLedgerStatusRequest, assertPurchaseLedgerStatusResponse, } from './types.js';
2
+ /**
3
+ * Ledger reads and the revocation write path over any `GameServicesStore`
4
+ * (RFC "Revocation and expiry entries"). Kept apart from the transport module
5
+ * so a notification handler can import it without the HTTP and oRPC
6
+ * machinery.
7
+ */
8
+ /** The D1 bound-parameter cap: every ledger read is issued in chunks of at most this many ids. */
9
+ export const ledgerReadChunkSize = 100;
10
+ export function chunkLedgerEntryIds(ledgerEntryIds, size = ledgerReadChunkSize) {
11
+ if (!Number.isSafeInteger(size) || size <= 0) {
12
+ throw new RangeError('Ledger read chunk size must be a positive integer.');
13
+ }
14
+ const chunks = [];
15
+ for (let index = 0; index < ledgerEntryIds.length; index += size) {
16
+ chunks.push(ledgerEntryIds.slice(index, index + size));
17
+ }
18
+ return chunks;
19
+ }
20
+ /** The revocations reversing any of the given grants, read in chunks the store accepts. */
21
+ export async function findEntitlementRevocationsChunked(store, ledgerEntryIds) {
22
+ const read = store.findEntitlementRevocations;
23
+ if (read === undefined || ledgerEntryIds.length === 0) {
24
+ return [];
25
+ }
26
+ const revocations = [];
27
+ for (const chunk of chunkLedgerEntryIds([...new Set(ledgerEntryIds)])) {
28
+ revocations.push(...await read.call(store, { ledgerEntryIds: chunk }));
29
+ }
30
+ return revocations;
31
+ }
32
+ /** The grants with the given ids, through the store's batched read when it has one. */
33
+ export async function getEntitlementTransactionsChunked(store, ledgerEntryIds) {
34
+ const found = new Map();
35
+ const unique = [...new Set(ledgerEntryIds)];
36
+ const batched = store.getEntitlementTransactions;
37
+ if (batched !== undefined) {
38
+ for (const chunk of chunkLedgerEntryIds(unique)) {
39
+ for (const transaction of await batched.call(store, chunk)) {
40
+ found.set(transaction.ledgerEntryId, transaction);
41
+ }
42
+ }
43
+ return found;
44
+ }
45
+ for (const ledgerEntryId of unique) {
46
+ const transaction = await store.getEntitlementTransaction(ledgerEntryId);
47
+ if (transaction !== undefined) {
48
+ found.set(ledgerEntryId, transaction);
49
+ }
50
+ }
51
+ return found;
52
+ }
53
+ /**
54
+ * Whether each grant is still active or which `revoke` entry reversed it
55
+ * (RFC "Revocation and expiry entries"): one status per requested id, in
56
+ * request order, for the grants of `playerId`. A store without revocation
57
+ * support answers `active` for every grant it holds; an id that names no
58
+ * grant, or a grant of another player, is `unknown`.
59
+ */
60
+ export async function readPurchaseLedgerStatuses(store, input) {
61
+ const request = assertPurchaseLedgerStatusRequest(input);
62
+ const grants = await getEntitlementTransactionsChunked(store, request.ledgerEntryIds);
63
+ const owned = [...grants.values()]
64
+ .filter((grant) => grant.playerId === request.playerId)
65
+ .map((grant) => grant.ledgerEntryId);
66
+ const reversedBy = new Map();
67
+ for (const revocation of await findEntitlementRevocationsChunked(store, owned)) {
68
+ if (!reversedBy.has(revocation.revokesLedgerEntryId)) {
69
+ reversedBy.set(revocation.revokesLedgerEntryId, revocation.ledgerEntryId);
70
+ }
71
+ }
72
+ const ownedSet = new Set(owned);
73
+ const statuses = request.ledgerEntryIds.map((ledgerEntryId) => {
74
+ const by = reversedBy.get(ledgerEntryId);
75
+ if (by !== undefined) {
76
+ return { ledgerEntryId, status: 'reversed', reversedBy: by };
77
+ }
78
+ return { ledgerEntryId, status: ownedSet.has(ledgerEntryId) ? 'active' : 'unknown' };
79
+ });
80
+ return assertPurchaseLedgerStatusResponse({ statuses });
81
+ }
82
+ /** A revocation key reused for another grant than the one it first reversed. */
83
+ export class RevocationKeyConflictError extends Error {
84
+ constructor() {
85
+ super('Entitlement revocation idempotency key is bound to another grant.');
86
+ this.name = 'RevocationKeyConflictError';
87
+ }
88
+ }
89
+ /**
90
+ * Appends a `revoke` entry for an earlier grant: the write path of store
91
+ * notification handlers (a Play voided purchase, an App Store refund, an Apps
92
+ * in Toss or Devvit refunded order). The grant must exist and belong to the
93
+ * revocation's player; a store without `recordEntitlementRevocation` throws.
94
+ * Replays of the same notification key, and a second revocation of one
95
+ * grant, answer the first entry with `alreadyProcessed: true`; a key or
96
+ * authority identity reused for another grant is a conflict, not a success.
97
+ */
98
+ export async function revokeEntitlementGrant(store, input) {
99
+ const revocation = assertEntitlementLedgerRevocation(input);
100
+ if (store.recordEntitlementRevocation === undefined) {
101
+ throw new Error('This GameServicesStore does not record entitlement revocations.');
102
+ }
103
+ const grant = await store.getEntitlementTransaction(revocation.revokesLedgerEntryId);
104
+ if (grant === undefined) {
105
+ return { status: 'grant_not_found' };
106
+ }
107
+ if (grant.playerId !== revocation.playerId) {
108
+ return { status: 'grant_mismatch' };
109
+ }
110
+ try {
111
+ const result = await store.recordEntitlementRevocation(revocation);
112
+ return {
113
+ status: 'revoked',
114
+ ledgerEntryId: result.ledgerEntryId,
115
+ alreadyProcessed: result.alreadyProcessed,
116
+ };
117
+ }
118
+ catch (error) {
119
+ if (error instanceof RevocationKeyConflictError) {
120
+ return { status: 'key_conflict' };
121
+ }
122
+ if (error instanceof Error && error.name === 'EvidenceAlreadyProcessedError') {
123
+ return { status: 'evidence_already_processed' };
124
+ }
125
+ throw error;
126
+ }
127
+ }
128
+ /** Whether a grant was reversed by a `revoke` entry; false on a store without revocations. */
129
+ export async function isEntitlementGrantReversed(store, ledgerEntryId) {
130
+ const revocations = await findEntitlementRevocationsChunked(store, [ledgerEntryId]);
131
+ return revocations.length > 0;
132
+ }
133
+ /**
134
+ * The ledger's grants minus those a `revoke` entry reversed: the read an
135
+ * entitlement or balance computation starts from (RFC "Revocation and expiry
136
+ * entries": entitlement reads subtract reversed grants).
137
+ */
138
+ export async function listActiveEntitlementTransactions(store, options = {}) {
139
+ const transactions = (await store.listEntitlementTransactions()).filter((transaction) => options.playerId === undefined || transaction.playerId === options.playerId);
140
+ if (store.findEntitlementRevocations === undefined || transactions.length === 0) {
141
+ return transactions;
142
+ }
143
+ const revocations = await findEntitlementRevocationsChunked(store, transactions.map((transaction) => transaction.ledgerEntryId));
144
+ const reversed = new Set(revocations.map((revocation) => revocation.revokesLedgerEntryId));
145
+ return transactions.filter((transaction) => !reversed.has(transaction.ledgerEntryId));
146
+ }
@@ -0,0 +1,191 @@
1
+ import type { LogicalProductId } from '@mpgd/platform';
2
+ import { type CommerceCapabilities, type CommerceErrorInfo, type CommerceProvider, type CommercePurchaseState, type CommercePurchaseSummaryStatus, type FetchProductsInput, type PurchaseKind, type RequestPurchaseInput, type RequestPurchaseResult, type StoreId, type StoreProduct, type StorePurchase } from '@mpgd/platform/commerce';
3
+ import type { PurchaseVerificationApi } from './client.js';
4
+ import { type CommerceServiceAlignment } from './commerce-evidence-registry.js';
5
+ import type { MonetizationOperationStore } from './monetization-recovery.js';
6
+ import type { GameServicesPurchaseInput, GameServicesPurchaseResult } from './operations.js';
7
+ import type { GameServicesLedgerTarget, VerifyPurchaseRequest, VerifyPurchaseResponse } from './types.js';
8
+ /**
9
+ * Commerce v2 reconciler (RFC "Purchase lifecycle", "Reconciler", and
10
+ * "Idempotency keys"). One reconciler per `CommerceProvider` replaces the
11
+ * per-store recovery drivers: it journals every purchase the game starts or
12
+ * the store reports as one record moving through the RFC states, sends the
13
+ * saved verify request to the game backend, and finishes a purchase only after
14
+ * the backend's ledger grant. Nothing here grants: only the backend response
15
+ * moves a record to `granted`, and only the backend ledger moves it to
16
+ * `revoked`.
17
+ */
18
+ /** Journal record kind of a commerce v2 purchase. */
19
+ export declare const commercePurchaseOperationKind: 'commerce-purchase';
20
+ export type CommercePurchaseOrigin = 'game' | 'discovered';
21
+ /**
22
+ * One commerce v2 purchase in the `MonetizationOperationStore`. `key` is the
23
+ * store's global key (`createCommercePurchaseOperationKey`); `idempotencyKey`
24
+ * is the purchase key the backend sees: the game's key for a purchase the
25
+ * game started, or the derived key of a purchase discovered from the store.
26
+ * Every state change is its own revision; `request` is saved before the
27
+ * first send and resent unchanged; `response` is the last backend answer.
28
+ */
29
+ export interface CommercePurchaseOperationRecord {
30
+ readonly kind: typeof commercePurchaseOperationKind;
31
+ /** The store must enforce uniqueness across users, not just within one account. */
32
+ readonly key: string;
33
+ readonly playerId: string;
34
+ readonly target: string;
35
+ readonly deploymentTarget: string;
36
+ readonly revision: number;
37
+ readonly idempotencyKey: string;
38
+ readonly store: StoreId;
39
+ readonly logicalId: LogicalProductId;
40
+ /** Unknown while the catalog cannot be read; a client or host finish waits until it is. */
41
+ readonly productKind?: PurchaseKind;
42
+ readonly origin: CommercePurchaseOrigin;
43
+ readonly state: CommercePurchaseState;
44
+ readonly updatedAt: string;
45
+ /** The game's purchase input; absent for a purchase discovered from the store. */
46
+ readonly input?: GameServicesPurchaseInput;
47
+ readonly purchase?: StorePurchase;
48
+ /** Saved before the first send and resent unchanged on later passes. */
49
+ readonly request?: VerifyPurchaseRequest;
50
+ readonly response?: VerifyPurchaseResponse;
51
+ readonly ledgerEntryId?: string;
52
+ readonly error?: CommerceErrorInfo;
53
+ readonly revokedBy?: string;
54
+ }
55
+ /** The store key of a commerce v2 purchase record: one namespace per ledger target. */
56
+ export declare function createCommercePurchaseOperationKey(target: string, idempotencyKey: string): string;
57
+ export declare function isCommercePurchaseOperationRecord(record: unknown): record is CommercePurchaseOperationRecord;
58
+ /** Summary status of a record: the RFC mapping, with `revoked` kept distinct. */
59
+ export declare function summarizeCommercePurchaseOperation(record: CommercePurchaseOperationRecord): CommercePurchaseSummaryStatus;
60
+ /**
61
+ * Only `granted` and `finishing` records that hold the backend's ledger entry
62
+ * id and a store transaction may be finished. The StoreKit v2 SDK keeps no
63
+ * finish allow-list of its own (the v1 driver's `finishGrantedTransaction`
64
+ * pairing), so this is the finish gate for every `client` and `host` owner:
65
+ * the ledger entry id comes from the journal revision the backend grant wrote.
66
+ */
67
+ export declare function assertCommerceFinishAllowed(record: CommercePurchaseOperationRecord): {
68
+ readonly transactionId: string;
69
+ readonly ledgerEntryId: string;
70
+ };
71
+ /** The `GameServicesPurchaseResult` a journal record stands for; `pending` allows no client-side grant. */
72
+ export declare function toGameServicesPurchaseResult(record: CommercePurchaseOperationRecord): GameServicesPurchaseResult;
73
+ /** A catalog entry the reconciler resolves kinds and SKUs from without a store read. */
74
+ export interface CommerceReconcilerProduct {
75
+ readonly logicalId: LogicalProductId;
76
+ readonly kind: PurchaseKind;
77
+ readonly storeSku?: string;
78
+ }
79
+ export type CommerceReconcilerFailureStage = 'capabilities' | 'available' | 'catalog' | 'match' | 'journal' | 'verify' | 'finish' | 'ledger-status' | 'restore';
80
+ export type CommerceReconcilerEvent = {
81
+ readonly type: 'transition';
82
+ readonly record: CommercePurchaseOperationRecord;
83
+ /** Undefined when the record was just reserved. */
84
+ readonly previous: CommercePurchaseState | undefined;
85
+ } | {
86
+ readonly type: 'verify-requested';
87
+ readonly record: CommercePurchaseOperationRecord;
88
+ } | {
89
+ readonly type: 'verify-result';
90
+ readonly record: CommercePurchaseOperationRecord;
91
+ readonly response: VerifyPurchaseResponse;
92
+ } | {
93
+ readonly type: 'failure';
94
+ readonly stage: CommerceReconcilerFailureStage;
95
+ readonly idempotencyKey?: string;
96
+ readonly error: unknown;
97
+ };
98
+ export interface CreateCommerceReconcilerInput {
99
+ readonly provider: CommerceProvider;
100
+ /** The game backend; `getPurchaseLedgerStatus` enables the revocation pass once the server exposes it (C2). */
101
+ readonly backend: PurchaseVerificationApi;
102
+ /** Durable, compare-and-swap journal shared with the v1 recovery client. */
103
+ readonly store: MonetizationOperationStore;
104
+ readonly playerId: string;
105
+ /** Ledger target sent as `VerifyPurchaseRequest.target`. */
106
+ readonly target: GameServicesLedgerTarget;
107
+ readonly deploymentTarget?: string;
108
+ /**
109
+ * Catalog mapping consulted before `fetchProducts`; required for a provider
110
+ * whose product list cannot be read before the first purchase.
111
+ */
112
+ readonly products?: readonly CommerceReconcilerProduct[];
113
+ readonly now?: () => string;
114
+ readonly onEvent?: (event: CommerceReconcilerEvent) => void;
115
+ /**
116
+ * How long a `launched` record with no store purchase keeps serializing
117
+ * purchases of its product. After it the attempt is `failed` with
118
+ * `OUTCOME_UNKNOWN`; a purchase the store reports later is journaled under
119
+ * its derived key. Defaults to 24 hours; `Infinity` keeps the RFC rule
120
+ * without expiry.
121
+ */
122
+ readonly staleLaunchedAfterMs?: number;
123
+ /**
124
+ * Minimum time before an event-triggered pass retries a record's backend
125
+ * or store step: resending a backend-pending request, a verify or finish
126
+ * that failed in transport, a finalization re-read, or a catalog lookup.
127
+ * An explicit trigger (`start`, `reconcile`, `restorePurchases`) always
128
+ * retries. Defaults to one minute; `0` retries on every pass.
129
+ */
130
+ readonly retryIntervalMs?: number;
131
+ /**
132
+ * How long a running store call keeps its product serialized and its
133
+ * events waiting. A call that has not answered after this (a store sheet
134
+ * that never settles) stops deferring: events for the product are
135
+ * reconciled as they come and a new purchase of it may start. Defaults to
136
+ * ten minutes.
137
+ */
138
+ readonly inFlightTimeoutMs?: number;
139
+ /**
140
+ * How long the backend's `getCommerceCapabilities` read may take at start
141
+ * and on explicit passes before the last comparison is kept. Defaults to
142
+ * ten seconds.
143
+ */
144
+ readonly serviceCapabilitiesTimeoutMs?: number;
145
+ }
146
+ export declare const defaultStaleLaunchedAfterMs: number;
147
+ /** How long one backend capabilities read may take before the last comparison is kept. */
148
+ export declare const defaultServiceCapabilitiesTimeoutMs: number;
149
+ /** `compareCommerceServiceCapabilities`'s answer, or `unpublished` for a backend without the read. */
150
+ export type CommerceReconcilerServiceAlignment = CommerceServiceAlignment | {
151
+ readonly status: 'unpublished';
152
+ readonly store: StoreId;
153
+ };
154
+ export declare const defaultRetryIntervalMs: number;
155
+ export declare const defaultInFlightTimeoutMs: number;
156
+ export interface CommerceReconciler {
157
+ readonly provider: CommerceProvider;
158
+ readonly started: boolean;
159
+ /**
160
+ * The last comparison of the provider's capabilities with the backend's
161
+ * published ones (RFC "Provider capabilities endpoint"); undefined until
162
+ * the first read, or when the backend offers no `getCommerceCapabilities`
163
+ * (`unpublished` once a backend answered that it has no such read). While
164
+ * it is `configuration-required`, the capabilities this reconciler reports
165
+ * carry that availability and `requestPurchase` refuses with
166
+ * `CONFIGURATION_REQUIRED`; a purchase never opens the store before the
167
+ * comparison was attempted.
168
+ */
169
+ readonly serviceAlignment: CommerceReconcilerServiceAlignment | undefined;
170
+ /** The provider's capability block with the backend alignment applied to `availability`. */
171
+ getCapabilities(): Promise<CommerceCapabilities>;
172
+ /** Subscribe to the provider, read its available purchases when declared, then reconcile until idle. */
173
+ start(): Promise<void>;
174
+ /** Unsubscribe and drop in-memory state; the journal keeps every record. */
175
+ stop(): Promise<void>;
176
+ /** One reconcile pass (the app-resume trigger), then any passes that events arriving meanwhile trigger. */
177
+ reconcile(): Promise<void>;
178
+ /** Reconcile while a trigger is pending; a no-op when stopped. */
179
+ runUntilIdle(): Promise<void>;
180
+ /** The manual restore trigger: `provider.restorePurchases` when offered, then a pass over the available purchases. */
181
+ restorePurchases(): Promise<void>;
182
+ /** Journals the attempt, opens the store once per key, and reconciles the result. Never reopens the store UI for a journaled key. */
183
+ requestPurchase(input: RequestPurchaseInput): Promise<RequestPurchaseResult>;
184
+ fetchProducts(input?: FetchProductsInput): Promise<readonly StoreProduct[]>;
185
+ /** The record journaled under a purchase idempotency key. */
186
+ read(idempotencyKey: string): Promise<CommercePurchaseOperationRecord | undefined>;
187
+ /** The player's open records for this provider's store. */
188
+ listRecoverable(): Promise<readonly CommercePurchaseOperationRecord[]>;
189
+ subscribe(listener: (event: CommerceReconcilerEvent) => void): () => void;
190
+ }
191
+ export declare function createCommerceReconciler(input: CreateCommerceReconcilerInput): CommerceReconciler;