@mpgd/game-services 0.16.0 → 0.17.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/dist/admob-client-reward.d.ts +3 -0
- package/dist/admob-client-reward.js +21 -0
- package/dist/admob-ssv.d.ts +2 -0
- package/dist/admob-ssv.js +2 -0
- package/dist/app-store-recovery.d.ts +38 -0
- package/dist/app-store-recovery.js +271 -0
- package/dist/app-store-verifier.d.ts +7 -0
- package/dist/app-store-verifier.js +41 -3
- package/dist/authoritative-purchase.d.ts +9 -0
- package/dist/authoritative-purchase.js +10 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +14 -16
- package/dist/google-play-publisher-client.d.ts +11 -0
- package/dist/google-play-publisher-client.js +113 -0
- package/dist/google-play-purchase.d.ts +2 -0
- package/dist/google-play-purchase.js +21 -7
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/monetization-recovery.d.ts +63 -0
- package/dist/monetization-recovery.js +632 -0
- package/dist/operation-progress.d.ts +7 -1
- package/dist/operation-progress.js +23 -0
- package/dist/operation-results.d.ts +4 -0
- package/dist/operations.d.ts +1 -1
- package/dist/runtime.d.ts +4 -0
- package/dist/runtime.js +29 -14
- package/dist/server.js +94 -60
- package/dist/types.js +13 -0
- package/package.json +19 -6
|
@@ -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
|
|
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,5 +1,6 @@
|
|
|
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,8 +8,10 @@ export * from './contract.js';
|
|
|
7
8
|
export * from './evidence-verification.js';
|
|
8
9
|
export * from './guest-session.js';
|
|
9
10
|
export * from './google-play-purchase.js';
|
|
11
|
+
export * from './google-play-publisher-client.js';
|
|
10
12
|
export * from './microsoft-store-identity.js';
|
|
11
13
|
export * from './microsoft-store-purchase.js';
|
|
14
|
+
export * from './monetization-recovery.js';
|
|
12
15
|
export * from './notification-delivery.js';
|
|
13
16
|
export * from './platform-order.js';
|
|
14
17
|
export * from './progress-link.js';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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,8 +8,10 @@ export * from './contract.js';
|
|
|
7
8
|
export * from './evidence-verification.js';
|
|
8
9
|
export * from './guest-session.js';
|
|
9
10
|
export * from './google-play-purchase.js';
|
|
11
|
+
export * from './google-play-publisher-client.js';
|
|
10
12
|
export * from './microsoft-store-identity.js';
|
|
11
13
|
export * from './microsoft-store-purchase.js';
|
|
14
|
+
export * from './monetization-recovery.js';
|
|
12
15
|
export * from './notification-delivery.js';
|
|
13
16
|
export * from './platform-order.js';
|
|
14
17
|
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 {};
|