@mpgd/game-services 0.18.1 → 0.19.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.
@@ -72,6 +72,7 @@ const rewardTargets = new Set([
72
72
  'ait',
73
73
  'reddit',
74
74
  'verse8',
75
+ 'oneplay',
75
76
  ]);
76
77
  function bindingKey(schema, target, deploymentTarget) {
77
78
  return JSON.stringify([schema, target, deploymentTarget]);
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,
@@ -15,7 +15,8 @@ export function createGooglePlayPublisherClient(options) {
15
15
  response = await requestFetch(`${publisherOrigin}${path}`, {
16
16
  method,
17
17
  headers: { Authorization: `Bearer ${accessToken}` },
18
- redirect: 'error',
18
+ // Workers reject redirect: 'error'; a manual 3xx/opaque redirect is never ok and fails below.
19
+ redirect: 'manual',
19
20
  signal,
20
21
  });
21
22
  }
@@ -26,7 +27,8 @@ export function createGooglePlayPublisherClient(options) {
26
27
  // Fetch errors can contain the request URL (including the purchase token).
27
28
  throw new Error('Google Play Publisher request failed.');
28
29
  }
29
- if (!response.ok) {
30
+ // Also reject a redirect followed by an injected fetch that ignores redirect: 'manual'.
31
+ if (!response.ok || response.redirected) {
30
32
  await discardBody(response);
31
33
  // Do not forward an error body or URL: both can contain purchase credentials.
32
34
  throw new Error(`Google Play Publisher request failed (HTTP ${response.status}).`);
@@ -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,157 @@
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
+ // Workers reject redirect: 'error'; a manual 3xx/opaque redirect is never ok and fails below.
21
+ redirect: 'manual',
22
+ signal,
23
+ });
24
+ // Also reject a redirect followed by an injected fetch that ignores redirect: 'manual'.
25
+ if (!response.ok || response.redirected) {
26
+ if (response.status === 401) {
27
+ cached = undefined;
28
+ }
29
+ await response.body?.cancel().catch(() => undefined);
30
+ throw new Error('HTTP failure');
31
+ }
32
+ const value = await boundedJson(response);
33
+ signal.throwIfAborted();
34
+ return value;
35
+ }
36
+ catch {
37
+ signal.throwIfAborted();
38
+ // Transport errors and provider responses may contain URL tokens or client secrets.
39
+ throw new Error('ONE play purchase API request failed.');
40
+ }
41
+ }
42
+ async function token(signal) {
43
+ if (cached !== undefined && cached.expiresAt > now() + 60_000) {
44
+ return cached.token;
45
+ }
46
+ // Each caller owns its abort signal. Never share an abortable OAuth promise between requests.
47
+ const raw = await jsonRequest('/v7/oauth/token', {
48
+ method: 'POST',
49
+ headers: {
50
+ 'content-type': 'application/x-www-form-urlencoded',
51
+ 'x-market-code': marketCode,
52
+ },
53
+ body: new URLSearchParams({
54
+ grant_type: 'client_credentials',
55
+ client_id: clientId,
56
+ client_secret: clientSecret,
57
+ }),
58
+ }, signal);
59
+ if (!onePlayRecord(raw) || raw.client_id !== clientId || typeof raw.token_type !== 'string' || raw.token_type.toLowerCase() !== 'bearer'
60
+ || !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) {
61
+ throw new Error('ONE play OAuth response is invalid.');
62
+ }
63
+ cached = { token: raw.access_token, expiresAt: now() + raw.expires_in * 1000 };
64
+ return cached.token;
65
+ }
66
+ async function request(operation, request) {
67
+ const signal = AbortSignal.any([request.signal, AbortSignal.timeout(10_000)]);
68
+ const type = operation === 'acknowledge' ? 'all' : 'inapp';
69
+ const path = `/v7/apps/${pathPart(clientId)}/purchases/${type}/products/${pathPart(request.productId)}/${pathPart(request.purchaseToken)}${operation === 'get' ? '' : `/${operation}`}`;
70
+ const developerPayload = operation === 'get'
71
+ ? undefined
72
+ : onePlayIdentifier(request.developerPayload, 200);
73
+ if (developerPayload !== undefined && new TextEncoder().encode(developerPayload).length > 200) {
74
+ throw new TypeError('ONE play developer payload is too long.');
75
+ }
76
+ const body = developerPayload === undefined ? undefined : JSON.stringify({ developerPayload });
77
+ const result = await jsonRequest(path, {
78
+ method: operation === 'get' ? 'GET' : 'POST',
79
+ headers: {
80
+ authorization: `Bearer ${await token(signal)}`,
81
+ 'content-type': 'application/json',
82
+ 'x-market-code': marketCode,
83
+ },
84
+ ...(body === undefined ? {} : { body }),
85
+ }, signal);
86
+ if (operation !== 'get' && (!onePlayRecord(result) || !onePlayRecord(result.result) || result.result.code !== 'Success')) {
87
+ throw new Error('ONE play purchase finalization was not confirmed.');
88
+ }
89
+ return result;
90
+ }
91
+ return {
92
+ clientId,
93
+ environment,
94
+ marketCode,
95
+ getPurchaseDetails: (requestInput) => request('get', requestInput),
96
+ async consumePurchase(requestInput) {
97
+ await request('consume', requestInput);
98
+ },
99
+ async acknowledgePurchase(requestInput) {
100
+ await request('acknowledge', requestInput);
101
+ },
102
+ };
103
+ }
104
+ export function onePlayRecord(value) {
105
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
106
+ }
107
+ export function onePlayIsIdentifier(value, max = 256) {
108
+ return typeof value === 'string' && value.length > 0 && value.length <= max && value.trim() === value && !/[\u0000-\u001f\u007f]/u.test(value);
109
+ }
110
+ export function onePlayIdentifier(value, max = 256) {
111
+ if (!onePlayIsIdentifier(value, max)) {
112
+ throw new TypeError('ONE play identifier is invalid.');
113
+ }
114
+ return value;
115
+ }
116
+ function pathPart(value) {
117
+ onePlayIdentifier(value, 4096);
118
+ if (value === '.' || value === '..') {
119
+ throw new TypeError('ONE play path parameter is invalid.');
120
+ }
121
+ return encodeURIComponent(value);
122
+ }
123
+ async function boundedJson(response) {
124
+ if (response.body === null) {
125
+ throw new Error('Missing ONE play response.');
126
+ }
127
+ const reader = response.body.getReader();
128
+ const chunks = [];
129
+ let size = 0;
130
+ try {
131
+ while (true) {
132
+ const chunk = await reader.read();
133
+ if (chunk.done) {
134
+ break;
135
+ }
136
+ size += chunk.value.length;
137
+ if (size > 65_536) {
138
+ throw new Error('ONE play response exceeds its limit.');
139
+ }
140
+ chunks.push(chunk.value);
141
+ }
142
+ }
143
+ catch (error) {
144
+ await reader.cancel().catch(() => undefined);
145
+ throw error;
146
+ }
147
+ finally {
148
+ reader.releaseLock();
149
+ }
150
+ const bytes = new Uint8Array(size);
151
+ let offset = 0;
152
+ for (const chunk of chunks) {
153
+ bytes.set(chunk, offset);
154
+ offset += chunk.length;
155
+ }
156
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
157
+ }
@@ -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
@@ -400,6 +400,7 @@ const gameServicesDeploymentTargetPlatforms = new Set([
400
400
  'ios',
401
401
  'ait',
402
402
  'verse8',
403
+ 'oneplay',
403
404
  'reddit',
404
405
  ]);
405
406
  function resolveDeploymentTargetBindings(input) {
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
- throw new Error('target must be microsoft-store, android, ios, or ait.');
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.18.1",
3
+ "version": "0.19.1",
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/analytics": "0.4.2",
170
- "@mpgd/platform": "0.16.0",
171
- "@mpgd/catalog": "0.7.8"
189
+ "@mpgd/analytics": "0.4.4",
190
+ "@mpgd/catalog": "0.8.1",
191
+ "@mpgd/platform": "0.18.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
  }