@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/README.md +82 -0
- package/dist/admob-client-reward.d.ts +3 -0
- package/dist/admob-client-reward.js +21 -0
- package/dist/admob-ssv.d.ts +2 -0
- package/dist/admob-ssv.js +2 -0
- package/dist/app-store-recovery.d.ts +38 -0
- package/dist/app-store-recovery.js +271 -0
- package/dist/app-store-verifier.d.ts +7 -0
- package/dist/app-store-verifier.js +41 -3
- package/dist/authoritative-purchase.d.ts +9 -0
- package/dist/authoritative-purchase.js +10 -0
- package/dist/client.d.ts +21 -0
- package/dist/client.js +75 -24
- package/dist/google-play-publisher-client.d.ts +11 -0
- package/dist/google-play-publisher-client.js +113 -0
- package/dist/google-play-purchase.d.ts +2 -0
- package/dist/google-play-purchase.js +21 -7
- package/dist/guest-session.d.ts +84 -0
- package/dist/guest-session.js +247 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/monetization-recovery.d.ts +63 -0
- package/dist/monetization-recovery.js +632 -0
- package/dist/operation-progress.d.ts +7 -1
- package/dist/operation-progress.js +23 -0
- package/dist/operation-results.d.ts +4 -0
- package/dist/operations.d.ts +1 -1
- package/dist/runtime.d.ts +17 -3
- package/dist/runtime.js +53 -19
- package/dist/server.js +94 -60
- package/dist/types.js +13 -0
- package/package.json +26 -5
package/README.md
CHANGED
|
@@ -71,9 +71,91 @@ and retry policy are not redesigned by this observer API.
|
|
|
71
71
|
The root `pnpm test` pipeline includes compiled client JS and public declaration
|
|
72
72
|
consumption checks alongside the client and target conformance suites.
|
|
73
73
|
|
|
74
|
+
## Runtime backend transport
|
|
75
|
+
|
|
76
|
+
`createGameServicesRuntime` accepts `httpTransport` for its HTTP JSON backend
|
|
77
|
+
path. This is a `GameServicesBackendTransport` with fixed Game Services
|
|
78
|
+
endpoints, not a general `fetch` implementation. It is never used for the
|
|
79
|
+
`orpc` path. A production runtime still requires a valid public HTTPS
|
|
80
|
+
`baseUrl`; supplying a custom transport does not enable a local backend or
|
|
81
|
+
silently fall back to the default network path.
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { createGameServicesRuntime } from '@mpgd/game-services/runtime';
|
|
85
|
+
import type { GameServicesBackendTransport } from '@mpgd/game-services/client';
|
|
86
|
+
|
|
87
|
+
declare const gateway: Parameters<typeof createGameServicesRuntime>[0]['gateway'];
|
|
88
|
+
declare const nativeJsonTransport: GameServicesBackendTransport;
|
|
89
|
+
declare const currentAccessToken: () => string;
|
|
90
|
+
|
|
91
|
+
const runtime = createGameServicesRuntime({
|
|
92
|
+
gateway,
|
|
93
|
+
playerId: 'game-player',
|
|
94
|
+
authorityMode: 'production',
|
|
95
|
+
baseUrl: 'https://api.example.com',
|
|
96
|
+
transport: 'http',
|
|
97
|
+
httpTransport: nativeJsonTransport,
|
|
98
|
+
getHeaders: () => ({ authorization: `Bearer ${currentAccessToken()}` }),
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`headers` remains available for static values. `getHeaders` is evaluated for
|
|
103
|
+
every backend request, including default HTTP and oRPC requests, so session
|
|
104
|
+
rotation does not retain an old token. An injected HTTP transport receives
|
|
105
|
+
those headers only with a Game Services endpoint request and must itself
|
|
106
|
+
enforce its configured HTTPS origin, redirect policy, and response limits;
|
|
107
|
+
the runtime cannot inspect a transport's internal network destinations.
|
|
108
|
+
Static headers are overridden by refreshed headers, then by explicit
|
|
109
|
+
per-request headers, with names compared case-insensitively. If `getHeaders`
|
|
110
|
+
fails, the runtime throws a sanitized
|
|
111
|
+
`GameServicesHeaderResolutionError` **before** sending the request; this is
|
|
112
|
+
distinct from an uncertain network outcome.
|
|
113
|
+
Non-2xx HTTP transport responses still become `GameServicesBackendError` and
|
|
114
|
+
are not retried through default fetch. Invalid responses and thrown native
|
|
115
|
+
transport errors become `GameServicesBackendTransportError` without embedding
|
|
116
|
+
the original exception, which might contain credentials. A failed request can
|
|
117
|
+
still have completed on the server: reconcile it before retrying a purchase
|
|
118
|
+
or reward claim. Do not forward these headers to an unrelated origin or copy
|
|
119
|
+
them into analytics or logs.
|
|
120
|
+
|
|
74
121
|
All published entrypoints use explicit internal ESM module paths and are smoke-tested
|
|
75
122
|
with native Node imports, without a bundler or TypeScript runtime loader.
|
|
76
123
|
|
|
124
|
+
## Guest session and account binding foundation
|
|
125
|
+
|
|
126
|
+
`createGuestSessionCoordinator` from `@mpgd/game-services/guest-session`
|
|
127
|
+
accepts a game-owned `GuestSessionBackend` and a dedicated
|
|
128
|
+
`SecureCredentialStore`. The kit does **not** issue tokens or infer server
|
|
129
|
+
authentication from `installationId` or a local `playerId`. The backend must
|
|
130
|
+
authenticate opaque refresh tokens, verify external account proofs, atomically
|
|
131
|
+
deduplicate account binding by idempotency key, return `conflict` for an account
|
|
132
|
+
owned by another server user, and revoke sessions durably. It must define
|
|
133
|
+
token rotation, expiry, replay response, idempotent revocation, and database
|
|
134
|
+
transactions. The session ID may rotate during refresh or binding, but the
|
|
135
|
+
server user ID must remain the same; the
|
|
136
|
+
injected contract is not a production identity provider by itself.
|
|
137
|
+
|
|
138
|
+
The coordinator loads and saves the refresh token only through secure native
|
|
139
|
+
credential storage. A load failure never silently starts a new guest, and a
|
|
140
|
+
failed save cannot expose a new access token as active. Concurrent refreshes
|
|
141
|
+
share one backend call; logout closes header access immediately and waits for
|
|
142
|
+
in-flight token changes before revoking the latest token and removing the
|
|
143
|
+
credential. Failed revocation or native removal leaves the credential intact
|
|
144
|
+
and allows a later logout retry; callers must treat an uncertain logout as a
|
|
145
|
+
server-side session that may still be live. Backend and native load errors are
|
|
146
|
+
normalized to token-free coordinator codes, not forwarded with raw messages.
|
|
147
|
+
`getHeaders()` can be passed as the Game Services runtime's
|
|
148
|
+
`getHeaders` resolver. The public session view omits both bearer tokens.
|
|
149
|
+
|
|
150
|
+
Binding an external account never switches to a different server user on a
|
|
151
|
+
client callback. It does not merge purchases, currency, or progress. Use the
|
|
152
|
+
existing `progress-link` nonce/idempotency service separately for a
|
|
153
|
+
server-verified guest-progress handoff. A platform player ID and store purchase
|
|
154
|
+
binding remain distinct from the server user and external provider subject.
|
|
155
|
+
The tests use a fake backend and secure-storage port; they do not prove an
|
|
156
|
+
OAuth provider, durable session database, or device Keychain/Keystore is
|
|
157
|
+
correctly configured.
|
|
158
|
+
|
|
77
159
|
Headless consumers can import operation input/result/progress types and the two-method
|
|
78
160
|
`GameServicesOperationClient` port from `@mpgd/game-services/operations`. This entrypoint
|
|
79
161
|
has no runtime implementation and its declarations require no DOM or fetch globals.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** This schema records SDK callback evidence, never an authoritative grant. */
|
|
2
|
+
export const admobClientRewardEvidenceSchema = 'mpgd.admob.client-reward.v1';
|
|
3
|
+
export function isAdMobClientRewardEvidence(value) {
|
|
4
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
5
|
+
return false;
|
|
6
|
+
}
|
|
7
|
+
const reward = value;
|
|
8
|
+
const evidence = reward.evidence;
|
|
9
|
+
if ((reward.status !== 'completed' && reward.status !== 'pending')
|
|
10
|
+
|| reward.rewardGranted !== false
|
|
11
|
+
|| reward.ledgerEntryId !== undefined
|
|
12
|
+
|| typeof evidence !== 'object' || evidence === null || Array.isArray(evidence)) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
const envelope = evidence;
|
|
16
|
+
const payload = envelope.payload;
|
|
17
|
+
return envelope.schema === admobClientRewardEvidenceSchema
|
|
18
|
+
&& typeof payload === 'object' && payload !== null && !Array.isArray(payload)
|
|
19
|
+
&& typeof payload.adUnitId === 'string'
|
|
20
|
+
&& payload.adUnitId.length > 0;
|
|
21
|
+
}
|
package/dist/admob-ssv.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { AdPlacementEntry } from '@mpgd/catalog';
|
|
|
2
2
|
import type { GameServicesEvidenceVerifier } from './evidence-verification.js';
|
|
3
3
|
import type { ClaimAdRewardRequest } from './types.js';
|
|
4
4
|
export declare const admobSsvCustomDataSchema = "mpgd.admob.ssv.binding.v1";
|
|
5
|
+
/** Maximum field length accepted by the shipped game-services SSV receiver. */
|
|
6
|
+
export declare const admobSsvMaximumBindingFieldLength = 256;
|
|
5
7
|
export declare const defaultAdMobSsvMaximumCallbackAgeMs = 86400000;
|
|
6
8
|
export declare const defaultAdMobSsvMaximumFutureSkewMs = 300000;
|
|
7
9
|
export interface AdMobSsvCustomDataBinding {
|
package/dist/admob-ssv.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Point, verify as verifySecp256k1 } from '@noble/secp256k1';
|
|
2
2
|
export const admobSsvCustomDataSchema = 'mpgd.admob.ssv.binding.v1';
|
|
3
|
+
/** Maximum field length accepted by the shipped game-services SSV receiver. */
|
|
4
|
+
export const admobSsvMaximumBindingFieldLength = 256;
|
|
3
5
|
export const defaultAdMobSsvMaximumCallbackAgeMs = 86_400_000;
|
|
4
6
|
export const defaultAdMobSsvMaximumFutureSkewMs = 300_000;
|
|
5
7
|
const signatureMarker = '&signature=';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type ProductCatalog } from '@mpgd/catalog';
|
|
2
|
+
import { type AppStoreEnvironment } from './app-store-verifier.js';
|
|
3
|
+
import type { PurchaseVerificationApi } from './client.js';
|
|
4
|
+
import type { GameServicesEvidenceVerifier } from './evidence-verification.js';
|
|
5
|
+
import type { GameServicesStore } from './server.js';
|
|
6
|
+
import type { VerifyPurchaseRequest, VerifyPurchaseResponse } from './types.js';
|
|
7
|
+
export type AppStoreRecoveryRequest = Omit<VerifyPurchaseRequest, 'target' | 'idempotencyKey' | 'evidence'> & {
|
|
8
|
+
readonly target: 'ios';
|
|
9
|
+
};
|
|
10
|
+
export interface AppStoreRestoredTransactionIdentity {
|
|
11
|
+
readonly originalTransactionId?: string;
|
|
12
|
+
readonly productType?: 'consumable' | 'non_consumable';
|
|
13
|
+
}
|
|
14
|
+
export interface CreateAppStoreRecoveryBackendOptions {
|
|
15
|
+
/** Bind this instance to a player authenticated by the game server. */
|
|
16
|
+
readonly playerId: string;
|
|
17
|
+
readonly deploymentTarget?: string;
|
|
18
|
+
readonly purchases: PurchaseVerificationApi;
|
|
19
|
+
readonly store: GameServicesStore;
|
|
20
|
+
/** Required to re-verify restored non-consumables whose current transaction ID changed. */
|
|
21
|
+
readonly restoredNonConsumables?: {
|
|
22
|
+
readonly catalog: ProductCatalog;
|
|
23
|
+
readonly evidenceVerifier: GameServicesEvidenceVerifier;
|
|
24
|
+
readonly bundleId: string;
|
|
25
|
+
readonly environment: AppStoreEnvironment;
|
|
26
|
+
readonly timeoutMs?: number;
|
|
27
|
+
};
|
|
28
|
+
/** Look up the original, durable checkout key; never derive one from the transaction ID. */
|
|
29
|
+
readonly resolveOriginalIdempotencyKey?: (input: AppStoreRecoveryRequest & AppStoreRestoredTransactionIdentity) => Promise<string | undefined>;
|
|
30
|
+
}
|
|
31
|
+
export interface AppStoreRecoveryBackend {
|
|
32
|
+
recoverPurchase(input: AppStoreRecoveryRequest & AppStoreRestoredTransactionIdentity): Promise<VerifyPurchaseResponse>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Connect StoreKit recovery to the existing authoritative purchase ledger.
|
|
36
|
+
* This is a server-side helper, not an authentication or Apple verifier.
|
|
37
|
+
*/
|
|
38
|
+
export declare function createAppStoreRecoveryBackend(options: CreateAppStoreRecoveryBackendOptions): AppStoreRecoveryBackend;
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { resolveProductPlatformId } from '@mpgd/catalog';
|
|
2
|
+
import { createAppStoreVerificationId, isAppStoreVerificationId, } from './app-store-verifier.js';
|
|
3
|
+
/**
|
|
4
|
+
* Connect StoreKit recovery to the existing authoritative purchase ledger.
|
|
5
|
+
* This is a server-side helper, not an authentication or Apple verifier.
|
|
6
|
+
*/
|
|
7
|
+
export function createAppStoreRecoveryBackend(options) {
|
|
8
|
+
if (options.playerId.trim() === '') {
|
|
9
|
+
throw new TypeError('App Store recovery requires an authenticated player ID.');
|
|
10
|
+
}
|
|
11
|
+
const restoreTimeoutMs = options.restoredNonConsumables?.timeoutMs;
|
|
12
|
+
if (restoreTimeoutMs !== undefined
|
|
13
|
+
&& (!Number.isSafeInteger(restoreTimeoutMs) || restoreTimeoutMs <= 0)) {
|
|
14
|
+
throw new TypeError('Restored App Store verification timeout must be positive.');
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
async recoverPurchase(input) {
|
|
18
|
+
if (input?.target !== 'ios' || input.playerId !== options.playerId
|
|
19
|
+
|| typeof input.productId !== 'string' || input.productId.trim() === ''
|
|
20
|
+
|| typeof input.platformTransactionId !== 'string'
|
|
21
|
+
|| input.platformTransactionId.trim() === ''
|
|
22
|
+
|| typeof input.purchasedAt !== 'string' || input.purchasedAt.trim() === ''
|
|
23
|
+
|| (options.deploymentTarget !== undefined
|
|
24
|
+
&& input.deploymentTarget !== undefined
|
|
25
|
+
&& input.deploymentTarget !== options.deploymentTarget)) {
|
|
26
|
+
return rejected('APP_STORE_RECOVERY_IDENTITY_MISMATCH');
|
|
27
|
+
}
|
|
28
|
+
let request = {
|
|
29
|
+
...input,
|
|
30
|
+
...(options.deploymentTarget === undefined
|
|
31
|
+
? {}
|
|
32
|
+
: { deploymentTarget: options.deploymentTarget }),
|
|
33
|
+
};
|
|
34
|
+
try {
|
|
35
|
+
const restoreConfig = options.restoredNonConsumables;
|
|
36
|
+
if (input.productType === 'non_consumable'
|
|
37
|
+
&& input.originalTransactionId !== undefined
|
|
38
|
+
&& input.originalTransactionId !== input.platformTransactionId
|
|
39
|
+
&& restoreConfig !== undefined) {
|
|
40
|
+
const candidateId = createAppStoreVerificationId({
|
|
41
|
+
environment: restoreConfig.environment,
|
|
42
|
+
bundleId: restoreConfig.bundleId,
|
|
43
|
+
transactionId: input.originalTransactionId,
|
|
44
|
+
});
|
|
45
|
+
const candidate = await findByVerificationId(options.store, candidateId);
|
|
46
|
+
if (candidate !== undefined) {
|
|
47
|
+
if (candidate.source !== 'purchase'
|
|
48
|
+
|| candidate.playerId !== request.playerId
|
|
49
|
+
|| candidate.grantId !== request.productId
|
|
50
|
+
|| candidate.payload.target !== 'ios'
|
|
51
|
+
|| candidate.payload.productType !== 'non_consumable'
|
|
52
|
+
|| candidate.payload.appStoreOriginalTransactionId
|
|
53
|
+
!== input.originalTransactionId) {
|
|
54
|
+
return rejected('APP_STORE_RECOVERY_IDENTITY_MISMATCH');
|
|
55
|
+
}
|
|
56
|
+
if (typeof candidate.payload.deploymentTarget === 'string') {
|
|
57
|
+
if (request.deploymentTarget !== undefined
|
|
58
|
+
&& request.deploymentTarget !== candidate.payload.deploymentTarget) {
|
|
59
|
+
return rejected('APP_STORE_RECOVERY_IDENTITY_MISMATCH');
|
|
60
|
+
}
|
|
61
|
+
request = { ...request, deploymentTarget: candidate.payload.deploymentTarget };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else if (options.deploymentTarget === undefined) {
|
|
65
|
+
// A request-supplied target is not a server-bound deployment identity.
|
|
66
|
+
return pending('APP_STORE_RECOVERY_DEPLOYMENT_TARGET_REQUIRED');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const restoredVerificationId = await verifyRestoredNonConsumable(input, request, options);
|
|
70
|
+
if (typeof restoredVerificationId !== 'string') {
|
|
71
|
+
return restoredVerificationId;
|
|
72
|
+
}
|
|
73
|
+
const platformGrant = await findByPlatformEvidence(options.store, request.platformTransactionId);
|
|
74
|
+
const originalGrant = restoredVerificationId === ''
|
|
75
|
+
? undefined
|
|
76
|
+
: await findByVerificationId(options.store, restoredVerificationId);
|
|
77
|
+
if (platformGrant !== undefined && originalGrant !== undefined
|
|
78
|
+
&& platformGrant.ledgerEntryId !== originalGrant.ledgerEntryId) {
|
|
79
|
+
return rejected('APP_STORE_RECOVERY_IDENTITY_MISMATCH');
|
|
80
|
+
}
|
|
81
|
+
const existing = platformGrant ?? originalGrant;
|
|
82
|
+
if (existing !== undefined
|
|
83
|
+
&& !matchesRecovery(existing, request, restoredVerificationId)) {
|
|
84
|
+
return rejected('APP_STORE_RECOVERY_IDENTITY_MISMATCH');
|
|
85
|
+
}
|
|
86
|
+
let key = existing?.idempotencyKey;
|
|
87
|
+
if (key === undefined) {
|
|
88
|
+
key = await options.resolveOriginalIdempotencyKey?.(request);
|
|
89
|
+
}
|
|
90
|
+
if (key === undefined || key.trim() === '') {
|
|
91
|
+
return pending('APP_STORE_ORIGINAL_PURCHASE_NOT_FOUND');
|
|
92
|
+
}
|
|
93
|
+
const keyedGrant = await findByIdempotency(options.store, options.playerId, key);
|
|
94
|
+
if (keyedGrant !== undefined
|
|
95
|
+
&& !matchesRecovery(keyedGrant, request, restoredVerificationId)) {
|
|
96
|
+
return rejected('APP_STORE_RECOVERY_IDENTITY_MISMATCH');
|
|
97
|
+
}
|
|
98
|
+
const priorGrant = existing ?? keyedGrant;
|
|
99
|
+
if (request.deploymentTarget === undefined
|
|
100
|
+
&& typeof priorGrant?.payload.deploymentTarget === 'string') {
|
|
101
|
+
request = { ...request, deploymentTarget: priorGrant.payload.deploymentTarget };
|
|
102
|
+
}
|
|
103
|
+
// A stored grant lets the backend's normal retry path return its original entry.
|
|
104
|
+
const response = await options.purchases.verifyPurchase({
|
|
105
|
+
...request,
|
|
106
|
+
idempotencyKey: key,
|
|
107
|
+
});
|
|
108
|
+
if (!response.verified) {
|
|
109
|
+
return response;
|
|
110
|
+
}
|
|
111
|
+
if (response.ledgerEntryId === undefined) {
|
|
112
|
+
return pending('APP_STORE_RECOVERY_LEDGER_UNAVAILABLE');
|
|
113
|
+
}
|
|
114
|
+
const recorded = await options.store.getEntitlementTransaction(response.ledgerEntryId);
|
|
115
|
+
if (recorded === undefined) {
|
|
116
|
+
return pending('APP_STORE_RECOVERY_LEDGER_UNAVAILABLE');
|
|
117
|
+
}
|
|
118
|
+
return matchesRecovery(recorded, request, restoredVerificationId)
|
|
119
|
+
? response
|
|
120
|
+
: rejected('APP_STORE_RECOVERY_IDENTITY_MISMATCH');
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return pending('APP_STORE_RECOVERY_DEPENDENCY_UNAVAILABLE');
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
async function verifyRestoredNonConsumable(input, request, options) {
|
|
129
|
+
if (input.productType !== 'non_consumable'
|
|
130
|
+
|| input.originalTransactionId === undefined
|
|
131
|
+
|| input.originalTransactionId === input.platformTransactionId) {
|
|
132
|
+
return '';
|
|
133
|
+
}
|
|
134
|
+
const config = options.restoredNonConsumables;
|
|
135
|
+
if (config === undefined) {
|
|
136
|
+
return pending('APP_STORE_RESTORE_VERIFIER_REQUIRED');
|
|
137
|
+
}
|
|
138
|
+
const product = config.catalog.products.find((item) => item.id === request.productId);
|
|
139
|
+
if (product?.type !== 'non_consumable') {
|
|
140
|
+
return rejected('APP_STORE_RECOVERY_PRODUCT_TYPE_MISMATCH');
|
|
141
|
+
}
|
|
142
|
+
const platformProductId = resolveProductPlatformId(product, request.deploymentTarget ?? request.target);
|
|
143
|
+
if (platformProductId === undefined) {
|
|
144
|
+
return rejected('APP_STORE_RECOVERY_PRODUCT_UNAVAILABLE');
|
|
145
|
+
}
|
|
146
|
+
const timeoutMs = config.timeoutMs ?? 10_000;
|
|
147
|
+
const controller = new AbortController();
|
|
148
|
+
let timeoutHandle;
|
|
149
|
+
const timeout = new Promise((resolve) => {
|
|
150
|
+
timeoutHandle = setTimeout(() => {
|
|
151
|
+
resolve({ status: 'pending', reason: 'APP_STORE_RESTORE_VERIFICATION_TIMEOUT' });
|
|
152
|
+
controller.abort();
|
|
153
|
+
}, timeoutMs);
|
|
154
|
+
});
|
|
155
|
+
// This probe calls only the evidence verifier; it never writes a ledger grant.
|
|
156
|
+
let decision;
|
|
157
|
+
try {
|
|
158
|
+
decision = await Promise.race([
|
|
159
|
+
config.evidenceVerifier.verifyPurchase({
|
|
160
|
+
request: { ...request, idempotencyKey: 'app-store-recovery-evidence-probe' },
|
|
161
|
+
product,
|
|
162
|
+
platformProductId,
|
|
163
|
+
signal: controller.signal,
|
|
164
|
+
timeoutMs,
|
|
165
|
+
}),
|
|
166
|
+
timeout,
|
|
167
|
+
]);
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
if (timeoutHandle !== undefined) {
|
|
171
|
+
clearTimeout(timeoutHandle);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (decision.status === 'pending') {
|
|
175
|
+
return pending(decision.reason ?? 'APP_STORE_RESTORE_VERIFICATION_PENDING');
|
|
176
|
+
}
|
|
177
|
+
if (decision.status === 'rejected') {
|
|
178
|
+
return rejected(decision.reason);
|
|
179
|
+
}
|
|
180
|
+
const originalTransactionId = decision.payload?.appStoreOriginalTransactionId;
|
|
181
|
+
const environment = decision.payload?.appStoreEnvironment;
|
|
182
|
+
const bundleId = decision.payload?.appStoreBundleId;
|
|
183
|
+
if (decision.payload?.appStoreTransactionType !== 'Non-Consumable'
|
|
184
|
+
|| originalTransactionId !== input.originalTransactionId
|
|
185
|
+
|| (environment !== 'Production' && environment !== 'Sandbox')
|
|
186
|
+
|| environment !== config.environment
|
|
187
|
+
|| bundleId !== config.bundleId
|
|
188
|
+
|| decision.verificationId !== createAppStoreVerificationId({
|
|
189
|
+
environment,
|
|
190
|
+
bundleId,
|
|
191
|
+
transactionId: originalTransactionId,
|
|
192
|
+
})) {
|
|
193
|
+
return rejected('APP_STORE_RESTORE_EVIDENCE_MISMATCH');
|
|
194
|
+
}
|
|
195
|
+
return decision.verificationId;
|
|
196
|
+
}
|
|
197
|
+
async function findByPlatformEvidence(store, platformTransactionId) {
|
|
198
|
+
const identity = {
|
|
199
|
+
source: 'purchase',
|
|
200
|
+
target: 'ios',
|
|
201
|
+
platformEvidenceId: platformTransactionId,
|
|
202
|
+
};
|
|
203
|
+
if (store.findEntitlementTransactionByPlatformEvidence !== undefined) {
|
|
204
|
+
return store.findEntitlementTransactionByPlatformEvidence(identity);
|
|
205
|
+
}
|
|
206
|
+
return (await store.listEntitlementTransactions()).find((transaction) => {
|
|
207
|
+
return transaction.source === identity.source
|
|
208
|
+
&& transaction.payload.target === identity.target
|
|
209
|
+
&& transaction.payload.platformTransactionId === identity.platformEvidenceId;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
async function findByIdempotency(store, playerId, idempotencyKey) {
|
|
213
|
+
const identity = { source: 'purchase', playerId, idempotencyKey };
|
|
214
|
+
if (store.findEntitlementTransactionByIdempotency !== undefined) {
|
|
215
|
+
return store.findEntitlementTransactionByIdempotency(identity);
|
|
216
|
+
}
|
|
217
|
+
return (await store.listEntitlementTransactions()).find((transaction) => {
|
|
218
|
+
return transaction.source === identity.source
|
|
219
|
+
&& transaction.playerId === identity.playerId
|
|
220
|
+
&& transaction.idempotencyKey === identity.idempotencyKey;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
async function findByVerificationId(store, evidenceVerificationId) {
|
|
224
|
+
const identity = { source: 'purchase', evidenceVerificationId };
|
|
225
|
+
if (store.findEntitlementTransactionByEvidenceVerificationId !== undefined) {
|
|
226
|
+
return store.findEntitlementTransactionByEvidenceVerificationId(identity);
|
|
227
|
+
}
|
|
228
|
+
return (await store.listEntitlementTransactions()).find((transaction) => {
|
|
229
|
+
return transaction.source === identity.source
|
|
230
|
+
&& (transaction.evidenceVerificationId
|
|
231
|
+
?? transaction.payload.evidenceVerificationId) === identity.evidenceVerificationId;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
function matchesRecovery(transaction, request, restoredVerificationId = '') {
|
|
235
|
+
const verificationId = transaction.evidenceVerificationId
|
|
236
|
+
?? transaction.payload.evidenceVerificationId;
|
|
237
|
+
const environment = transaction.payload.appStoreEnvironment;
|
|
238
|
+
const bundleId = transaction.payload.appStoreBundleId;
|
|
239
|
+
const originalTransactionId = transaction.payload.appStoreOriginalTransactionId;
|
|
240
|
+
const grantTransactionId = restoredVerificationId === ''
|
|
241
|
+
? request.platformTransactionId
|
|
242
|
+
: originalTransactionId;
|
|
243
|
+
return transaction.source === 'purchase'
|
|
244
|
+
&& transaction.playerId === request.playerId
|
|
245
|
+
&& transaction.grantId === request.productId
|
|
246
|
+
&& transaction.payload.target === 'ios'
|
|
247
|
+
&& (request.deploymentTarget === undefined
|
|
248
|
+
|| transaction.payload.deploymentTarget === request.deploymentTarget)
|
|
249
|
+
&& (restoredVerificationId === ''
|
|
250
|
+
? transaction.payload.platformTransactionId === request.platformTransactionId
|
|
251
|
+
: transaction.payload.productType === 'non_consumable'
|
|
252
|
+
&& transaction.payload.appStoreOriginalTransactionId
|
|
253
|
+
=== request.originalTransactionId
|
|
254
|
+
&& verificationId === restoredVerificationId)
|
|
255
|
+
&& typeof verificationId === 'string'
|
|
256
|
+
&& isAppStoreVerificationId(verificationId)
|
|
257
|
+
&& (environment === 'Production' || environment === 'Sandbox')
|
|
258
|
+
&& typeof bundleId === 'string'
|
|
259
|
+
&& typeof grantTransactionId === 'string'
|
|
260
|
+
&& verificationId === createAppStoreVerificationId({
|
|
261
|
+
environment,
|
|
262
|
+
bundleId,
|
|
263
|
+
transactionId: grantTransactionId,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
function pending(reason) {
|
|
267
|
+
return { verified: false, disposition: 'pending', alreadyProcessed: false, reason };
|
|
268
|
+
}
|
|
269
|
+
function rejected(reason) {
|
|
270
|
+
return { verified: false, disposition: 'rejected', alreadyProcessed: false, reason };
|
|
271
|
+
}
|
|
@@ -84,4 +84,11 @@ export type AppStoreFetch = (url: string, init: {
|
|
|
84
84
|
}) => Promise<AppStoreFetchResponse>;
|
|
85
85
|
export declare function createAppStoreServerApiClient(options: CreateAppStoreServerApiClientOptions): AppStoreServerApiClient;
|
|
86
86
|
export declare function createAppStoreGameServicesEvidenceVerifier(options: CreateAppStoreEvidenceVerifierOptions): GameServicesEvidenceVerifier;
|
|
87
|
+
export declare function createAppStoreVerificationId(input: {
|
|
88
|
+
readonly environment: AppStoreEnvironment;
|
|
89
|
+
readonly bundleId: string;
|
|
90
|
+
readonly transactionId: string;
|
|
91
|
+
}): string;
|
|
92
|
+
/** Identify IDs emitted by this verifier without duplicating its encoding format. */
|
|
93
|
+
export declare function isAppStoreVerificationId(verificationId: string): boolean;
|
|
87
94
|
export declare function assertAppStoreTransactionPayload(input: unknown): AppStoreTransactionPayload;
|
|
@@ -248,13 +248,51 @@ function createVerificationId(transaction) {
|
|
|
248
248
|
const grantTransactionId = transaction.type === 'Non-Consumable'
|
|
249
249
|
? transaction.originalTransactionId
|
|
250
250
|
: transaction.transactionId;
|
|
251
|
+
return createAppStoreVerificationId({
|
|
252
|
+
environment: transaction.environment,
|
|
253
|
+
bundleId: transaction.bundleId,
|
|
254
|
+
transactionId: grantTransactionId,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
export function createAppStoreVerificationId(input) {
|
|
251
258
|
return [
|
|
252
259
|
'app-store',
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
260
|
+
input.environment,
|
|
261
|
+
input.bundleId,
|
|
262
|
+
input.transactionId,
|
|
256
263
|
].map(encodeVerificationIdSegment).join(':');
|
|
257
264
|
}
|
|
265
|
+
/** Identify IDs emitted by this verifier without duplicating its encoding format. */
|
|
266
|
+
export function isAppStoreVerificationId(verificationId) {
|
|
267
|
+
const parts = verificationId.split(':');
|
|
268
|
+
if (parts.length !== 8) {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
const values = [];
|
|
272
|
+
for (let index = 0; index < parts.length; index += 2) {
|
|
273
|
+
const rawLength = parts[index];
|
|
274
|
+
const encoded = parts[index + 1];
|
|
275
|
+
if (rawLength === undefined || encoded === undefined
|
|
276
|
+
|| !/^(0|[1-9][0-9]*)$/u.test(rawLength)) {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
const value = decodeURIComponent(encoded);
|
|
281
|
+
if (value.length !== Number(rawLength)
|
|
282
|
+
|| encodeVerificationIdSegment(value) !== `${rawLength}:${encoded}`) {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
values.push(value);
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return values[0] === 'app-store'
|
|
292
|
+
&& (values[1] === 'Production' || values[1] === 'Sandbox')
|
|
293
|
+
&& typeof values[2] === 'string' && values[2].length > 0
|
|
294
|
+
&& typeof values[3] === 'string' && /^[1-9][0-9]{0,19}$/u.test(values[3]);
|
|
295
|
+
}
|
|
258
296
|
function encodeVerificationIdSegment(value) {
|
|
259
297
|
return `${value.length}:${encodeURIComponent(value)}`;
|
|
260
298
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PurchaseResult } from '@mpgd/platform';
|
|
2
|
+
/**
|
|
3
|
+
* Internal: the Microsoft Store adapter completes the ledger grant before it returns this
|
|
4
|
+
* checkout result, so no further backend verification is requested for it.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isAuthoritativeMicrosoftStoreCompletion(target: string, purchase: PurchaseResult): purchase is PurchaseResult & {
|
|
7
|
+
readonly status: 'completed';
|
|
8
|
+
readonly transactionId: string;
|
|
9
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal: the Microsoft Store adapter completes the ledger grant before it returns this
|
|
3
|
+
* checkout result, so no further backend verification is requested for it.
|
|
4
|
+
*/
|
|
5
|
+
export function isAuthoritativeMicrosoftStoreCompletion(target, purchase) {
|
|
6
|
+
return target === 'microsoft-store'
|
|
7
|
+
&& purchase.status === 'completed'
|
|
8
|
+
&& purchase.transactionId !== undefined
|
|
9
|
+
&& purchase.authoritativeGrant?.ledgerEntryId === purchase.transactionId;
|
|
10
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface GameServicesBackendTransportRequest<TBody = unknown> {
|
|
|
30
30
|
readonly method: 'POST';
|
|
31
31
|
readonly endpoint: GameServicesBackendEndpoint;
|
|
32
32
|
readonly body: TBody;
|
|
33
|
+
/** Resolved for this request only; custom transports must not forward elsewhere. */
|
|
34
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
33
35
|
}
|
|
34
36
|
export interface GameServicesBackendTransportResponse<TBody = unknown> {
|
|
35
37
|
readonly status: number;
|
|
@@ -45,12 +47,15 @@ export interface CreateGameServicesFetchBackendTransportInput {
|
|
|
45
47
|
readonly baseUrl: string;
|
|
46
48
|
readonly fetch?: GameServicesFetch;
|
|
47
49
|
readonly headers?: Record<string, string>;
|
|
50
|
+
readonly getHeaders?: GameServicesHeaderResolver;
|
|
48
51
|
}
|
|
49
52
|
export interface CreateGameServicesOrpcClientInput {
|
|
50
53
|
readonly url: string;
|
|
51
54
|
readonly fetch?: typeof fetch;
|
|
52
55
|
readonly headers?: Record<string, string>;
|
|
56
|
+
readonly getHeaders?: GameServicesHeaderResolver;
|
|
53
57
|
}
|
|
58
|
+
export type GameServicesHeaderResolver = () => Readonly<Record<string, string>> | Promise<Readonly<Record<string, string>>>;
|
|
54
59
|
export type GameServicesFetch = (url: string, init: {
|
|
55
60
|
readonly method: 'POST';
|
|
56
61
|
readonly headers: Record<string, string>;
|
|
@@ -67,6 +72,20 @@ export declare class GameServicesBackendError extends Error {
|
|
|
67
72
|
readonly body: unknown;
|
|
68
73
|
constructor(endpoint: GameServicesBackendEndpoint, status: number, body: unknown);
|
|
69
74
|
}
|
|
75
|
+
export declare class GameServicesBackendTransportError extends Error {
|
|
76
|
+
readonly endpoint: GameServicesBackendEndpoint;
|
|
77
|
+
constructor(endpoint: GameServicesBackendEndpoint);
|
|
78
|
+
}
|
|
79
|
+
/** Credentials could not be resolved; no backend request was dispatched. */
|
|
80
|
+
export declare class GameServicesHeaderResolutionError extends Error {
|
|
81
|
+
constructor();
|
|
82
|
+
}
|
|
83
|
+
/** Resolve rotating credentials once and preserve static → dynamic → request precedence. */
|
|
84
|
+
export declare function resolveGameServicesRequestHeaders(input: {
|
|
85
|
+
readonly headers?: Readonly<Record<string, string>> | undefined;
|
|
86
|
+
readonly getHeaders?: GameServicesHeaderResolver | undefined;
|
|
87
|
+
readonly requestHeaders?: Readonly<Record<string, string>> | undefined;
|
|
88
|
+
}): Promise<Readonly<Record<string, string>>>;
|
|
70
89
|
export interface GameServicesClient extends GameServicesOperationClient {
|
|
71
90
|
submitLeaderboardScore(input: GameServicesLeaderboardInput): Promise<GameServicesLeaderboardResult>;
|
|
72
91
|
}
|
|
@@ -79,6 +98,8 @@ export interface CreateGameServicesClientInput {
|
|
|
79
98
|
readonly analytics?: AnalyticsSink;
|
|
80
99
|
readonly analyticsSessionId?: string;
|
|
81
100
|
readonly now?: () => string;
|
|
101
|
+
/** Override backend evidence timestamps without backdating analytics events. */
|
|
102
|
+
readonly requestNow?: () => string;
|
|
82
103
|
}
|
|
83
104
|
export interface GameServicesLeaderboardInput extends LeaderboardScoreInput {
|
|
84
105
|
}
|