@mpgd/game-services 0.18.0 → 0.19.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/ad-reward-verifier-registry.js +1 -0
- package/dist/client.js +2 -1
- package/dist/default-client-reward-evidence.js +2 -0
- package/dist/oneplay-client-reward.d.ts +4 -0
- package/dist/oneplay-client-reward.js +17 -0
- package/dist/oneplay-pns.d.ts +42 -0
- package/dist/oneplay-pns.js +89 -0
- package/dist/oneplay-purchase-client.d.ts +31 -0
- package/dist/oneplay-purchase-client.js +155 -0
- package/dist/oneplay-purchase.d.ts +71 -0
- package/dist/oneplay-purchase.js +163 -0
- package/dist/oneplay-reward.d.ts +105 -0
- package/dist/oneplay-reward.js +185 -0
- package/dist/runtime.js +2 -1
- package/dist/server.js +1 -0
- package/dist/types.d.ts +2 -2
- package/dist/types.js +4 -3
- package/package.json +25 -5
package/dist/client.js
CHANGED
|
@@ -399,7 +399,8 @@ function isGameServicesCommerceTarget(target) {
|
|
|
399
399
|
|| target === 'android'
|
|
400
400
|
|| target === 'ios'
|
|
401
401
|
|| target === 'ait'
|
|
402
|
-
|| target === 'verse8'
|
|
402
|
+
|| target === 'verse8'
|
|
403
|
+
|| target === 'oneplay';
|
|
403
404
|
}
|
|
404
405
|
function isGameServicesLeaderboardTarget(target) {
|
|
405
406
|
return target === 'browser'
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { admobClientRewardEvidenceSchema, isAdMobClientRewardEvidence, isVerse8ClientRewardEvidence, verse8ClientRewardEvidenceSchema, } from './admob-client-reward.js';
|
|
2
2
|
import { createClientRewardEvidenceRegistry, } from './client-reward-evidence.js';
|
|
3
|
+
import { onePlayClientRewardEvidenceNormalizer } from './oneplay-client-reward.js';
|
|
3
4
|
/** Registered decoder data. Common claim and recovery code never inspects these provider payloads. */
|
|
4
5
|
export const defaultClientRewardEvidenceNormalizers = Object.freeze([
|
|
6
|
+
onePlayClientRewardEvidenceNormalizer,
|
|
5
7
|
Object.freeze({
|
|
6
8
|
schema: admobClientRewardEvidenceSchema,
|
|
7
9
|
normalize: (reward) => isAdMobClientRewardEvidence(reward) ? {} : undefined,
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ClientRewardEvidenceNormalizer } from './client-reward-evidence.js';
|
|
2
|
+
export declare const onePlayClientRewardEvidenceSchema = "oneplay.rewarded-ad.callback.v1";
|
|
3
|
+
/** Correlation only: a matching SDK envelope can request verification, never authorize a grant. */
|
|
4
|
+
export declare const onePlayClientRewardEvidenceNormalizer: ClientRewardEvidenceNormalizer;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const onePlayClientRewardEvidenceSchema = 'oneplay.rewarded-ad.callback.v1';
|
|
2
|
+
/** Correlation only: a matching SDK envelope can request verification, never authorize a grant. */
|
|
3
|
+
export const onePlayClientRewardEvidenceNormalizer = Object.freeze({
|
|
4
|
+
schema: onePlayClientRewardEvidenceSchema,
|
|
5
|
+
normalize(reward) {
|
|
6
|
+
const payload = reward.evidence?.payload;
|
|
7
|
+
if (reward.rewardGranted !== false || reward.ledgerEntryId !== undefined || payload?.rewardGranted !== false
|
|
8
|
+
|| payload.event !== 'rewarded' && payload.event !== 'outcome-unknown'
|
|
9
|
+
|| !identifier(payload.requestId) || !identifier(payload.placementId) || !identifier(payload.platformPlacementId)) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
return { platformImpressionId: payload.requestId };
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
function identifier(value) {
|
|
16
|
+
return typeof value === 'string' && value.trim() === value && value.length > 0 && value.length <= 256;
|
|
17
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { GameServicesBackendApi } from './client.js';
|
|
2
|
+
import type { VerifyPurchaseResponse } from './types.js';
|
|
3
|
+
import { type OnePlayInspectedPurchase, type OnePlayPurchaseBoundary } from './oneplay-purchase.js';
|
|
4
|
+
import { type OnePlayPurchaseClient } from './oneplay-purchase-client.js';
|
|
5
|
+
export interface OnePlayPnsNotification {
|
|
6
|
+
readonly clientId: string;
|
|
7
|
+
readonly productId: string;
|
|
8
|
+
readonly purchaseToken: string;
|
|
9
|
+
readonly purchaseId: string;
|
|
10
|
+
readonly developerPayload: string;
|
|
11
|
+
readonly purchaseState: 'COMPLETED' | 'CANCELED';
|
|
12
|
+
}
|
|
13
|
+
/** RSA SHA512 over compact UTF-8 JSON after removing signature, following the official PNS example. */
|
|
14
|
+
export declare function verifyOnePlayPns(input: {
|
|
15
|
+
readonly rawBody: Uint8Array<ArrayBuffer>;
|
|
16
|
+
/** ONEconsole license public key, base64 SPKI or PUBLIC KEY PEM. */
|
|
17
|
+
readonly publicKey: string;
|
|
18
|
+
readonly client: Pick<OnePlayPurchaseClient, 'clientId' | 'environment' | 'marketCode'>;
|
|
19
|
+
readonly subtle?: SubtleCrypto;
|
|
20
|
+
}): Promise<OnePlayPnsNotification | undefined>;
|
|
21
|
+
export type OnePlayPnsResult = {
|
|
22
|
+
readonly status: 'processed';
|
|
23
|
+
readonly state: 'completed';
|
|
24
|
+
readonly grant: VerifyPurchaseResponse;
|
|
25
|
+
} | {
|
|
26
|
+
readonly status: 'processed';
|
|
27
|
+
readonly state: 'cancelled';
|
|
28
|
+
readonly verificationId: string;
|
|
29
|
+
} | {
|
|
30
|
+
readonly status: 'pending' | 'rejected';
|
|
31
|
+
readonly reason: string;
|
|
32
|
+
};
|
|
33
|
+
export declare function createOnePlayPnsReceiver(input: {
|
|
34
|
+
readonly publicKey: string;
|
|
35
|
+
readonly client: Pick<OnePlayPurchaseClient, 'clientId' | 'environment' | 'marketCode'>;
|
|
36
|
+
readonly boundary: OnePlayPurchaseBoundary;
|
|
37
|
+
readonly backend: Pick<GameServicesBackendApi, 'purchases'>;
|
|
38
|
+
/** Durably fence and revoke by verificationId; the ledger grant transaction must honor that fence atomically. Never revoke by client identity alone. */
|
|
39
|
+
readonly onCancelled: (purchase: OnePlayInspectedPurchase) => Promise<void>;
|
|
40
|
+
}): {
|
|
41
|
+
receive(rawBody: Uint8Array<ArrayBuffer>, suppliedSignal?: AbortSignal): Promise<OnePlayPnsResult>;
|
|
42
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { onePlayManagedPurchaseEvidenceSchema, } from './oneplay-purchase.js';
|
|
2
|
+
import { onePlayIsIdentifier, onePlayRecord, } from './oneplay-purchase-client.js';
|
|
3
|
+
/** RSA SHA512 over compact UTF-8 JSON after removing signature, following the official PNS example. */
|
|
4
|
+
export async function verifyOnePlayPns(input) {
|
|
5
|
+
if (input.rawBody.length > 65_536 || input.publicKey.length > 8192) {
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
8
|
+
try {
|
|
9
|
+
const body = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(input.rawBody));
|
|
10
|
+
if (!onePlayRecord(body) || typeof body.signature !== 'string' || body.signature.length > 2048
|
|
11
|
+
|| body.clientId !== input.client.clientId || body.environment !== input.client.environment || body.marketCode !== input.client.marketCode
|
|
12
|
+
|| body.msgVersion !== (input.client.environment === 'SANDBOX' ? '3.1.0D' : '3.1.0') || body.messageType !== 'SINGLE_PAYMENT_TRANSACTION'
|
|
13
|
+
|| body.purchaseState !== 'COMPLETED' && body.purchaseState !== 'CANCELED'
|
|
14
|
+
|| !onePlayIsIdentifier(body.productId) || !onePlayIsIdentifier(body.purchaseId) || !onePlayIsIdentifier(body.purchaseToken, 4096) || !onePlayIsIdentifier(body.developerPayload, 200)) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
const { signature, ...message } = body;
|
|
18
|
+
const keyBytes = base64(input.publicKey.replace(/-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\s/gu, ''));
|
|
19
|
+
const subtle = input.subtle ?? globalThis.crypto.subtle;
|
|
20
|
+
const key = await subtle.importKey('spki', keyBytes, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-512' }, false, ['verify']);
|
|
21
|
+
if (!await subtle.verify('RSASSA-PKCS1-v1_5', key, base64(signature), new TextEncoder().encode(JSON.stringify(message)))) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
clientId: body.clientId,
|
|
26
|
+
productId: body.productId,
|
|
27
|
+
purchaseId: body.purchaseId,
|
|
28
|
+
purchaseToken: body.purchaseToken,
|
|
29
|
+
developerPayload: body.developerPayload,
|
|
30
|
+
purchaseState: body.purchaseState,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function createOnePlayPnsReceiver(input) {
|
|
38
|
+
if (typeof input.onCancelled !== 'function') {
|
|
39
|
+
throw new TypeError('ONE play PNS requires an authoritative cancellation handler.');
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
async receive(rawBody, suppliedSignal) {
|
|
43
|
+
const signal = suppliedSignal ?? AbortSignal.timeout(10_000);
|
|
44
|
+
signal.throwIfAborted();
|
|
45
|
+
const notification = await verifyOnePlayPns({ rawBody, publicKey: input.publicKey, client: input.client });
|
|
46
|
+
signal.throwIfAborted();
|
|
47
|
+
if (notification === undefined) {
|
|
48
|
+
return { status: 'rejected', reason: 'ONEPLAY_PNS_INVALID' };
|
|
49
|
+
}
|
|
50
|
+
let purchase;
|
|
51
|
+
try {
|
|
52
|
+
purchase = await input.boundary.inspect({ productId: notification.productId, purchaseToken: notification.purchaseToken, signal });
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
signal.throwIfAborted();
|
|
56
|
+
return { status: 'pending', reason: 'ONEPLAY_PURCHASE_API_UNAVAILABLE' };
|
|
57
|
+
}
|
|
58
|
+
if (purchase === undefined || purchase.intent.clientId !== input.client.clientId || purchase.intent.environment !== input.client.environment
|
|
59
|
+
|| purchase.intent.marketCode !== input.client.marketCode || purchase.purchaseId !== notification.purchaseId || purchase.intent.developerPayload !== notification.developerPayload) {
|
|
60
|
+
return { status: 'rejected', reason: 'ONEPLAY_PNS_BINDING_INVALID' };
|
|
61
|
+
}
|
|
62
|
+
if ((purchase.purchaseState === 0 ? 'COMPLETED' : 'CANCELED') !== notification.purchaseState) {
|
|
63
|
+
return { status: 'pending', reason: 'ONEPLAY_PNS_STATE_CHANGED' };
|
|
64
|
+
}
|
|
65
|
+
if (purchase.purchaseState === 1) {
|
|
66
|
+
await input.onCancelled(purchase);
|
|
67
|
+
signal.throwIfAborted();
|
|
68
|
+
return { status: 'processed', state: 'cancelled', verificationId: purchase.verificationId };
|
|
69
|
+
}
|
|
70
|
+
const intent = purchase.intent;
|
|
71
|
+
const grant = await input.backend.purchases.verifyPurchase({ target: 'oneplay', ...(intent.deploymentTarget === 'oneplay' ? {} : { deploymentTarget: intent.deploymentTarget }),
|
|
72
|
+
playerId: intent.playerId, productId: intent.productId, platformTransactionId: purchase.purchaseId, idempotencyKey: intent.idempotencyKey,
|
|
73
|
+
purchasedAt: new Date(purchase.purchaseTime).toISOString(), evidence: { schema: onePlayManagedPurchaseEvidenceSchema, payload: {
|
|
74
|
+
purchaseToken: purchase.purchaseToken, purchaseId: purchase.purchaseId, productId: intent.platformProductId, developerPayload: intent.developerPayload,
|
|
75
|
+
} } });
|
|
76
|
+
signal.throwIfAborted();
|
|
77
|
+
if (!grant.verified || grant.finalization?.status !== 'completed') {
|
|
78
|
+
return { status: 'pending', reason: grant.reason ?? grant.finalization?.reason ?? (grant.verified && grant.finalization === undefined ? 'ONEPLAY_FINALIZATION_REQUIRED' : 'ONEPLAY_LEDGER_PENDING') };
|
|
79
|
+
}
|
|
80
|
+
return { status: 'processed', state: 'completed', grant };
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function base64(value) {
|
|
85
|
+
if (!value || value.length % 4 !== 0 || !/^[A-Za-z\d+/]+={0,2}$/u.test(value)) {
|
|
86
|
+
throw new TypeError('Invalid ONE play signature encoding.');
|
|
87
|
+
}
|
|
88
|
+
return Uint8Array.from(atob(value), (character) => character.charCodeAt(0));
|
|
89
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** ONE play / ONE store API V7 transport. Credentials and this client belong on the server. */
|
|
2
|
+
export type OnePlayPurchaseEnvironment = 'SANDBOX' | 'COMMERCIAL';
|
|
3
|
+
export type OnePlayMarketCode = 'MKT_ONE' | 'MKT_GLB';
|
|
4
|
+
export interface OnePlayPurchaseApiInput {
|
|
5
|
+
readonly productId: string;
|
|
6
|
+
readonly purchaseToken: string;
|
|
7
|
+
readonly signal: AbortSignal;
|
|
8
|
+
}
|
|
9
|
+
export interface OnePlayPurchaseClient {
|
|
10
|
+
readonly clientId: string;
|
|
11
|
+
readonly environment: OnePlayPurchaseEnvironment;
|
|
12
|
+
readonly marketCode: OnePlayMarketCode;
|
|
13
|
+
getPurchaseDetails(input: OnePlayPurchaseApiInput): Promise<unknown>;
|
|
14
|
+
consumePurchase(input: OnePlayPurchaseApiInput & {
|
|
15
|
+
readonly developerPayload: string;
|
|
16
|
+
}): Promise<void>;
|
|
17
|
+
acknowledgePurchase(input: OnePlayPurchaseApiInput & {
|
|
18
|
+
readonly developerPayload: string;
|
|
19
|
+
}): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
export declare function createOnePlayPurchaseClient(input: {
|
|
22
|
+
readonly clientId: string;
|
|
23
|
+
readonly clientSecret: string;
|
|
24
|
+
readonly environment: OnePlayPurchaseEnvironment;
|
|
25
|
+
readonly marketCode?: OnePlayMarketCode;
|
|
26
|
+
readonly fetch?: typeof fetch;
|
|
27
|
+
readonly now?: () => number;
|
|
28
|
+
}): OnePlayPurchaseClient;
|
|
29
|
+
export declare function onePlayRecord(value: unknown): value is Record<string, unknown>;
|
|
30
|
+
export declare function onePlayIsIdentifier(value: unknown, max?: number): value is string;
|
|
31
|
+
export declare function onePlayIdentifier(value: unknown, max?: number): string;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
export function createOnePlayPurchaseClient(input) {
|
|
2
|
+
const clientId = onePlayIdentifier(input.clientId, 128);
|
|
3
|
+
const clientSecret = onePlayIdentifier(input.clientSecret, 4096);
|
|
4
|
+
const environment = input.environment;
|
|
5
|
+
const marketCode = input.marketCode ?? 'MKT_ONE';
|
|
6
|
+
if (!['SANDBOX', 'COMMERCIAL'].includes(environment) || !['MKT_ONE', 'MKT_GLB'].includes(marketCode)) {
|
|
7
|
+
throw new TypeError('ONE play purchase environment is invalid.');
|
|
8
|
+
}
|
|
9
|
+
const origin = environment === 'SANDBOX'
|
|
10
|
+
? 'https://sbpp.onestore.net'
|
|
11
|
+
: 'https://iap-apis.onestore.net';
|
|
12
|
+
const requestFetch = input.fetch ?? globalThis.fetch;
|
|
13
|
+
const now = input.now ?? Date.now;
|
|
14
|
+
let cached;
|
|
15
|
+
async function jsonRequest(path, init, signal) {
|
|
16
|
+
signal.throwIfAborted();
|
|
17
|
+
try {
|
|
18
|
+
const response = await requestFetch(`${origin}${path}`, {
|
|
19
|
+
...init,
|
|
20
|
+
redirect: 'error',
|
|
21
|
+
signal,
|
|
22
|
+
});
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
if (response.status === 401) {
|
|
25
|
+
cached = undefined;
|
|
26
|
+
}
|
|
27
|
+
await response.body?.cancel().catch(() => undefined);
|
|
28
|
+
throw new Error('HTTP failure');
|
|
29
|
+
}
|
|
30
|
+
const value = await boundedJson(response);
|
|
31
|
+
signal.throwIfAborted();
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
signal.throwIfAborted();
|
|
36
|
+
// Transport errors and provider responses may contain URL tokens or client secrets.
|
|
37
|
+
throw new Error('ONE play purchase API request failed.');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function token(signal) {
|
|
41
|
+
if (cached !== undefined && cached.expiresAt > now() + 60_000) {
|
|
42
|
+
return cached.token;
|
|
43
|
+
}
|
|
44
|
+
// Each caller owns its abort signal. Never share an abortable OAuth promise between requests.
|
|
45
|
+
const raw = await jsonRequest('/v7/oauth/token', {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: {
|
|
48
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
49
|
+
'x-market-code': marketCode,
|
|
50
|
+
},
|
|
51
|
+
body: new URLSearchParams({
|
|
52
|
+
grant_type: 'client_credentials',
|
|
53
|
+
client_id: clientId,
|
|
54
|
+
client_secret: clientSecret,
|
|
55
|
+
}),
|
|
56
|
+
}, signal);
|
|
57
|
+
if (!onePlayRecord(raw) || raw.client_id !== clientId || typeof raw.token_type !== 'string' || raw.token_type.toLowerCase() !== 'bearer'
|
|
58
|
+
|| !onePlayIsIdentifier(raw.access_token, 4096) || /\s/u.test(raw.access_token) || typeof raw.expires_in !== 'number' || !Number.isSafeInteger(raw.expires_in) || raw.expires_in <= 0 || raw.expires_in > 86_400) {
|
|
59
|
+
throw new Error('ONE play OAuth response is invalid.');
|
|
60
|
+
}
|
|
61
|
+
cached = { token: raw.access_token, expiresAt: now() + raw.expires_in * 1000 };
|
|
62
|
+
return cached.token;
|
|
63
|
+
}
|
|
64
|
+
async function request(operation, request) {
|
|
65
|
+
const signal = AbortSignal.any([request.signal, AbortSignal.timeout(10_000)]);
|
|
66
|
+
const type = operation === 'acknowledge' ? 'all' : 'inapp';
|
|
67
|
+
const path = `/v7/apps/${pathPart(clientId)}/purchases/${type}/products/${pathPart(request.productId)}/${pathPart(request.purchaseToken)}${operation === 'get' ? '' : `/${operation}`}`;
|
|
68
|
+
const developerPayload = operation === 'get'
|
|
69
|
+
? undefined
|
|
70
|
+
: onePlayIdentifier(request.developerPayload, 200);
|
|
71
|
+
if (developerPayload !== undefined && new TextEncoder().encode(developerPayload).length > 200) {
|
|
72
|
+
throw new TypeError('ONE play developer payload is too long.');
|
|
73
|
+
}
|
|
74
|
+
const body = developerPayload === undefined ? undefined : JSON.stringify({ developerPayload });
|
|
75
|
+
const result = await jsonRequest(path, {
|
|
76
|
+
method: operation === 'get' ? 'GET' : 'POST',
|
|
77
|
+
headers: {
|
|
78
|
+
authorization: `Bearer ${await token(signal)}`,
|
|
79
|
+
'content-type': 'application/json',
|
|
80
|
+
'x-market-code': marketCode,
|
|
81
|
+
},
|
|
82
|
+
...(body === undefined ? {} : { body }),
|
|
83
|
+
}, signal);
|
|
84
|
+
if (operation !== 'get' && (!onePlayRecord(result) || !onePlayRecord(result.result) || result.result.code !== 'Success')) {
|
|
85
|
+
throw new Error('ONE play purchase finalization was not confirmed.');
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
clientId,
|
|
91
|
+
environment,
|
|
92
|
+
marketCode,
|
|
93
|
+
getPurchaseDetails: (requestInput) => request('get', requestInput),
|
|
94
|
+
async consumePurchase(requestInput) {
|
|
95
|
+
await request('consume', requestInput);
|
|
96
|
+
},
|
|
97
|
+
async acknowledgePurchase(requestInput) {
|
|
98
|
+
await request('acknowledge', requestInput);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
export function onePlayRecord(value) {
|
|
103
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
104
|
+
}
|
|
105
|
+
export function onePlayIsIdentifier(value, max = 256) {
|
|
106
|
+
return typeof value === 'string' && value.length > 0 && value.length <= max && value.trim() === value && !/[\u0000-\u001f\u007f]/u.test(value);
|
|
107
|
+
}
|
|
108
|
+
export function onePlayIdentifier(value, max = 256) {
|
|
109
|
+
if (!onePlayIsIdentifier(value, max)) {
|
|
110
|
+
throw new TypeError('ONE play identifier is invalid.');
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
function pathPart(value) {
|
|
115
|
+
onePlayIdentifier(value, 4096);
|
|
116
|
+
if (value === '.' || value === '..') {
|
|
117
|
+
throw new TypeError('ONE play path parameter is invalid.');
|
|
118
|
+
}
|
|
119
|
+
return encodeURIComponent(value);
|
|
120
|
+
}
|
|
121
|
+
async function boundedJson(response) {
|
|
122
|
+
if (response.body === null) {
|
|
123
|
+
throw new Error('Missing ONE play response.');
|
|
124
|
+
}
|
|
125
|
+
const reader = response.body.getReader();
|
|
126
|
+
const chunks = [];
|
|
127
|
+
let size = 0;
|
|
128
|
+
try {
|
|
129
|
+
while (true) {
|
|
130
|
+
const chunk = await reader.read();
|
|
131
|
+
if (chunk.done) {
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
size += chunk.value.length;
|
|
135
|
+
if (size > 65_536) {
|
|
136
|
+
throw new Error('ONE play response exceeds its limit.');
|
|
137
|
+
}
|
|
138
|
+
chunks.push(chunk.value);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
await reader.cancel().catch(() => undefined);
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
reader.releaseLock();
|
|
147
|
+
}
|
|
148
|
+
const bytes = new Uint8Array(size);
|
|
149
|
+
let offset = 0;
|
|
150
|
+
for (const chunk of chunks) {
|
|
151
|
+
bytes.set(chunk, offset);
|
|
152
|
+
offset += chunk.length;
|
|
153
|
+
}
|
|
154
|
+
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
155
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type ProductCatalog } from '@mpgd/catalog';
|
|
2
|
+
import type { EvidenceVerificationDecision, GameServicesPurchaseGrantFinalizer, VerifyPurchaseEvidenceInput } from './evidence-verification.js';
|
|
3
|
+
import { type OnePlayMarketCode, type OnePlayPurchaseClient, type OnePlayPurchaseEnvironment } from './oneplay-purchase-client.js';
|
|
4
|
+
export declare const onePlayManagedPurchaseEvidenceSchema = "oneplay.managed-purchase.v1";
|
|
5
|
+
export interface OnePlayCheckoutIntent {
|
|
6
|
+
readonly clientId: string;
|
|
7
|
+
readonly environment: OnePlayPurchaseEnvironment;
|
|
8
|
+
readonly marketCode: OnePlayMarketCode;
|
|
9
|
+
readonly deploymentTarget: string;
|
|
10
|
+
readonly playerId: string;
|
|
11
|
+
readonly productId: string;
|
|
12
|
+
readonly platformProductId: string;
|
|
13
|
+
readonly productType: 'consumable' | 'non_consumable';
|
|
14
|
+
readonly idempotencyKey: string;
|
|
15
|
+
readonly developerPayload: string;
|
|
16
|
+
readonly issuedAt: number;
|
|
17
|
+
readonly expiresAt: number;
|
|
18
|
+
}
|
|
19
|
+
/** Durable atomic uniqueness for claim identity and payload; keep records for purchases, PNS and refunds. */
|
|
20
|
+
export interface OnePlayCheckoutIntentStore {
|
|
21
|
+
/** Insert or return the row for client/environment/market/deployment/player/idempotency. */
|
|
22
|
+
issue(intent: OnePlayCheckoutIntent): Promise<OnePlayCheckoutIntent>;
|
|
23
|
+
findByPayload(input: {
|
|
24
|
+
readonly clientId: string;
|
|
25
|
+
readonly environment: OnePlayPurchaseEnvironment;
|
|
26
|
+
readonly marketCode: OnePlayMarketCode;
|
|
27
|
+
readonly developerPayload: string;
|
|
28
|
+
}): Promise<OnePlayCheckoutIntent | undefined>;
|
|
29
|
+
}
|
|
30
|
+
export declare function createOnePlayCheckoutIntentIssuer(input: {
|
|
31
|
+
readonly client: Pick<OnePlayPurchaseClient, 'clientId' | 'environment' | 'marketCode'>;
|
|
32
|
+
readonly catalog: ProductCatalog;
|
|
33
|
+
readonly store: OnePlayCheckoutIntentStore;
|
|
34
|
+
readonly deploymentTarget?: string;
|
|
35
|
+
readonly now?: () => number;
|
|
36
|
+
readonly createPayload?: () => string;
|
|
37
|
+
}): {
|
|
38
|
+
/** Derive playerId from authenticated server context, never SDK identity or request body. */
|
|
39
|
+
issue(request: {
|
|
40
|
+
readonly playerId: string;
|
|
41
|
+
readonly productId: string;
|
|
42
|
+
readonly idempotencyKey: string;
|
|
43
|
+
}): Promise<{
|
|
44
|
+
readonly developerPayload: string;
|
|
45
|
+
}>;
|
|
46
|
+
};
|
|
47
|
+
export interface OnePlayInspectedPurchase {
|
|
48
|
+
readonly intent: OnePlayCheckoutIntent;
|
|
49
|
+
readonly purchaseId: string;
|
|
50
|
+
readonly purchaseToken: string;
|
|
51
|
+
readonly purchaseTime: number;
|
|
52
|
+
readonly purchaseState: 0 | 1;
|
|
53
|
+
readonly consumptionState: 0 | 1;
|
|
54
|
+
readonly acknowledgeState: 0 | 1;
|
|
55
|
+
readonly verificationId: string;
|
|
56
|
+
}
|
|
57
|
+
export interface OnePlayPurchaseBoundary extends GameServicesPurchaseGrantFinalizer {
|
|
58
|
+
verifyPurchase(input: VerifyPurchaseEvidenceInput): Promise<EvidenceVerificationDecision>;
|
|
59
|
+
/** Independent API lookup for PNS / scheduled reconciliation. No notification can grant by itself. */
|
|
60
|
+
inspect(input: {
|
|
61
|
+
readonly productId: string;
|
|
62
|
+
readonly purchaseToken: string;
|
|
63
|
+
readonly signal: AbortSignal;
|
|
64
|
+
}): Promise<OnePlayInspectedPurchase | undefined>;
|
|
65
|
+
}
|
|
66
|
+
export declare function createOnePlayPurchaseBoundary(input: {
|
|
67
|
+
readonly client: OnePlayPurchaseClient;
|
|
68
|
+
readonly store: OnePlayCheckoutIntentStore;
|
|
69
|
+
readonly deploymentTarget?: string;
|
|
70
|
+
readonly now?: () => number;
|
|
71
|
+
}): OnePlayPurchaseBoundary;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { resolveProductPlatformId } from '@mpgd/catalog';
|
|
2
|
+
import { onePlayIdentifier, onePlayIsIdentifier, onePlayRecord, } from './oneplay-purchase-client.js';
|
|
3
|
+
export const onePlayManagedPurchaseEvidenceSchema = 'oneplay.managed-purchase.v1';
|
|
4
|
+
export function createOnePlayCheckoutIntentIssuer(input) {
|
|
5
|
+
const deploymentTarget = onePlayIdentifier(input.deploymentTarget ?? 'oneplay');
|
|
6
|
+
return {
|
|
7
|
+
/** Derive playerId from authenticated server context, never SDK identity or request body. */
|
|
8
|
+
async issue(request) {
|
|
9
|
+
const product = input.catalog.products.find((candidate) => candidate.id === request.productId);
|
|
10
|
+
const platformProductId = product === undefined ? undefined : resolveProductPlatformId(product, deploymentTarget);
|
|
11
|
+
if (product === undefined || product.type === 'subscription' || platformProductId === undefined) {
|
|
12
|
+
throw new TypeError('ONE play managed product is not configured.');
|
|
13
|
+
}
|
|
14
|
+
const issuedAt = (input.now ?? Date.now)();
|
|
15
|
+
if (!Number.isSafeInteger(issuedAt) || issuedAt < 0 || issuedAt > 8_639_999_913_600_000) {
|
|
16
|
+
throw new TypeError('ONE play checkout time is invalid.');
|
|
17
|
+
}
|
|
18
|
+
const intent = { clientId: onePlayIdentifier(input.client.clientId, 128), environment: input.client.environment, marketCode: input.client.marketCode,
|
|
19
|
+
deploymentTarget, playerId: onePlayIdentifier(request.playerId), productId: product.id, platformProductId, productType: product.type,
|
|
20
|
+
idempotencyKey: onePlayIdentifier(request.idempotencyKey), developerPayload: payload((input.createPayload ?? (() => globalThis.crypto.randomUUID()))()), issuedAt, expiresAt: issuedAt + 86_400_000 };
|
|
21
|
+
const stored = await input.store.issue(intent);
|
|
22
|
+
if (!validIntent(stored) || !sameIdentity(stored, intent) || stored.productId !== intent.productId || stored.platformProductId !== intent.platformProductId
|
|
23
|
+
|| stored.productType !== intent.productType || stored.expiresAt < issuedAt) {
|
|
24
|
+
throw new Error('ONE play checkout intent conflict.');
|
|
25
|
+
}
|
|
26
|
+
return { developerPayload: stored.developerPayload };
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function createOnePlayPurchaseBoundary(input) {
|
|
31
|
+
const { client, store } = input;
|
|
32
|
+
const deploymentTarget = onePlayIdentifier(input.deploymentTarget ?? 'oneplay');
|
|
33
|
+
const now = input.now ?? Date.now;
|
|
34
|
+
// Provider and application clocks may differ within the five-minute verification tolerance.
|
|
35
|
+
async function inspect(request) {
|
|
36
|
+
request.signal.throwIfAborted();
|
|
37
|
+
const purchaseToken = onePlayIdentifier(request.purchaseToken, 4096);
|
|
38
|
+
const raw = await client.getPurchaseDetails({ ...request, purchaseToken });
|
|
39
|
+
request.signal.throwIfAborted();
|
|
40
|
+
if (!onePlayRecord(raw) || !onePlayIsIdentifier(raw.purchaseId) || !onePlayIsIdentifier(raw.developerPayload, 200)
|
|
41
|
+
|| raw.quantity !== 1 || raw.purchaseState !== 0 && raw.purchaseState !== 1
|
|
42
|
+
|| raw.consumptionState !== 0 && raw.consumptionState !== 1 || raw.acknowledgeState !== 0 && raw.acknowledgeState !== 1
|
|
43
|
+
|| typeof raw.purchaseTime !== 'number' || !Number.isSafeInteger(raw.purchaseTime) || raw.purchaseTime < 0 || raw.purchaseTime > now() + 300_000) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
const intent = await store.findByPayload({
|
|
47
|
+
clientId: client.clientId,
|
|
48
|
+
environment: client.environment,
|
|
49
|
+
marketCode: client.marketCode,
|
|
50
|
+
developerPayload: raw.developerPayload,
|
|
51
|
+
});
|
|
52
|
+
request.signal.throwIfAborted();
|
|
53
|
+
if (intent === undefined || !validIntent(intent) || intent.clientId !== client.clientId || intent.environment !== client.environment || intent.marketCode !== client.marketCode
|
|
54
|
+
|| intent.deploymentTarget !== deploymentTarget || intent.platformProductId !== request.productId || intent.developerPayload !== raw.developerPayload
|
|
55
|
+
|| raw.purchaseTime < intent.issuedAt - 300_000 || raw.purchaseTime > intent.expiresAt + 300_000) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
const digest = new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify([client.clientId, client.environment, client.marketCode, purchaseToken]))));
|
|
59
|
+
request.signal.throwIfAborted();
|
|
60
|
+
const verificationId = `oneplay:purchase:${[...digest].map((byte) => byte.toString(16).padStart(2, '0')).join('')}`;
|
|
61
|
+
return {
|
|
62
|
+
intent,
|
|
63
|
+
purchaseId: raw.purchaseId,
|
|
64
|
+
purchaseTime: raw.purchaseTime,
|
|
65
|
+
purchaseState: raw.purchaseState,
|
|
66
|
+
consumptionState: raw.consumptionState,
|
|
67
|
+
acknowledgeState: raw.acknowledgeState,
|
|
68
|
+
purchaseToken,
|
|
69
|
+
verificationId,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
async function inspectClaim(request, allowCanonicalTransaction = false) {
|
|
73
|
+
const envelope = request.request.evidence;
|
|
74
|
+
if (request.request.target !== 'oneplay' || (request.request.deploymentTarget ?? request.request.target) !== deploymentTarget || request.product.type === 'subscription'
|
|
75
|
+
|| envelope?.schema !== onePlayManagedPurchaseEvidenceSchema || !onePlayIsIdentifier(envelope.payload.purchaseToken, 4096)) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const purchase = await inspect({
|
|
79
|
+
productId: request.platformProductId,
|
|
80
|
+
purchaseToken: envelope.payload.purchaseToken,
|
|
81
|
+
signal: request.signal,
|
|
82
|
+
});
|
|
83
|
+
if (purchase === undefined || purchase.intent.playerId !== request.request.playerId || purchase.intent.productId !== request.request.productId
|
|
84
|
+
|| purchase.intent.idempotencyKey !== request.request.idempotencyKey || purchase.intent.productType !== request.product.type
|
|
85
|
+
|| purchase.purchaseId !== request.request.platformTransactionId && (!allowCanonicalTransaction || purchase.verificationId !== request.request.platformTransactionId) || envelope.payload.purchaseId !== purchase.purchaseId
|
|
86
|
+
|| envelope.payload.productId !== request.platformProductId || envelope.payload.developerPayload !== purchase.intent.developerPayload) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
return purchase;
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
inspect,
|
|
93
|
+
supportsPurchaseGrant: (request) => request.request.target === 'oneplay' && request.product.type !== 'subscription' && request.request.evidence?.schema === onePlayManagedPurchaseEvidenceSchema,
|
|
94
|
+
async verifyPurchase(request) {
|
|
95
|
+
try {
|
|
96
|
+
const purchase = await inspectClaim(request);
|
|
97
|
+
if (purchase === undefined) {
|
|
98
|
+
return { status: 'rejected', reason: 'ONEPLAY_PURCHASE_BINDING_INVALID' };
|
|
99
|
+
}
|
|
100
|
+
if (purchase.purchaseState !== 0) {
|
|
101
|
+
return { status: 'rejected', reason: 'ONEPLAY_PURCHASE_CANCELLED' };
|
|
102
|
+
}
|
|
103
|
+
if (purchase.consumptionState !== 0) {
|
|
104
|
+
return { status: 'rejected', reason: 'ONEPLAY_PURCHASE_ALREADY_CONSUMED' };
|
|
105
|
+
}
|
|
106
|
+
return { status: 'verified', verificationId: purchase.verificationId, platformEvidenceId: purchase.verificationId, verifiedAt: new Date(now()).toISOString(), payload: {
|
|
107
|
+
onePlayPurchaseId: purchase.purchaseId, onePlayDeveloperPayload: purchase.intent.developerPayload, onePlayVerificationId: purchase.verificationId,
|
|
108
|
+
onePlayClientId: client.clientId, onePlayEnvironment: client.environment, onePlayMarketCode: client.marketCode,
|
|
109
|
+
} };
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
request.signal.throwIfAborted();
|
|
113
|
+
return { status: 'pending', reason: 'ONEPLAY_PURCHASE_API_UNAVAILABLE' };
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
async finalizePurchaseGrant(request) {
|
|
117
|
+
const action = request.product.type === 'consumable' ? 'consume' : 'acknowledge';
|
|
118
|
+
try {
|
|
119
|
+
const purchase = await inspectClaim(request, true);
|
|
120
|
+
if (purchase === undefined || purchase.purchaseState !== 0 || purchase.verificationId !== request.evidenceVerificationId
|
|
121
|
+
|| request.evidencePayload?.onePlayVerificationId !== purchase.verificationId || request.evidencePayload.onePlayClientId !== client.clientId
|
|
122
|
+
|| request.evidencePayload.onePlayEnvironment !== client.environment || request.evidencePayload.onePlayMarketCode !== client.marketCode
|
|
123
|
+
|| request.evidencePayload.onePlayPurchaseId !== purchase.purchaseId || request.evidencePayload.onePlayDeveloperPayload !== purchase.intent.developerPayload) {
|
|
124
|
+
return { status: 'pending', action, alreadyCompleted: false, reason: 'ONEPLAY_FINALIZATION_BINDING_INVALID' };
|
|
125
|
+
}
|
|
126
|
+
if (action === 'consume' && purchase.consumptionState === 1 || action === 'acknowledge' && purchase.acknowledgeState === 1) {
|
|
127
|
+
return { status: 'completed', action, alreadyCompleted: true };
|
|
128
|
+
}
|
|
129
|
+
const operation = { productId: request.platformProductId, purchaseToken: purchase.purchaseToken, developerPayload: purchase.intent.developerPayload, signal: request.signal };
|
|
130
|
+
if (action === 'consume') {
|
|
131
|
+
await client.consumePurchase(operation);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
await client.acknowledgePurchase(operation);
|
|
135
|
+
}
|
|
136
|
+
request.signal.throwIfAborted();
|
|
137
|
+
return { status: 'completed', action, alreadyCompleted: false };
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
request.signal.throwIfAborted();
|
|
141
|
+
return { status: 'pending', action, alreadyCompleted: false, reason: 'ONEPLAY_FINALIZATION_API_UNAVAILABLE' };
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function payload(value) {
|
|
147
|
+
const text = onePlayIdentifier(value, 200);
|
|
148
|
+
if (new TextEncoder().encode(text).length > 200) {
|
|
149
|
+
throw new TypeError('ONE play developer payload is too long.');
|
|
150
|
+
}
|
|
151
|
+
return text;
|
|
152
|
+
}
|
|
153
|
+
function validIntent(intent) {
|
|
154
|
+
return [intent.clientId, intent.deploymentTarget, intent.playerId, intent.productId, intent.platformProductId, intent.idempotencyKey].every((value) => onePlayIsIdentifier(value))
|
|
155
|
+
&& onePlayIsIdentifier(intent.developerPayload, 200) && new TextEncoder().encode(intent.developerPayload).length <= 200
|
|
156
|
+
&& ['SANDBOX', 'COMMERCIAL'].includes(intent.environment) && ['MKT_ONE', 'MKT_GLB'].includes(intent.marketCode)
|
|
157
|
+
&& ['consumable', 'non_consumable'].includes(intent.productType) && Number.isSafeInteger(intent.issuedAt) && Number.isSafeInteger(intent.expiresAt)
|
|
158
|
+
&& intent.issuedAt >= 0 && intent.expiresAt >= intent.issuedAt;
|
|
159
|
+
}
|
|
160
|
+
function sameIdentity(left, right) {
|
|
161
|
+
return left.clientId === right.clientId && left.environment === right.environment && left.marketCode === right.marketCode
|
|
162
|
+
&& left.deploymentTarget === right.deploymentTarget && left.playerId === right.playerId && left.idempotencyKey === right.idempotencyKey;
|
|
163
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { type AdPlacements } from '@mpgd/catalog';
|
|
2
|
+
import type { AdRewardVerifierRegistration } from './ad-reward-verifier-registry.js';
|
|
3
|
+
import type { GameServicesBackendApi } from './client.js';
|
|
4
|
+
import type { ClaimAdRewardResponse } from './types.js';
|
|
5
|
+
export interface OnePlayRewardBinding {
|
|
6
|
+
readonly applicationId: string;
|
|
7
|
+
readonly deploymentTarget: string;
|
|
8
|
+
readonly requestId: string;
|
|
9
|
+
readonly playerId: string;
|
|
10
|
+
readonly placementId: string;
|
|
11
|
+
readonly platformPlacementId: string;
|
|
12
|
+
readonly idempotencyKey: string;
|
|
13
|
+
readonly issuedAt: number;
|
|
14
|
+
readonly expiresAt: number;
|
|
15
|
+
}
|
|
16
|
+
export interface OnePlayRewardClaimIdentity {
|
|
17
|
+
readonly applicationId: string;
|
|
18
|
+
readonly deploymentTarget: string;
|
|
19
|
+
readonly playerId: string;
|
|
20
|
+
readonly idempotencyKey: string;
|
|
21
|
+
}
|
|
22
|
+
export interface OnePlaySsvReceipt {
|
|
23
|
+
/** Original bytes. Never reserialize the body used for HMAC verification. */
|
|
24
|
+
readonly rawBody: Uint8Array<ArrayBuffer>;
|
|
25
|
+
readonly timestamp: string;
|
|
26
|
+
readonly signature: string;
|
|
27
|
+
/** Trusted receiver time, not a client-supplied backdated timestamp. */
|
|
28
|
+
readonly receivedAt: number;
|
|
29
|
+
}
|
|
30
|
+
/** Production implementations must durably enforce unique claim and request IDs, and immutable callbacks. */
|
|
31
|
+
export interface OnePlayRewardStore {
|
|
32
|
+
/** Atomically insert or return the row for application/deployment/player/idempotency. */
|
|
33
|
+
issue(binding: OnePlayRewardBinding): Promise<OnePlayRewardBinding>;
|
|
34
|
+
findByClaim(input: OnePlayRewardClaimIdentity): Promise<OnePlayRewardBinding | undefined>;
|
|
35
|
+
findByRequest(input: {
|
|
36
|
+
readonly applicationId: string;
|
|
37
|
+
readonly requestId: string;
|
|
38
|
+
}): Promise<OnePlayRewardBinding | undefined>;
|
|
39
|
+
recordReceipt(input: {
|
|
40
|
+
readonly applicationId: string;
|
|
41
|
+
readonly requestId: string;
|
|
42
|
+
readonly receipt: OnePlaySsvReceipt;
|
|
43
|
+
}): Promise<void>;
|
|
44
|
+
findReceipt(input: {
|
|
45
|
+
readonly applicationId: string;
|
|
46
|
+
readonly requestId: string;
|
|
47
|
+
}): Promise<OnePlaySsvReceipt | undefined>;
|
|
48
|
+
}
|
|
49
|
+
export interface OnePlaySsvInput {
|
|
50
|
+
readonly rawBody: Uint8Array<ArrayBuffer>;
|
|
51
|
+
readonly apiKey: string;
|
|
52
|
+
readonly timestamp: string;
|
|
53
|
+
readonly signature: string;
|
|
54
|
+
}
|
|
55
|
+
export type OnePlaySsvVerification = {
|
|
56
|
+
readonly status: 'verified';
|
|
57
|
+
readonly requestId: string;
|
|
58
|
+
readonly grant?: ClaimAdRewardResponse;
|
|
59
|
+
} | {
|
|
60
|
+
readonly status: 'rejected';
|
|
61
|
+
readonly reason: string;
|
|
62
|
+
};
|
|
63
|
+
export declare function createOnePlayRewardRequestIssuer(input: {
|
|
64
|
+
readonly applicationId: string;
|
|
65
|
+
readonly deploymentTarget?: string;
|
|
66
|
+
readonly placements: AdPlacements;
|
|
67
|
+
readonly store: OnePlayRewardStore;
|
|
68
|
+
readonly now?: () => number;
|
|
69
|
+
readonly createRequestId?: () => string;
|
|
70
|
+
}): {
|
|
71
|
+
/** playerId MUST come from authenticated server context, not SDK identity or the request body. */
|
|
72
|
+
issue(request: {
|
|
73
|
+
readonly playerId: string;
|
|
74
|
+
readonly placementId: string;
|
|
75
|
+
readonly idempotencyKey: string;
|
|
76
|
+
}): Promise<{
|
|
77
|
+
readonly requestId: string;
|
|
78
|
+
}>;
|
|
79
|
+
};
|
|
80
|
+
/** Authenticate the sender, timestamp and exact raw bytes before trusting requestId. */
|
|
81
|
+
export declare function verifyOnePlaySsv(input: OnePlaySsvInput, options: {
|
|
82
|
+
readonly apiKey: string;
|
|
83
|
+
readonly now?: () => number;
|
|
84
|
+
readonly subtle?: SubtleCrypto;
|
|
85
|
+
}): Promise<OnePlaySsvVerification>;
|
|
86
|
+
/** Receive once, persist first, then let the normal backend claim / recovery pipeline grant. */
|
|
87
|
+
export declare function createOnePlaySsvReceiver(input: {
|
|
88
|
+
readonly applicationId: string;
|
|
89
|
+
readonly apiKey: string;
|
|
90
|
+
readonly store: OnePlayRewardStore;
|
|
91
|
+
readonly now?: () => number;
|
|
92
|
+
readonly subtle?: SubtleCrypto;
|
|
93
|
+
/** Optional immediate grant through the same configured verifier and backend ledger used by client claims. */
|
|
94
|
+
readonly backend?: Pick<GameServicesBackendApi, 'adRewards'>;
|
|
95
|
+
}): {
|
|
96
|
+
receive(callback: OnePlaySsvInput): Promise<OnePlaySsvVerification>;
|
|
97
|
+
};
|
|
98
|
+
export declare function createOnePlayAdRewardVerifier(input: {
|
|
99
|
+
readonly applicationId: string;
|
|
100
|
+
readonly deploymentTarget?: string;
|
|
101
|
+
readonly apiKey: string;
|
|
102
|
+
readonly store: OnePlayRewardStore;
|
|
103
|
+
readonly now?: () => number;
|
|
104
|
+
readonly subtle?: SubtleCrypto;
|
|
105
|
+
}): AdRewardVerifierRegistration;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { resolveAdPlacementPlatformId } from '@mpgd/catalog';
|
|
2
|
+
import { onePlayClientRewardEvidenceSchema } from './oneplay-client-reward.js';
|
|
3
|
+
export function createOnePlayRewardRequestIssuer(input) {
|
|
4
|
+
const applicationId = identifier(input.applicationId);
|
|
5
|
+
const deploymentTarget = identifier(input.deploymentTarget ?? 'oneplay');
|
|
6
|
+
const now = input.now ?? Date.now;
|
|
7
|
+
return {
|
|
8
|
+
/** playerId MUST come from authenticated server context, not SDK identity or the request body. */
|
|
9
|
+
async issue(request) {
|
|
10
|
+
const playerId = identifier(request.playerId);
|
|
11
|
+
const idempotencyKey = identifier(request.idempotencyKey);
|
|
12
|
+
const placement = input.placements.placements.find((entry) => entry.id === request.placementId);
|
|
13
|
+
const platformPlacementId = placement === undefined ? undefined : resolveAdPlacementPlatformId(placement, deploymentTarget);
|
|
14
|
+
if (placement?.type !== 'rewarded' || platformPlacementId === undefined) {
|
|
15
|
+
throw new TypeError('ONE play rewarded placement is not configured.');
|
|
16
|
+
}
|
|
17
|
+
const issuedAt = timestamp(now());
|
|
18
|
+
const candidate = { applicationId, deploymentTarget, playerId, idempotencyKey, placementId: placement.id,
|
|
19
|
+
platformPlacementId, requestId: identifier((input.createRequestId ?? (() => globalThis.crypto.randomUUID()))()), issuedAt, expiresAt: issuedAt + 86_400_000 };
|
|
20
|
+
const stored = await input.store.issue(candidate);
|
|
21
|
+
if (!matchesClaim(stored, candidate) || stored.placementId !== candidate.placementId || stored.platformPlacementId !== platformPlacementId
|
|
22
|
+
|| !validBinding(stored) || stored.expiresAt < issuedAt) {
|
|
23
|
+
throw new Error('ONE play reward request binding conflict.');
|
|
24
|
+
}
|
|
25
|
+
return { requestId: stored.requestId };
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Authenticate the sender, timestamp and exact raw bytes before trusting requestId. */
|
|
30
|
+
export async function verifyOnePlaySsv(input, options) {
|
|
31
|
+
const key = apiKey(options.apiKey);
|
|
32
|
+
const at = (options.now ?? Date.now)();
|
|
33
|
+
if (input.rawBody.byteLength > 8192 || !constantTimeEqual(input.apiKey, key) || !/^\d{1,16}$/u.test(input.timestamp)
|
|
34
|
+
|| !Number.isSafeInteger(at) || Math.abs(Number(input.timestamp) - at) > 300_000 || !/^[a-f\d]{64}$/iu.test(input.signature)) {
|
|
35
|
+
return { status: 'rejected', reason: 'ONEPLAY_SSV_INVALID' };
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const subtle = options.subtle ?? globalThis.crypto.subtle;
|
|
39
|
+
const signingKey = await subtle.importKey('raw', new TextEncoder().encode(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
40
|
+
const prefix = new TextEncoder().encode(`${input.timestamp}\n`);
|
|
41
|
+
const bytes = new Uint8Array(prefix.length + input.rawBody.length);
|
|
42
|
+
bytes.set(prefix);
|
|
43
|
+
bytes.set(input.rawBody, prefix.length);
|
|
44
|
+
const signature = Uint8Array.from(input.signature.match(/../gu) ?? [], (pair) => Number.parseInt(pair, 16));
|
|
45
|
+
if (!await subtle.verify('HMAC', signingKey, signature, bytes)) {
|
|
46
|
+
return {
|
|
47
|
+
status: 'rejected',
|
|
48
|
+
reason: 'ONEPLAY_SSV_INVALID',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const body = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(input.rawBody));
|
|
52
|
+
if (!record(body) || body.status !== 'SUCCESS' || body.reason !== '' || !isIdentifier(body.requestId)) {
|
|
53
|
+
return {
|
|
54
|
+
status: 'rejected',
|
|
55
|
+
reason: 'ONEPLAY_SSV_INVALID',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return { status: 'verified', requestId: body.requestId };
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return { status: 'rejected', reason: 'ONEPLAY_SSV_INVALID' };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Receive once, persist first, then let the normal backend claim / recovery pipeline grant. */
|
|
65
|
+
export function createOnePlaySsvReceiver(input) {
|
|
66
|
+
const applicationId = identifier(input.applicationId);
|
|
67
|
+
apiKey(input.apiKey);
|
|
68
|
+
const now = input.now ?? Date.now;
|
|
69
|
+
return {
|
|
70
|
+
async receive(callback) {
|
|
71
|
+
const receivedAt = timestamp(now());
|
|
72
|
+
const decision = await verifyOnePlaySsv(callback, { apiKey: input.apiKey, now: () => receivedAt, ...(input.subtle === undefined ? {} : { subtle: input.subtle }) });
|
|
73
|
+
if (decision.status !== 'verified') {
|
|
74
|
+
return decision;
|
|
75
|
+
}
|
|
76
|
+
const binding = await input.store.findByRequest({ applicationId, requestId: decision.requestId });
|
|
77
|
+
if (binding === undefined || !validBinding(binding) || binding.applicationId !== applicationId || binding.requestId !== decision.requestId
|
|
78
|
+
|| receivedAt < binding.issuedAt - 300_000 || receivedAt > binding.expiresAt + 300_000) {
|
|
79
|
+
return { status: 'rejected', reason: 'ONEPLAY_SSV_BINDING_INVALID' };
|
|
80
|
+
}
|
|
81
|
+
await input.store.recordReceipt({ applicationId, requestId: decision.requestId, receipt: {
|
|
82
|
+
rawBody: new Uint8Array(callback.rawBody), timestamp: callback.timestamp, signature: callback.signature, receivedAt,
|
|
83
|
+
} });
|
|
84
|
+
if (input.backend === undefined) {
|
|
85
|
+
return decision;
|
|
86
|
+
}
|
|
87
|
+
const grant = await input.backend.adRewards.claimAdReward({
|
|
88
|
+
target: 'oneplay', providerId: 'oneplay-ads',
|
|
89
|
+
...(binding.deploymentTarget === 'oneplay' ? {} : { deploymentTarget: binding.deploymentTarget }),
|
|
90
|
+
playerId: binding.playerId, placementId: binding.placementId, platformImpressionId: binding.requestId,
|
|
91
|
+
idempotencyKey: binding.idempotencyKey, completedAt: new Date(receivedAt).toISOString(),
|
|
92
|
+
});
|
|
93
|
+
return { ...decision, grant };
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export function createOnePlayAdRewardVerifier(input) {
|
|
98
|
+
const applicationId = identifier(input.applicationId);
|
|
99
|
+
const deploymentTarget = identifier(input.deploymentTarget ?? 'oneplay');
|
|
100
|
+
apiKey(input.apiKey);
|
|
101
|
+
const now = input.now ?? Date.now;
|
|
102
|
+
return {
|
|
103
|
+
providerId: 'oneplay-ads', schema: onePlayClientRewardEvidenceSchema,
|
|
104
|
+
bindings: [{ target: 'oneplay', deploymentTarget }], acceptsMissingEvidence: true,
|
|
105
|
+
async verify({ request, platformPlacementId, signal }) {
|
|
106
|
+
signal.throwIfAborted();
|
|
107
|
+
if (request.target !== 'oneplay' || (request.deploymentTarget ?? request.target) !== deploymentTarget
|
|
108
|
+
|| platformPlacementId === undefined || request.providerId !== undefined && request.providerId !== 'oneplay-ads') {
|
|
109
|
+
return { status: 'rejected', reason: 'ONEPLAY_REWARD_BINDING_INVALID' };
|
|
110
|
+
}
|
|
111
|
+
const identity = { applicationId, deploymentTarget, playerId: request.playerId, idempotencyKey: request.idempotencyKey };
|
|
112
|
+
const binding = await input.store.findByClaim(identity);
|
|
113
|
+
signal.throwIfAborted();
|
|
114
|
+
if (binding === undefined || !validBinding(binding) || !matchesClaim(binding, identity) || binding.placementId !== request.placementId
|
|
115
|
+
|| binding.platformPlacementId !== platformPlacementId || request.platformImpressionId !== undefined && request.platformImpressionId !== binding.requestId) {
|
|
116
|
+
return { status: 'rejected', reason: 'ONEPLAY_REWARD_BINDING_INVALID' };
|
|
117
|
+
}
|
|
118
|
+
const envelope = request.evidence;
|
|
119
|
+
if (envelope !== undefined && (envelope.schema !== onePlayClientRewardEvidenceSchema || envelope.payload.requestId !== binding.requestId
|
|
120
|
+
|| envelope.payload.placementId !== binding.placementId || envelope.payload.platformPlacementId !== platformPlacementId || envelope.payload.rewardGranted !== false)) {
|
|
121
|
+
return { status: 'rejected', reason: 'ONEPLAY_REWARD_BINDING_INVALID' };
|
|
122
|
+
}
|
|
123
|
+
const receipt = await input.store.findReceipt({ applicationId, requestId: binding.requestId });
|
|
124
|
+
signal.throwIfAborted();
|
|
125
|
+
if (receipt === undefined) {
|
|
126
|
+
return { status: 'pending', reason: 'ONEPLAY_SSV_PENDING' };
|
|
127
|
+
}
|
|
128
|
+
if (!Number.isSafeInteger(receipt.receivedAt) || receipt.receivedAt < binding.issuedAt - 300_000 || receipt.receivedAt > binding.expiresAt + 300_000 || receipt.receivedAt > now() + 300_000) {
|
|
129
|
+
return { status: 'rejected', reason: 'ONEPLAY_SSV_BINDING_INVALID' };
|
|
130
|
+
}
|
|
131
|
+
const verified = await verifyOnePlaySsv({ ...receipt, apiKey: input.apiKey }, {
|
|
132
|
+
apiKey: input.apiKey, now: () => receipt.receivedAt, ...(input.subtle === undefined ? {} : { subtle: input.subtle }),
|
|
133
|
+
});
|
|
134
|
+
signal.throwIfAborted();
|
|
135
|
+
if (verified.status !== 'verified' || verified.requestId !== binding.requestId) {
|
|
136
|
+
return { status: 'rejected', reason: 'ONEPLAY_SSV_INVALID' };
|
|
137
|
+
}
|
|
138
|
+
const proofId = `${encodeURIComponent(applicationId)}:${encodeURIComponent(binding.requestId)}`;
|
|
139
|
+
return { status: 'verified', verificationId: `oneplay:ssv:${proofId}`, platformEvidenceId: proofId,
|
|
140
|
+
verifiedAt: new Date(receipt.receivedAt).toISOString(), payload: { onePlayRequestId: binding.requestId, onePlayApplicationId: applicationId } };
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function validBinding(binding) {
|
|
145
|
+
return [binding.applicationId, binding.deploymentTarget, binding.playerId, binding.idempotencyKey, binding.requestId, binding.placementId, binding.platformPlacementId].every(isIdentifier)
|
|
146
|
+
&& Number.isSafeInteger(binding.issuedAt) && Number.isSafeInteger(binding.expiresAt) && binding.issuedAt >= 0 && binding.expiresAt >= binding.issuedAt;
|
|
147
|
+
}
|
|
148
|
+
function matchesClaim(binding, request) {
|
|
149
|
+
return binding.applicationId === request.applicationId && binding.deploymentTarget === request.deploymentTarget
|
|
150
|
+
&& binding.playerId === request.playerId && binding.idempotencyKey === request.idempotencyKey;
|
|
151
|
+
}
|
|
152
|
+
function isIdentifier(value) {
|
|
153
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 256 && value.trim() === value && !/[\u0000-\u001f\u007f]/u.test(value);
|
|
154
|
+
}
|
|
155
|
+
function identifier(value) {
|
|
156
|
+
if (!isIdentifier(value)) {
|
|
157
|
+
throw new TypeError('ONE play identifier is invalid.');
|
|
158
|
+
}
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
function timestamp(value) {
|
|
162
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > 8_639_999_913_600_000) {
|
|
163
|
+
throw new TypeError('ONE play timestamp is invalid.');
|
|
164
|
+
}
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
function apiKey(value) {
|
|
168
|
+
if (!/^[A-Za-z\d_-]{5,64}$/u.test(value)) {
|
|
169
|
+
throw new TypeError('ONE play SSV API key is invalid.');
|
|
170
|
+
}
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
173
|
+
function constantTimeEqual(left, right) {
|
|
174
|
+
if (typeof left !== 'string' || left.length !== right.length) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
let difference = 0;
|
|
178
|
+
for (let index = 0; index < right.length; index += 1) {
|
|
179
|
+
difference |= left.charCodeAt(index) ^ right.charCodeAt(index);
|
|
180
|
+
}
|
|
181
|
+
return difference === 0;
|
|
182
|
+
}
|
|
183
|
+
function record(value) {
|
|
184
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
185
|
+
}
|
package/dist/runtime.js
CHANGED
|
@@ -105,7 +105,8 @@ export function resolveGameServicesLedgerTarget(target) {
|
|
|
105
105
|
|| target === 'ios'
|
|
106
106
|
|| target === 'ait'
|
|
107
107
|
|| target === 'reddit'
|
|
108
|
-
|| target === 'verse8'
|
|
108
|
+
|| target === 'verse8'
|
|
109
|
+
|| target === 'oneplay') {
|
|
109
110
|
return target;
|
|
110
111
|
}
|
|
111
112
|
return null;
|
package/dist/server.js
CHANGED
package/dist/types.d.ts
CHANGED
|
@@ -3,10 +3,10 @@ import type { LeaderboardScoreInput, LogicalAdPlacementId, LogicalProductId, Pla
|
|
|
3
3
|
import type { ClaimAdRewardResponse, PurchaseGrantFinalization, VerifyPurchaseResponse } from './operation-results.js';
|
|
4
4
|
export type { ClaimAdRewardResponse, PurchaseGrantFinalization, PurchaseGrantFinalizationAction, VerifyPurchaseResponse, } from './operation-results.js';
|
|
5
5
|
export type { PlatformEvidenceEnvelope } from '@mpgd/platform';
|
|
6
|
-
export type GameServicesStoreTarget = Extract<PlatformTarget, 'microsoft-store' | 'android' | 'ios' | 'ait'>;
|
|
6
|
+
export type GameServicesStoreTarget = Extract<PlatformTarget, 'microsoft-store' | 'android' | 'ios' | 'ait' | 'oneplay'>;
|
|
7
7
|
export type GameServicesAdRewardTarget = GameServicesLedgerTarget;
|
|
8
8
|
export type GameServicesLeaderboardTarget = Extract<PlatformTarget, 'browser' | 'microsoft-store' | 'android' | 'ios' | 'ait' | 'reddit'>;
|
|
9
|
-
export type GameServicesLedgerTarget = Extract<PlatformTarget, 'browser' | 'microsoft-store' | 'android' | 'ios' | 'ait' | 'reddit' | 'verse8'>;
|
|
9
|
+
export type GameServicesLedgerTarget = Extract<PlatformTarget, 'browser' | 'microsoft-store' | 'android' | 'ios' | 'ait' | 'reddit' | 'verse8' | 'oneplay'>;
|
|
10
10
|
export type PurchaseIdempotencyKey = string;
|
|
11
11
|
export type AdRewardIdempotencyKey = string;
|
|
12
12
|
export type EntitlementIdempotencyKey = PurchaseIdempotencyKey | AdRewardIdempotencyKey;
|
package/dist/types.js
CHANGED
|
@@ -154,13 +154,14 @@ function assertStoreTarget(input) {
|
|
|
154
154
|
if (input !== 'microsoft-store'
|
|
155
155
|
&& input !== 'android'
|
|
156
156
|
&& input !== 'ios'
|
|
157
|
-
&& input !== 'ait'
|
|
158
|
-
|
|
157
|
+
&& input !== 'ait'
|
|
158
|
+
&& input !== 'oneplay') {
|
|
159
|
+
throw new Error('target must be microsoft-store, android, ios, ait, or oneplay.');
|
|
159
160
|
}
|
|
160
161
|
}
|
|
161
162
|
function assertAdRewardTarget(input) {
|
|
162
163
|
if (input !== 'browser' && input !== 'microsoft-store' && input !== 'android'
|
|
163
|
-
&& input !== 'ios' && input !== 'ait' && input !== 'reddit' && input !== 'verse8') {
|
|
164
|
+
&& input !== 'ios' && input !== 'ait' && input !== 'reddit' && input !== 'verse8' && input !== 'oneplay') {
|
|
164
165
|
throw new Error('target must be a supported platform target.');
|
|
165
166
|
}
|
|
166
167
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/game-services",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -156,6 +156,26 @@
|
|
|
156
156
|
"./ad-reward-verifier-registry": {
|
|
157
157
|
"types": "./dist/ad-reward-verifier-registry.d.ts",
|
|
158
158
|
"default": "./dist/ad-reward-verifier-registry.js"
|
|
159
|
+
},
|
|
160
|
+
"./oneplay-client-reward": {
|
|
161
|
+
"types": "./dist/oneplay-client-reward.d.ts",
|
|
162
|
+
"default": "./dist/oneplay-client-reward.js"
|
|
163
|
+
},
|
|
164
|
+
"./oneplay-reward": {
|
|
165
|
+
"types": "./dist/oneplay-reward.d.ts",
|
|
166
|
+
"default": "./dist/oneplay-reward.js"
|
|
167
|
+
},
|
|
168
|
+
"./oneplay-purchase-client": {
|
|
169
|
+
"types": "./dist/oneplay-purchase-client.d.ts",
|
|
170
|
+
"default": "./dist/oneplay-purchase-client.js"
|
|
171
|
+
},
|
|
172
|
+
"./oneplay-purchase": {
|
|
173
|
+
"types": "./dist/oneplay-purchase.d.ts",
|
|
174
|
+
"default": "./dist/oneplay-purchase.js"
|
|
175
|
+
},
|
|
176
|
+
"./oneplay-pns": {
|
|
177
|
+
"types": "./dist/oneplay-pns.d.ts",
|
|
178
|
+
"default": "./dist/oneplay-pns.js"
|
|
159
179
|
}
|
|
160
180
|
},
|
|
161
181
|
"files": [
|
|
@@ -166,9 +186,9 @@
|
|
|
166
186
|
"@orpc/client": "2.0.0-beta.14",
|
|
167
187
|
"@orpc/contract": "2.0.0-beta.14",
|
|
168
188
|
"@orpc/server": "2.0.0-beta.14",
|
|
169
|
-
"@mpgd/
|
|
170
|
-
"@mpgd/
|
|
171
|
-
"@mpgd/platform": "0.
|
|
189
|
+
"@mpgd/analytics": "0.4.3",
|
|
190
|
+
"@mpgd/catalog": "0.8.0",
|
|
191
|
+
"@mpgd/platform": "0.17.0"
|
|
172
192
|
},
|
|
173
193
|
"devDependencies": {
|
|
174
194
|
"@opentelemetry/api": "1.9.0",
|
|
@@ -187,7 +207,7 @@
|
|
|
187
207
|
"fix": "ttsc fix",
|
|
188
208
|
"build": "cd ../.. && node tools/run-ttsx.mjs tools/package/build-packages.ts @mpgd/game-services",
|
|
189
209
|
"test:packed-transport": "pnpm build && node test/runtime-transport-package-import.mjs && node test/reward-evidence-import.mjs",
|
|
190
|
-
"test:unit": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-progress.test.ts && node tools/run-ttsx.mjs packages/game-services/src/runtime.test.ts && node tools/run-ttsx.mjs packages/game-services/src/types.test.ts && node tools/run-ttsx.mjs packages/game-services/src/server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-publisher-client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-identity.test.ts && node tools/run-ttsx.mjs packages/game-services/src/platform-order.test.ts && node tools/run-ttsx.mjs packages/game-services/src/progress-link.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notification-delivery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/admob-ssv.test.ts && node tools/run-ttsx.mjs packages/game-services/src/app-store-verifier.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-evidence-verification-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-partner-api.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-durability-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-transport.test.ts && node tools/run-ttsx.mjs packages/game-services/src/guest-session.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-reward-evidence.test.ts && node tools/run-ttsx.mjs packages/game-services/src/ad-reward-verifier-registry.test.ts",
|
|
210
|
+
"test:unit": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-progress.test.ts && node tools/run-ttsx.mjs packages/game-services/src/runtime.test.ts && node tools/run-ttsx.mjs packages/game-services/src/types.test.ts && node tools/run-ttsx.mjs packages/game-services/src/server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-publisher-client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-identity.test.ts && node tools/run-ttsx.mjs packages/game-services/src/platform-order.test.ts && node tools/run-ttsx.mjs packages/game-services/src/progress-link.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notification-delivery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/admob-ssv.test.ts && node tools/run-ttsx.mjs packages/game-services/src/app-store-verifier.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-evidence-verification-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-partner-api.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-durability-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-transport.test.ts && node tools/run-ttsx.mjs packages/game-services/src/guest-session.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-reward-evidence.test.ts && node tools/run-ttsx.mjs packages/game-services/src/ad-reward-verifier-registry.test.ts && node tools/run-ttsx.mjs packages/game-services/src/oneplay-reward.test.ts && node tools/run-ttsx.mjs packages/game-services/src/oneplay-purchase.test.ts",
|
|
191
211
|
"test:recovery": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/monetization-recovery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/app-store-recovery.test.ts",
|
|
192
212
|
"test": "pnpm test:unit && pnpm test:recovery && pnpm test:packed-transport"
|
|
193
213
|
}
|