@mpgd/game-services 0.15.3 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { createORPCClient } from '@orpc/client';
2
2
  import { RPCLink } from '@orpc/client/fetch';
3
3
  import { createAnalyticsReporter } from '@mpgd/analytics';
4
+ import { isAdMobClientRewardEvidence } from './admob-client-reward.js';
5
+ import { isAuthoritativeMicrosoftStoreCompletion } from './authoritative-purchase.js';
4
6
  import { observeGameServicesOperation } from './operation-progress.js';
5
7
  export const gameServicesBackendEndpoints = {
6
8
  verifyPurchase: '/game-services/purchases/verify',
@@ -19,8 +21,42 @@ export class GameServicesBackendError extends Error {
19
21
  this.body = body;
20
22
  }
21
23
  }
24
+ export class GameServicesBackendTransportError extends Error {
25
+ endpoint;
26
+ constructor(endpoint) {
27
+ super(`GameServices backend transport failed: ${endpoint}`);
28
+ this.name = 'GameServicesBackendTransportError';
29
+ this.endpoint = endpoint;
30
+ }
31
+ }
32
+ /** Credentials could not be resolved; no backend request was dispatched. */
33
+ export class GameServicesHeaderResolutionError extends Error {
34
+ constructor() {
35
+ super('Game Services request headers could not be resolved before dispatch.');
36
+ this.name = 'GameServicesHeaderResolutionError';
37
+ }
38
+ }
39
+ /** Resolve rotating credentials once and preserve static → dynamic → request precedence. */
40
+ export async function resolveGameServicesRequestHeaders(input) {
41
+ let dynamicHeaders;
42
+ try {
43
+ dynamicHeaders = await input.getHeaders?.();
44
+ }
45
+ catch {
46
+ // Resolver exceptions can contain credentials; expose a stable outcome.
47
+ throw new GameServicesHeaderResolutionError();
48
+ }
49
+ const merged = new Map();
50
+ for (const layer of [input.headers, dynamicHeaders, input.requestHeaders]) {
51
+ for (const [name, value] of Object.entries(layer ?? {})) {
52
+ merged.set(name.toLowerCase(), value);
53
+ }
54
+ }
55
+ return Object.fromEntries(merged);
56
+ }
22
57
  export function createGameServicesClient(input) {
23
58
  const now = input.now ?? (() => new Date().toISOString());
59
+ const requestNow = input.requestNow ?? now;
24
60
  const analytics = createAnalyticsReporter({
25
61
  target: input.target,
26
62
  sessionId: input.analyticsSessionId ?? input.playerId,
@@ -55,7 +91,7 @@ export function createGameServicesClient(input) {
55
91
  if (target === 'verse8') {
56
92
  const status = purchase.status === 'completed' ? 'rejected' : purchase.status;
57
93
  await analytics.track({
58
- name: 'purchase_rejected',
94
+ name: purchase.status === 'pending' ? 'purchase_pending' : 'purchase_rejected',
59
95
  properties: {
60
96
  productId: purchaseInput.productId,
61
97
  status,
@@ -90,7 +126,7 @@ export function createGameServicesClient(input) {
90
126
  }
91
127
  if (purchase.status !== 'completed' || purchase.transactionId === undefined) {
92
128
  await analytics.track({
93
- name: 'purchase_rejected',
129
+ name: purchase.status === 'pending' ? 'purchase_pending' : 'purchase_rejected',
94
130
  properties: {
95
131
  productId: purchaseInput.productId,
96
132
  status: purchase.status,
@@ -111,14 +147,14 @@ export function createGameServicesClient(input) {
111
147
  productId: purchaseInput.productId,
112
148
  platformTransactionId: purchase.transactionId,
113
149
  idempotencyKey: purchaseInput.idempotencyKey,
114
- purchasedAt: now(),
150
+ purchasedAt: requestNow(),
115
151
  ...(purchase.evidence === undefined ? {} : { evidence: purchase.evidence }),
116
152
  };
117
153
  progress.serverRequested();
118
154
  const verification = await input.backend.purchases.verifyPurchase(verificationRequest);
119
155
  progress.serverResult(verification.verified);
120
156
  const result = {
121
- status: verification.verified ? 'granted' : 'rejected',
157
+ status: verification.verified ? 'granted' : (verification.disposition ?? 'rejected'),
122
158
  purchase,
123
159
  verification,
124
160
  ...(verification.ledgerEntryId === undefined
@@ -126,7 +162,7 @@ export function createGameServicesClient(input) {
126
162
  : { ledgerEntryId: verification.ledgerEntryId }),
127
163
  };
128
164
  await analytics.track({
129
- name: verification.verified ? 'purchase_granted' : 'purchase_rejected',
165
+ name: verification.verified ? 'purchase_granted' : verification.disposition === 'pending' ? 'purchase_pending' : 'purchase_rejected',
130
166
  properties: {
131
167
  productId: purchaseInput.productId,
132
168
  status: result.status,
@@ -163,9 +199,10 @@ export function createGameServicesClient(input) {
163
199
  progress.platformRequested();
164
200
  const reward = await input.gateway.ads.showRewarded(rewardInput);
165
201
  progress.platformResult(reward.status);
166
- if (reward.status !== 'completed' || !reward.rewardGranted) {
202
+ if ((reward.status !== 'completed' || !reward.rewardGranted)
203
+ && !isAdMobClientRewardEvidence(reward)) {
167
204
  await analytics.track({
168
- name: 'rewarded_ad_rejected',
205
+ name: reward.status === 'pending' ? 'rewarded_ad_pending' : 'rewarded_ad_rejected',
169
206
  properties: {
170
207
  placementId: rewardInput.placementId,
171
208
  status: reward.status,
@@ -188,20 +225,20 @@ export function createGameServicesClient(input) {
188
225
  ? {}
189
226
  : { platformImpressionId: reward.ledgerEntryId }),
190
227
  idempotencyKey: rewardInput.idempotencyKey,
191
- completedAt: now(),
228
+ completedAt: requestNow(),
192
229
  ...(reward.evidence === undefined ? {} : { evidence: reward.evidence }),
193
230
  };
194
231
  progress.serverRequested();
195
232
  const claim = await input.backend.adRewards.claimAdReward(claimRequest);
196
233
  progress.serverResult(claim.granted);
197
234
  const result = {
198
- status: claim.granted ? 'granted' : 'rejected',
235
+ status: claim.granted ? 'granted' : (claim.disposition ?? 'rejected'),
199
236
  reward,
200
237
  claim,
201
238
  ...(claim.ledgerEntryId === undefined ? {} : { ledgerEntryId: claim.ledgerEntryId }),
202
239
  };
203
240
  await analytics.track({
204
- name: claim.granted ? 'rewarded_ad_granted' : 'rewarded_ad_rejected',
241
+ name: claim.granted ? 'rewarded_ad_granted' : claim.disposition === 'pending' ? 'rewarded_ad_pending' : 'rewarded_ad_rejected',
205
242
  properties: {
206
243
  placementId: rewardInput.placementId,
207
244
  status: result.status,
@@ -297,10 +334,15 @@ export function createGameServicesFetchBackendTransport(input) {
297
334
  const fetcher = input.fetch ?? readGlobalFetch();
298
335
  return {
299
336
  async send(request) {
337
+ const headers = await resolveGameServicesRequestHeaders({
338
+ headers: input.headers,
339
+ getHeaders: input.getHeaders,
340
+ requestHeaders: request.headers,
341
+ });
300
342
  const response = await fetcher(joinUrl(input.baseUrl, request.endpoint), {
301
343
  method: request.method,
302
344
  headers: {
303
- ...(input.headers ?? {}),
345
+ ...headers,
304
346
  'content-type': 'application/json',
305
347
  },
306
348
  body: JSON.stringify(request.body),
@@ -316,10 +358,10 @@ export function createGameServicesOrpcClient(input) {
316
358
  const link = new RPCLink({
317
359
  origin: input.url,
318
360
  ...(input.fetch === undefined ? {} : { fetch: input.fetch }),
319
- ...(input.headers === undefined
361
+ ...(input.headers === undefined && input.getHeaders === undefined
320
362
  ? {}
321
363
  : {
322
- headers: () => input.headers,
364
+ headers: () => resolveGameServicesRequestHeaders(input),
323
365
  }),
324
366
  });
325
367
  return createORPCClient(link);
@@ -364,12 +406,6 @@ function purchaseRejectionReason(purchase) {
364
406
  }
365
407
  return undefined;
366
408
  }
367
- function isAuthoritativeMicrosoftStoreCompletion(target, purchase) {
368
- return target === 'microsoft-store'
369
- && purchase.status === 'completed'
370
- && purchase.transactionId !== undefined
371
- && purchase.authoritativeGrant?.ledgerEntryId === purchase.transactionId;
372
- }
373
409
  function isGameServicesCommerceTarget(target) {
374
410
  return target === 'microsoft-store'
375
411
  || target === 'android'
@@ -389,11 +425,26 @@ function isGameServicesLeaderboardTarget(target) {
389
425
  || target === 'reddit';
390
426
  }
391
427
  async function sendGameServicesBackendRequest(transport, endpoint, body) {
392
- const response = await transport.send({
393
- method: 'POST',
394
- endpoint,
395
- body,
396
- });
428
+ let response;
429
+ try {
430
+ response = await transport.send({
431
+ method: 'POST',
432
+ endpoint,
433
+ body,
434
+ });
435
+ }
436
+ catch (error) {
437
+ if (error instanceof GameServicesHeaderResolutionError) {
438
+ throw error;
439
+ }
440
+ // Do not leak SDK or native transport exceptions (which can contain
441
+ // request headers); the caller must reconcile uncertain server outcomes.
442
+ throw new GameServicesBackendTransportError(endpoint);
443
+ }
444
+ if (!Number.isInteger(response?.status) || response.status < 100 || response.status > 599
445
+ || !Object.hasOwn(response, 'body')) {
446
+ throw new GameServicesBackendTransportError(endpoint);
447
+ }
397
448
  if (response.status < 200 || response.status >= 300) {
398
449
  throw new GameServicesBackendError(endpoint, response.status, response.body);
399
450
  }
@@ -0,0 +1,11 @@
1
+ import type { GooglePlayProductPurchaseClient } from './google-play-purchase.js';
2
+ export interface GooglePlayPublisherClientOptions {
3
+ /** Supplies an androidpublisher-scoped OAuth access token for each request. */
4
+ readonly getAccessToken: (signal: AbortSignal) => Promise<string> | string;
5
+ readonly fetch?: typeof fetch;
6
+ }
7
+ /**
8
+ * Google Play purchase verification and post-ledger finalization transport.
9
+ * The caller owns OAuth credential rotation and must never expose this client to the game.
10
+ */
11
+ export declare function createGooglePlayPublisherClient(options: GooglePlayPublisherClientOptions): GooglePlayProductPurchaseClient;
@@ -0,0 +1,113 @@
1
+ const publisherOrigin = 'https://androidpublisher.googleapis.com';
2
+ /**
3
+ * Google Play purchase verification and post-ledger finalization transport.
4
+ * The caller owns OAuth credential rotation and must never expose this client to the game.
5
+ */
6
+ export function createGooglePlayPublisherClient(options) {
7
+ const requestFetch = options.fetch ?? fetch;
8
+ async function request(method, path, signal) {
9
+ const accessToken = await options.getAccessToken(signal);
10
+ if (accessToken.length === 0 || /[\s\x00-\x1f\x7f]/u.test(accessToken)) {
11
+ throw new TypeError('Google Play Publisher access token is invalid.');
12
+ }
13
+ let response;
14
+ try {
15
+ response = await requestFetch(`${publisherOrigin}${path}`, {
16
+ method,
17
+ headers: { Authorization: `Bearer ${accessToken}` },
18
+ redirect: 'error',
19
+ signal,
20
+ });
21
+ }
22
+ catch {
23
+ if (signal.aborted) {
24
+ throw new DOMException('Google Play Publisher request was cancelled.', 'AbortError');
25
+ }
26
+ // Fetch errors can contain the request URL (including the purchase token).
27
+ throw new Error('Google Play Publisher request failed.');
28
+ }
29
+ if (!response.ok) {
30
+ await discardBody(response);
31
+ // Do not forward an error body or URL: both can contain purchase credentials.
32
+ throw new Error(`Google Play Publisher request failed (HTTP ${response.status}).`);
33
+ }
34
+ return response;
35
+ }
36
+ return {
37
+ async getProductPurchaseV2({ packageName, purchaseToken, signal }) {
38
+ const path = `${purchasePath(packageName)}/productsv2/tokens/${pathPart(purchaseToken)}`;
39
+ const response = await request('GET', path, signal);
40
+ try {
41
+ return await readBoundedJson(response);
42
+ }
43
+ catch {
44
+ if (signal.aborted) {
45
+ throw new DOMException('Google Play Publisher request was cancelled.', 'AbortError');
46
+ }
47
+ throw new Error('Google Play Publisher returned invalid purchase JSON.');
48
+ }
49
+ },
50
+ async acknowledgeProductPurchase({ packageName, productId, purchaseToken, signal }) {
51
+ const path = `${purchasePath(packageName)}/products/${pathPart(productId)}`
52
+ + `/tokens/${pathPart(purchaseToken)}:acknowledge`;
53
+ await discardBody(await request('POST', path, signal));
54
+ },
55
+ async consumeProductPurchase({ packageName, productId, purchaseToken, signal }) {
56
+ const path = `${purchasePath(packageName)}/products/${pathPart(productId)}`
57
+ + `/tokens/${pathPart(purchaseToken)}:consume`;
58
+ await discardBody(await request('POST', path, signal));
59
+ },
60
+ };
61
+ }
62
+ function purchasePath(packageName) {
63
+ return `/androidpublisher/v3/applications/${pathPart(packageName)}/purchases`;
64
+ }
65
+ function pathPart(value) {
66
+ if (value.length === 0
67
+ || value === '.'
68
+ || value === '..'
69
+ || value.trim() !== value
70
+ || /[\x00-\x1f\x7f]/u.test(value)) {
71
+ throw new TypeError('Google Play Publisher path parameter is invalid.');
72
+ }
73
+ return encodeURIComponent(value);
74
+ }
75
+ async function discardBody(response) {
76
+ await response.body?.cancel().catch(() => undefined);
77
+ }
78
+ async function readBoundedJson(response) {
79
+ const body = response.body;
80
+ if (body === null) {
81
+ throw new Error('Missing Google Play Publisher response body.');
82
+ }
83
+ const reader = body.getReader();
84
+ const chunks = [];
85
+ let size = 0;
86
+ try {
87
+ while (true) {
88
+ const next = await reader.read();
89
+ if (next.done) {
90
+ break;
91
+ }
92
+ size += next.value.byteLength;
93
+ if (size > 1024 * 1024) {
94
+ throw new Error('Google Play Publisher response exceeds maximum size.');
95
+ }
96
+ chunks.push(next.value);
97
+ }
98
+ }
99
+ catch (error) {
100
+ await reader.cancel().catch(() => undefined);
101
+ throw error;
102
+ }
103
+ finally {
104
+ reader.releaseLock();
105
+ }
106
+ const bytes = new Uint8Array(size);
107
+ let offset = 0;
108
+ for (const chunk of chunks) {
109
+ bytes.set(chunk, offset);
110
+ offset += chunk.byteLength;
111
+ }
112
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
113
+ }
@@ -38,3 +38,5 @@ export interface GooglePlayProductPurchaseBoundary extends GameServicesPurchaseG
38
38
  verifyPurchase(input: VerifyPurchaseEvidenceInput): ReturnType<GameServicesEvidenceVerifier['verifyPurchase']>;
39
39
  }
40
40
  export declare function createGooglePlayProductPurchaseBoundary(input: CreateGooglePlayProductPurchaseBoundaryInput): GooglePlayProductPurchaseBoundary;
41
+ /** A non-secret transaction ID for purchases whose Google order ID is absent or delayed. */
42
+ export declare function createGooglePlayTokenTransactionId(purchaseToken: string): Promise<string>;
@@ -45,10 +45,7 @@ export function createGooglePlayProductPurchaseBoundary(input) {
45
45
  purchaseToken,
46
46
  expectedProductId: verificationInput.platformProductId,
47
47
  expectedProductType: verificationInput.product.type,
48
- orderMatch: {
49
- mode: 'if-present',
50
- orderId: verificationInput.request.platformTransactionId,
51
- },
48
+ orderMatch: createGooglePlayObservedOrderMatch(verificationInput.request.platformTransactionId, await createGooglePlayTokenTransactionId(purchaseToken)),
52
49
  accountBinding,
53
50
  allowConsumed: false,
54
51
  signal: verificationInput.signal,
@@ -146,7 +143,7 @@ async function finalizeGooglePlayPurchase(input, packageName, purchaseToken, pro
146
143
  purchaseToken,
147
144
  expectedProductId: finalizationInput.platformProductId,
148
145
  expectedProductType: productType,
149
- orderMatch: createGooglePlayFinalizationOrderMatch(verifiedContext, finalizationInput.request.platformTransactionId),
146
+ orderMatch: createGooglePlayFinalizationOrderMatch(verifiedContext, finalizationInput.request.platformTransactionId, await createGooglePlayTokenTransactionId(purchaseToken)),
150
147
  accountBinding,
151
148
  allowConsumed: true,
152
149
  signal: finalizationInput.signal,
@@ -369,15 +366,32 @@ async function inspectGooglePlayPurchase(input) {
369
366
  },
370
367
  };
371
368
  }
372
- function createGooglePlayFinalizationOrderMatch(verifiedContext, clientOrderId) {
369
+ function createGooglePlayFinalizationOrderMatch(verifiedContext, clientOrderId, tokenTransactionId) {
373
370
  if (verifiedContext === undefined) {
374
- return { mode: 'if-present', orderId: clientOrderId };
371
+ return createGooglePlayObservedOrderMatch(clientOrderId, tokenTransactionId);
375
372
  }
376
373
  if (verifiedContext.orderId === undefined) {
377
374
  return { mode: 'token-only' };
378
375
  }
379
376
  return { mode: 'exact', orderId: verifiedContext.orderId };
380
377
  }
378
+ function createGooglePlayObservedOrderMatch(observedTransactionId, tokenTransactionId) {
379
+ return observedTransactionId === tokenTransactionId
380
+ ? { mode: 'token-only' }
381
+ : { mode: 'if-present', orderId: observedTransactionId };
382
+ }
383
+ /** A non-secret transaction ID for purchases whose Google order ID is absent or delayed. */
384
+ export async function createGooglePlayTokenTransactionId(purchaseToken) {
385
+ if (readOptionalIdentifier(purchaseToken, 4096) === undefined) {
386
+ throw new TypeError('Google Play purchase token is invalid.');
387
+ }
388
+ const subtle = globalThis.crypto?.subtle;
389
+ if (subtle === undefined) {
390
+ throw new Error('Web Crypto is required to hash Google Play purchase tokens.');
391
+ }
392
+ const digest = new Uint8Array(await subtle.digest('SHA-256', new TextEncoder().encode(purchaseToken)));
393
+ return `play-token-sha256:${[...digest].map((value) => value.toString(16).padStart(2, '0')).join('')}`;
394
+ }
381
395
  function readGooglePlayVerifiedContext(payload, packageName, productId, purchaseTokenDigest) {
382
396
  if (payload?.googlePlayPackageName !== packageName
383
397
  || payload.googlePlayProductId !== productId
@@ -0,0 +1,84 @@
1
+ import type { SecureCredentialStore } from '@mpgd/platform';
2
+ /** These identifiers are deliberately not interchangeable authentication claims. */
3
+ export interface GuestInstallationIdentity {
4
+ readonly installationId: string;
5
+ }
6
+ export interface VerifiedExternalAccount {
7
+ readonly provider: string;
8
+ readonly subject: string;
9
+ }
10
+ export interface NativeGamePlayerIdentity {
11
+ readonly platform: 'game-center' | 'play-games';
12
+ readonly platformPlayerId: string;
13
+ }
14
+ export interface StorePurchaseAccountBinding {
15
+ readonly store: 'app-store' | 'google-play';
16
+ readonly bindingId: string;
17
+ }
18
+ /** Only a trusted server may issue this opaque-token response. */
19
+ export interface ServerGuestSessionCredentials {
20
+ readonly serverUserId: string;
21
+ /** May rotate on refresh or binding; serverUserId remains the ownership key. */
22
+ readonly sessionId: string;
23
+ readonly identityLevel: 'guest' | 'authenticated';
24
+ readonly accessToken: string;
25
+ readonly refreshToken: string;
26
+ readonly accessExpiresAt: string;
27
+ }
28
+ /** Identity view: neither bearer token is returned to game code. */
29
+ export type ServerGuestSessionView = Omit<ServerGuestSessionCredentials, 'accessToken' | 'refreshToken'>;
30
+ export type AccountBindingOutcome = {
31
+ readonly status: 'bound' | 'already-bound';
32
+ readonly session: ServerGuestSessionCredentials;
33
+ } | {
34
+ readonly status: 'conflict';
35
+ };
36
+ /**
37
+ * Implement on the game backend. Refresh/revoke must authenticate the opaque
38
+ * refresh token, and bindAccount must verify both it and the external proof,
39
+ * atomically deduplicate by idempotencyKey, and return conflict for an account
40
+ * already owned by another server user. An installationId is never authority.
41
+ */
42
+ export interface GuestSessionBackend {
43
+ issueGuest(input: GuestInstallationIdentity): Promise<ServerGuestSessionCredentials>;
44
+ refresh(input: {
45
+ readonly refreshToken: string;
46
+ }): Promise<ServerGuestSessionCredentials>;
47
+ revoke(input: {
48
+ readonly refreshToken: string;
49
+ }): Promise<void>;
50
+ bindAccount(input: {
51
+ readonly refreshToken: string;
52
+ readonly externalProof: string;
53
+ readonly idempotencyKey: string;
54
+ }): Promise<AccountBindingOutcome>;
55
+ }
56
+ export type GuestSessionCoordinatorErrorCode = 'GUEST_SESSION_CLOSED' | 'GUEST_SESSION_NOT_READY' | 'GUEST_SESSION_INVALID_INPUT' | 'GUEST_SESSION_INVALID_RESPONSE' | 'GUEST_SESSION_OWNERSHIP_CONFLICT' | 'GUEST_SESSION_BACKEND_FAILED' | 'GUEST_SESSION_CREDENTIAL_LOAD_FAILED' | 'GUEST_SESSION_CREDENTIAL_SAVE_FAILED' | 'GUEST_SESSION_LOGOUT_UNCERTAIN';
57
+ /** Error messages intentionally contain no token, proof, or installation ID. */
58
+ export declare class GuestSessionCoordinatorError extends Error {
59
+ readonly code: GuestSessionCoordinatorErrorCode;
60
+ constructor(code: GuestSessionCoordinatorErrorCode);
61
+ }
62
+ export interface GuestSessionCoordinator {
63
+ start(): Promise<ServerGuestSessionView>;
64
+ refresh(): Promise<ServerGuestSessionView>;
65
+ bindAccount(input: {
66
+ readonly externalProof: string;
67
+ readonly idempotencyKey: string;
68
+ }): Promise<{
69
+ readonly status: 'bound' | 'already-bound' | 'conflict';
70
+ }>;
71
+ getHeaders(): Readonly<Record<'authorization', string>>;
72
+ logout(): Promise<void>;
73
+ }
74
+ /**
75
+ * Serializes token mutation and closes synchronously on logout. Native secure
76
+ * storage failures never fall back to ordinary game storage or mint a new guest.
77
+ */
78
+ export declare function createGuestSessionCoordinator(input: {
79
+ readonly backend: GuestSessionBackend;
80
+ readonly credentials: SecureCredentialStore;
81
+ readonly installationId: string;
82
+ readonly credentialKey?: string;
83
+ readonly now?: () => number;
84
+ }): GuestSessionCoordinator;