@mpgd/game-services 0.16.0 → 0.17.1

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/dist/client.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { createORPCClient } from '@orpc/client';
2
2
  import { RPCLink } from '@orpc/client/fetch';
3
3
  import { createAnalyticsReporter } from '@mpgd/analytics';
4
+ import { isAuthoritativeMicrosoftStoreCompletion } from './authoritative-purchase.js';
5
+ import { isClientRewardEvidence, resolveRewardPlatformImpressionId, } from './admob-client-reward.js';
4
6
  import { observeGameServicesOperation } from './operation-progress.js';
5
7
  export const gameServicesBackendEndpoints = {
6
8
  verifyPurchase: '/game-services/purchases/verify',
@@ -54,6 +56,7 @@ export async function resolveGameServicesRequestHeaders(input) {
54
56
  }
55
57
  export function createGameServicesClient(input) {
56
58
  const now = input.now ?? (() => new Date().toISOString());
59
+ const requestNow = input.requestNow ?? now;
57
60
  const analytics = createAnalyticsReporter({
58
61
  target: input.target,
59
62
  sessionId: input.analyticsSessionId ?? input.playerId,
@@ -88,7 +91,7 @@ export function createGameServicesClient(input) {
88
91
  if (target === 'verse8') {
89
92
  const status = purchase.status === 'completed' ? 'rejected' : purchase.status;
90
93
  await analytics.track({
91
- name: 'purchase_rejected',
94
+ name: purchase.status === 'pending' ? 'purchase_pending' : 'purchase_rejected',
92
95
  properties: {
93
96
  productId: purchaseInput.productId,
94
97
  status,
@@ -123,7 +126,7 @@ export function createGameServicesClient(input) {
123
126
  }
124
127
  if (purchase.status !== 'completed' || purchase.transactionId === undefined) {
125
128
  await analytics.track({
126
- name: 'purchase_rejected',
129
+ name: purchase.status === 'pending' ? 'purchase_pending' : 'purchase_rejected',
127
130
  properties: {
128
131
  productId: purchaseInput.productId,
129
132
  status: purchase.status,
@@ -144,14 +147,14 @@ export function createGameServicesClient(input) {
144
147
  productId: purchaseInput.productId,
145
148
  platformTransactionId: purchase.transactionId,
146
149
  idempotencyKey: purchaseInput.idempotencyKey,
147
- purchasedAt: now(),
150
+ purchasedAt: requestNow(),
148
151
  ...(purchase.evidence === undefined ? {} : { evidence: purchase.evidence }),
149
152
  };
150
153
  progress.serverRequested();
151
154
  const verification = await input.backend.purchases.verifyPurchase(verificationRequest);
152
155
  progress.serverResult(verification.verified);
153
156
  const result = {
154
- status: verification.verified ? 'granted' : 'rejected',
157
+ status: verification.verified ? 'granted' : (verification.disposition ?? 'rejected'),
155
158
  purchase,
156
159
  verification,
157
160
  ...(verification.ledgerEntryId === undefined
@@ -159,7 +162,7 @@ export function createGameServicesClient(input) {
159
162
  : { ledgerEntryId: verification.ledgerEntryId }),
160
163
  };
161
164
  await analytics.track({
162
- name: verification.verified ? 'purchase_granted' : 'purchase_rejected',
165
+ name: verification.verified ? 'purchase_granted' : verification.disposition === 'pending' ? 'purchase_pending' : 'purchase_rejected',
163
166
  properties: {
164
167
  productId: purchaseInput.productId,
165
168
  status: result.status,
@@ -196,9 +199,10 @@ export function createGameServicesClient(input) {
196
199
  progress.platformRequested();
197
200
  const reward = await input.gateway.ads.showRewarded(rewardInput);
198
201
  progress.platformResult(reward.status);
199
- if (reward.status !== 'completed' || !reward.rewardGranted) {
202
+ if ((reward.status !== 'completed' || !reward.rewardGranted)
203
+ && !isClientRewardEvidence(reward)) {
200
204
  await analytics.track({
201
- name: 'rewarded_ad_rejected',
205
+ name: reward.status === 'pending' ? 'rewarded_ad_pending' : 'rewarded_ad_rejected',
202
206
  properties: {
203
207
  placementId: rewardInput.placementId,
204
208
  status: reward.status,
@@ -210,6 +214,7 @@ export function createGameServicesClient(input) {
210
214
  reward,
211
215
  };
212
216
  }
217
+ const platformImpressionId = resolveRewardPlatformImpressionId(reward);
213
218
  const claimRequest = {
214
219
  target,
215
220
  ...(input.deploymentTarget === undefined || input.deploymentTarget === target
@@ -217,24 +222,24 @@ export function createGameServicesClient(input) {
217
222
  : { deploymentTarget: input.deploymentTarget }),
218
223
  playerId: input.playerId,
219
224
  placementId: rewardInput.placementId,
220
- ...(reward.ledgerEntryId === undefined
225
+ ...(platformImpressionId === undefined
221
226
  ? {}
222
- : { platformImpressionId: reward.ledgerEntryId }),
227
+ : { platformImpressionId }),
223
228
  idempotencyKey: rewardInput.idempotencyKey,
224
- completedAt: now(),
229
+ completedAt: requestNow(),
225
230
  ...(reward.evidence === undefined ? {} : { evidence: reward.evidence }),
226
231
  };
227
232
  progress.serverRequested();
228
233
  const claim = await input.backend.adRewards.claimAdReward(claimRequest);
229
234
  progress.serverResult(claim.granted);
230
235
  const result = {
231
- status: claim.granted ? 'granted' : 'rejected',
236
+ status: claim.granted ? 'granted' : (claim.disposition ?? 'rejected'),
232
237
  reward,
233
238
  claim,
234
239
  ...(claim.ledgerEntryId === undefined ? {} : { ledgerEntryId: claim.ledgerEntryId }),
235
240
  };
236
241
  await analytics.track({
237
- name: claim.granted ? 'rewarded_ad_granted' : 'rewarded_ad_rejected',
242
+ name: claim.granted ? 'rewarded_ad_granted' : claim.disposition === 'pending' ? 'rewarded_ad_pending' : 'rewarded_ad_rejected',
238
243
  properties: {
239
244
  placementId: rewardInput.placementId,
240
245
  status: result.status,
@@ -402,12 +407,6 @@ function purchaseRejectionReason(purchase) {
402
407
  }
403
408
  return undefined;
404
409
  }
405
- function isAuthoritativeMicrosoftStoreCompletion(target, purchase) {
406
- return target === 'microsoft-store'
407
- && purchase.status === 'completed'
408
- && purchase.transactionId !== undefined
409
- && purchase.authoritativeGrant?.ledgerEntryId === purchase.transactionId;
410
- }
411
410
  function isGameServicesCommerceTarget(target) {
412
411
  return target === 'microsoft-store'
413
412
  || target === 'android'
@@ -0,0 +1,11 @@
1
+ import type { GooglePlayProductPurchaseClient } from './google-play-purchase.js';
2
+ export interface GooglePlayPublisherClientOptions {
3
+ /** Supplies an androidpublisher-scoped OAuth access token for each request. */
4
+ readonly getAccessToken: (signal: AbortSignal) => Promise<string> | string;
5
+ readonly fetch?: typeof fetch;
6
+ }
7
+ /**
8
+ * Google Play purchase verification and post-ledger finalization transport.
9
+ * The caller owns OAuth credential rotation and must never expose this client to the game.
10
+ */
11
+ export declare function createGooglePlayPublisherClient(options: GooglePlayPublisherClientOptions): GooglePlayProductPurchaseClient;
@@ -0,0 +1,113 @@
1
+ const publisherOrigin = 'https://androidpublisher.googleapis.com';
2
+ /**
3
+ * Google Play purchase verification and post-ledger finalization transport.
4
+ * The caller owns OAuth credential rotation and must never expose this client to the game.
5
+ */
6
+ export function createGooglePlayPublisherClient(options) {
7
+ const requestFetch = options.fetch ?? fetch;
8
+ async function request(method, path, signal) {
9
+ const accessToken = await options.getAccessToken(signal);
10
+ if (accessToken.length === 0 || /[\s\x00-\x1f\x7f]/u.test(accessToken)) {
11
+ throw new TypeError('Google Play Publisher access token is invalid.');
12
+ }
13
+ let response;
14
+ try {
15
+ response = await requestFetch(`${publisherOrigin}${path}`, {
16
+ method,
17
+ headers: { Authorization: `Bearer ${accessToken}` },
18
+ redirect: 'error',
19
+ signal,
20
+ });
21
+ }
22
+ catch {
23
+ if (signal.aborted) {
24
+ throw new DOMException('Google Play Publisher request was cancelled.', 'AbortError');
25
+ }
26
+ // Fetch errors can contain the request URL (including the purchase token).
27
+ throw new Error('Google Play Publisher request failed.');
28
+ }
29
+ if (!response.ok) {
30
+ await discardBody(response);
31
+ // Do not forward an error body or URL: both can contain purchase credentials.
32
+ throw new Error(`Google Play Publisher request failed (HTTP ${response.status}).`);
33
+ }
34
+ return response;
35
+ }
36
+ return {
37
+ async getProductPurchaseV2({ packageName, purchaseToken, signal }) {
38
+ const path = `${purchasePath(packageName)}/productsv2/tokens/${pathPart(purchaseToken)}`;
39
+ const response = await request('GET', path, signal);
40
+ try {
41
+ return await readBoundedJson(response);
42
+ }
43
+ catch {
44
+ if (signal.aborted) {
45
+ throw new DOMException('Google Play Publisher request was cancelled.', 'AbortError');
46
+ }
47
+ throw new Error('Google Play Publisher returned invalid purchase JSON.');
48
+ }
49
+ },
50
+ async acknowledgeProductPurchase({ packageName, productId, purchaseToken, signal }) {
51
+ const path = `${purchasePath(packageName)}/products/${pathPart(productId)}`
52
+ + `/tokens/${pathPart(purchaseToken)}:acknowledge`;
53
+ await discardBody(await request('POST', path, signal));
54
+ },
55
+ async consumeProductPurchase({ packageName, productId, purchaseToken, signal }) {
56
+ const path = `${purchasePath(packageName)}/products/${pathPart(productId)}`
57
+ + `/tokens/${pathPart(purchaseToken)}:consume`;
58
+ await discardBody(await request('POST', path, signal));
59
+ },
60
+ };
61
+ }
62
+ function purchasePath(packageName) {
63
+ return `/androidpublisher/v3/applications/${pathPart(packageName)}/purchases`;
64
+ }
65
+ function pathPart(value) {
66
+ if (value.length === 0
67
+ || value === '.'
68
+ || value === '..'
69
+ || value.trim() !== value
70
+ || /[\x00-\x1f\x7f]/u.test(value)) {
71
+ throw new TypeError('Google Play Publisher path parameter is invalid.');
72
+ }
73
+ return encodeURIComponent(value);
74
+ }
75
+ async function discardBody(response) {
76
+ await response.body?.cancel().catch(() => undefined);
77
+ }
78
+ async function readBoundedJson(response) {
79
+ const body = response.body;
80
+ if (body === null) {
81
+ throw new Error('Missing Google Play Publisher response body.');
82
+ }
83
+ const reader = body.getReader();
84
+ const chunks = [];
85
+ let size = 0;
86
+ try {
87
+ while (true) {
88
+ const next = await reader.read();
89
+ if (next.done) {
90
+ break;
91
+ }
92
+ size += next.value.byteLength;
93
+ if (size > 1024 * 1024) {
94
+ throw new Error('Google Play Publisher response exceeds maximum size.');
95
+ }
96
+ chunks.push(next.value);
97
+ }
98
+ }
99
+ catch (error) {
100
+ await reader.cancel().catch(() => undefined);
101
+ throw error;
102
+ }
103
+ finally {
104
+ reader.releaseLock();
105
+ }
106
+ const bytes = new Uint8Array(size);
107
+ let offset = 0;
108
+ for (const chunk of chunks) {
109
+ bytes.set(chunk, offset);
110
+ offset += chunk.byteLength;
111
+ }
112
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
113
+ }
@@ -38,3 +38,5 @@ export interface GooglePlayProductPurchaseBoundary extends GameServicesPurchaseG
38
38
  verifyPurchase(input: VerifyPurchaseEvidenceInput): ReturnType<GameServicesEvidenceVerifier['verifyPurchase']>;
39
39
  }
40
40
  export declare function createGooglePlayProductPurchaseBoundary(input: CreateGooglePlayProductPurchaseBoundaryInput): GooglePlayProductPurchaseBoundary;
41
+ /** A non-secret transaction ID for purchases whose Google order ID is absent or delayed. */
42
+ export declare function createGooglePlayTokenTransactionId(purchaseToken: string): Promise<string>;
@@ -45,10 +45,7 @@ export function createGooglePlayProductPurchaseBoundary(input) {
45
45
  purchaseToken,
46
46
  expectedProductId: verificationInput.platformProductId,
47
47
  expectedProductType: verificationInput.product.type,
48
- orderMatch: {
49
- mode: 'if-present',
50
- orderId: verificationInput.request.platformTransactionId,
51
- },
48
+ orderMatch: createGooglePlayObservedOrderMatch(verificationInput.request.platformTransactionId, await createGooglePlayTokenTransactionId(purchaseToken)),
52
49
  accountBinding,
53
50
  allowConsumed: false,
54
51
  signal: verificationInput.signal,
@@ -146,7 +143,7 @@ async function finalizeGooglePlayPurchase(input, packageName, purchaseToken, pro
146
143
  purchaseToken,
147
144
  expectedProductId: finalizationInput.platformProductId,
148
145
  expectedProductType: productType,
149
- orderMatch: createGooglePlayFinalizationOrderMatch(verifiedContext, finalizationInput.request.platformTransactionId),
146
+ orderMatch: createGooglePlayFinalizationOrderMatch(verifiedContext, finalizationInput.request.platformTransactionId, await createGooglePlayTokenTransactionId(purchaseToken)),
150
147
  accountBinding,
151
148
  allowConsumed: true,
152
149
  signal: finalizationInput.signal,
@@ -369,15 +366,32 @@ async function inspectGooglePlayPurchase(input) {
369
366
  },
370
367
  };
371
368
  }
372
- function createGooglePlayFinalizationOrderMatch(verifiedContext, clientOrderId) {
369
+ function createGooglePlayFinalizationOrderMatch(verifiedContext, clientOrderId, tokenTransactionId) {
373
370
  if (verifiedContext === undefined) {
374
- return { mode: 'if-present', orderId: clientOrderId };
371
+ return createGooglePlayObservedOrderMatch(clientOrderId, tokenTransactionId);
375
372
  }
376
373
  if (verifiedContext.orderId === undefined) {
377
374
  return { mode: 'token-only' };
378
375
  }
379
376
  return { mode: 'exact', orderId: verifiedContext.orderId };
380
377
  }
378
+ function createGooglePlayObservedOrderMatch(observedTransactionId, tokenTransactionId) {
379
+ return observedTransactionId === tokenTransactionId
380
+ ? { mode: 'token-only' }
381
+ : { mode: 'if-present', orderId: observedTransactionId };
382
+ }
383
+ /** A non-secret transaction ID for purchases whose Google order ID is absent or delayed. */
384
+ export async function createGooglePlayTokenTransactionId(purchaseToken) {
385
+ if (readOptionalIdentifier(purchaseToken, 4096) === undefined) {
386
+ throw new TypeError('Google Play purchase token is invalid.');
387
+ }
388
+ const subtle = globalThis.crypto?.subtle;
389
+ if (subtle === undefined) {
390
+ throw new Error('Web Crypto is required to hash Google Play purchase tokens.');
391
+ }
392
+ const digest = new Uint8Array(await subtle.digest('SHA-256', new TextEncoder().encode(purchaseToken)));
393
+ return `play-token-sha256:${[...digest].map((value) => value.toString(16).padStart(2, '0')).join('')}`;
394
+ }
381
395
  function readGooglePlayVerifiedContext(payload, packageName, productId, purchaseTokenDigest) {
382
396
  if (payload?.googlePlayPackageName !== packageName
383
397
  || payload.googlePlayProductId !== productId
package/dist/index.d.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  export * from './admob-ssv.js';
2
2
  export * from './app-store-verifier.js';
3
+ export * from './app-store-recovery.js';
3
4
  export * from './apps-in-toss-evidence-verification.js';
4
5
  export * from './apps-in-toss-partner-api.js';
5
6
  export * from './client.js';
7
+ export * from './admob-client-reward.js';
6
8
  export * from './contract.js';
7
9
  export * from './evidence-verification.js';
8
10
  export * from './guest-session.js';
9
11
  export * from './google-play-purchase.js';
12
+ export * from './google-play-publisher-client.js';
10
13
  export * from './microsoft-store-identity.js';
11
14
  export * from './microsoft-store-purchase.js';
15
+ export * from './monetization-recovery.js';
12
16
  export * from './notification-delivery.js';
13
17
  export * from './platform-order.js';
14
18
  export * from './progress-link.js';
package/dist/index.js CHANGED
@@ -1,14 +1,18 @@
1
1
  export * from './admob-ssv.js';
2
2
  export * from './app-store-verifier.js';
3
+ export * from './app-store-recovery.js';
3
4
  export * from './apps-in-toss-evidence-verification.js';
4
5
  export * from './apps-in-toss-partner-api.js';
5
6
  export * from './client.js';
7
+ export * from './admob-client-reward.js';
6
8
  export * from './contract.js';
7
9
  export * from './evidence-verification.js';
8
10
  export * from './guest-session.js';
9
11
  export * from './google-play-purchase.js';
12
+ export * from './google-play-publisher-client.js';
10
13
  export * from './microsoft-store-identity.js';
11
14
  export * from './microsoft-store-purchase.js';
15
+ export * from './monetization-recovery.js';
12
16
  export * from './notification-delivery.js';
13
17
  export * from './platform-order.js';
14
18
  export * from './progress-link.js';
@@ -0,0 +1,63 @@
1
+ import type { PurchaseResult, RewardedAdResult } from '@mpgd/platform';
2
+ import { type CreateGameServicesClientInput } from './client.js';
3
+ import type { GameServicesOperationClient, GameServicesPurchaseInput, GameServicesPurchaseResult, GameServicesRewardedAdInput, GameServicesRewardedAdResult } from './operations.js';
4
+ import type { ClaimAdRewardRequest, ClaimAdRewardResponse, VerifyPurchaseRequest, VerifyPurchaseResponse } from './types.js';
5
+ interface OperationIdentity {
6
+ /** The store must enforce uniqueness across users, not just within one account. */
7
+ readonly key: string;
8
+ readonly playerId: string;
9
+ readonly target: string;
10
+ readonly deploymentTarget: string;
11
+ readonly revision: number;
12
+ /** Fixed when a platform result is first journaled, before backend dispatch. */
13
+ readonly platformCompletedAt?: string;
14
+ }
15
+ export type MonetizationOperationRecord = (OperationIdentity & {
16
+ readonly kind: 'purchase';
17
+ readonly input: GameServicesPurchaseInput;
18
+ readonly platform?: PurchaseResult;
19
+ readonly request?: VerifyPurchaseRequest;
20
+ readonly response?: VerifyPurchaseResponse;
21
+ readonly result?: GameServicesPurchaseResult;
22
+ }) | (OperationIdentity & {
23
+ readonly kind: 'rewarded-ad';
24
+ readonly input: GameServicesRewardedAdInput;
25
+ readonly platform?: RewardedAdResult;
26
+ readonly request?: ClaimAdRewardRequest;
27
+ readonly response?: ClaimAdRewardResponse;
28
+ readonly result?: GameServicesRewardedAdResult;
29
+ });
30
+ /** Games must supply a durable, atomic implementation; an in-memory map is not recovery. */
31
+ export interface MonetizationOperationStore {
32
+ /** Atomically insert or return the existing operation for this global key. */
33
+ reserve(record: MonetizationOperationRecord): Promise<{
34
+ readonly created: boolean;
35
+ readonly record: MonetizationOperationRecord;
36
+ }>;
37
+ read(key: string): Promise<MonetizationOperationRecord | undefined>;
38
+ /** Compare-and-swap; reject stale revisions and changes to immutable identity. */
39
+ replace(expectedRevision: number, record: MonetizationOperationRecord): Promise<void>;
40
+ /** Return only unresolved operations; keep terminal records addressable by read for deduplication. */
41
+ listRecoverable(playerId: string): Promise<readonly MonetizationOperationRecord[]>;
42
+ }
43
+ export interface CreateRecoverableMonetizationClientInput extends CreateGameServicesClientInput {
44
+ readonly operationStore: MonetizationOperationStore;
45
+ }
46
+ export interface MonetizationOperationSummary {
47
+ readonly kind: MonetizationOperationRecord['kind'];
48
+ readonly idempotencyKey: string;
49
+ readonly status: 'pending' | 'granted' | 'rejected' | 'cancelled' | 'failed' | 'skipped' | 'unavailable' | 'action-required';
50
+ readonly finalizationPending: boolean;
51
+ }
52
+ export interface RecoverableMonetizationClient extends GameServicesOperationClient {
53
+ recoverPurchaseResult(idempotencyKey: string, platform: PurchaseResult): Promise<GameServicesPurchaseResult>;
54
+ recoverRewardResult(idempotencyKey: string, platform: RewardedAdResult): Promise<GameServicesRewardedAdResult>;
55
+ reconcile(): Promise<readonly MonetizationOperationSummary[]>;
56
+ }
57
+ /**
58
+ * Client-side recovery coordinates UI callbacks and authoritative backend calls.
59
+ * It never grants a wallet locally. Journal evidence can contain receipts/tokens,
60
+ * so the game's durable store must encrypt and access-control its records.
61
+ */
62
+ export declare function createRecoverableMonetizationClient(input: CreateRecoverableMonetizationClientInput): RecoverableMonetizationClient;
63
+ export {};