@mpgd/game-services 0.19.1 → 0.21.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 +16 -0
- package/dist/app-store-server.d.ts +12 -0
- package/dist/app-store-server.js +119 -0
- package/dist/app-store-verifier-conformance.d.ts +20 -1
- package/dist/app-store-verifier-conformance.js +22 -0
- package/dist/app-store-verifier.d.ts +38 -0
- package/dist/app-store-verifier.js +164 -43
- package/dist/authoritative-purchase.d.ts +24 -1
- package/dist/authoritative-purchase.js +52 -0
- package/dist/client.js +13 -5
- package/dist/google-play-purchase.d.ts +9 -2
- package/dist/google-play-purchase.js +12 -14
- package/dist/server.js +22 -1
- package/dist/types.js +5 -2
- package/package.json +21 -6
package/README.md
CHANGED
|
@@ -4,6 +4,22 @@ Client, contract, backend, and store helpers for authoritative game services.
|
|
|
4
4
|
See [backend integration](../../docs/GAME_SERVICES_BACKEND.md) for the existing
|
|
5
5
|
verification and ledger boundaries.
|
|
6
6
|
|
|
7
|
+
## App Store server-side transaction signatures
|
|
8
|
+
|
|
9
|
+
`@mpgd/game-services/app-store-server` exports
|
|
10
|
+
`createAppleSignedTransactionVerifier`, an `AppStoreSignedTransactionVerifier`
|
|
11
|
+
over the optional `@apple/app-store-server-library@3.1.0` peer. It verifies the
|
|
12
|
+
App Store JWS certificate chain and signature before exposing any decoded
|
|
13
|
+
one-time purchase field, keeps Apple's retryable certificate-status failures
|
|
14
|
+
pending (`AppStoreDependencyUnavailableError`), and rejects invalid signatures,
|
|
15
|
+
subscription payloads, and oversized transactions. Supply DER-encoded Apple
|
|
16
|
+
root certificates, the bundle ID, environment, and production App Apple ID,
|
|
17
|
+
and pass the result as `signedTransactionVerifier` to
|
|
18
|
+
`createAppStoreGameServicesEvidenceVerifier`. Backend only: the peer is not
|
|
19
|
+
installed by game clients, and no other subpath imports it. It moved here from
|
|
20
|
+
`@mpgd/adapter-capacitor/app-store-server`, which stays as a deprecated
|
|
21
|
+
re-export for one release.
|
|
22
|
+
|
|
7
23
|
## Registered rewarded-ad evidence
|
|
8
24
|
|
|
9
25
|
`createGameServicesClient`, `createRecoverableMonetizationClient`, and
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type AppStoreEnvironment, type AppStoreSignedTransactionVerifier } from './app-store-verifier.js';
|
|
2
|
+
export interface CreateAppleSignedTransactionVerifierOptions {
|
|
3
|
+
/** DER-encoded Apple root certificates, supplied by the server operator. */
|
|
4
|
+
readonly rootCertificates: readonly Uint8Array[];
|
|
5
|
+
readonly environment: AppStoreEnvironment;
|
|
6
|
+
readonly bundleId: string;
|
|
7
|
+
/** Required by Apple's verifier in production. */
|
|
8
|
+
readonly appAppleId?: number;
|
|
9
|
+
readonly enableOnlineChecks?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/** Verify the App Store JWS before exposing any decoded purchase fields. */
|
|
12
|
+
export declare function createAppleSignedTransactionVerifier(options: CreateAppleSignedTransactionVerifierOptions): AppStoreSignedTransactionVerifier;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Environment, SignedDataVerifier, VerificationException, VerificationStatus, } from '@apple/app-store-server-library';
|
|
2
|
+
import { AppStoreDependencyUnavailableError, } from './app-store-verifier.js';
|
|
3
|
+
/** Verify the App Store JWS before exposing any decoded purchase fields. */
|
|
4
|
+
export function createAppleSignedTransactionVerifier(options) {
|
|
5
|
+
if (!Array.isArray(options.rootCertificates)
|
|
6
|
+
|| options.rootCertificates.length === 0
|
|
7
|
+
|| options.rootCertificates.some((certificate) => !(certificate instanceof Uint8Array)
|
|
8
|
+
|| certificate.length === 0)) {
|
|
9
|
+
throw new TypeError('Apple root certificates are required.');
|
|
10
|
+
}
|
|
11
|
+
if (options.environment !== 'Production' && options.environment !== 'Sandbox') {
|
|
12
|
+
throw new TypeError('App Store environment must be Production or Sandbox.');
|
|
13
|
+
}
|
|
14
|
+
if (options.bundleId.trim() === '') {
|
|
15
|
+
throw new TypeError('App Store bundle ID is required.');
|
|
16
|
+
}
|
|
17
|
+
if (options.environment === 'Production'
|
|
18
|
+
&& (!Number.isSafeInteger(options.appAppleId) || (options.appAppleId ?? 0) <= 0)) {
|
|
19
|
+
throw new TypeError('Production App Store verification requires appAppleId.');
|
|
20
|
+
}
|
|
21
|
+
if (options.environment === 'Production' && options.enableOnlineChecks === false) {
|
|
22
|
+
throw new TypeError('Production App Store verification requires online certificate checks.');
|
|
23
|
+
}
|
|
24
|
+
const verifier = new SignedDataVerifier(options.rootCertificates.map((certificate) => Buffer.from(certificate)), options.enableOnlineChecks ?? true, options.environment === 'Production' ? Environment.PRODUCTION : Environment.SANDBOX, options.bundleId, options.appAppleId);
|
|
25
|
+
return {
|
|
26
|
+
async verifyAndDecode(input) {
|
|
27
|
+
if (input.environment !== options.environment || input.bundleId !== options.bundleId) {
|
|
28
|
+
return { status: 'rejected', reason: 'APP_STORE_VERIFIER_SCOPE_MISMATCH' };
|
|
29
|
+
}
|
|
30
|
+
if (typeof input.signedTransaction !== 'string'
|
|
31
|
+
|| input.signedTransaction.length === 0
|
|
32
|
+
|| input.signedTransaction.length > 512 * 1024) {
|
|
33
|
+
return { status: 'rejected', reason: 'APP_STORE_SIGNED_TRANSACTION_INVALID' };
|
|
34
|
+
}
|
|
35
|
+
input.signal.throwIfAborted();
|
|
36
|
+
let decoded;
|
|
37
|
+
try {
|
|
38
|
+
decoded = await waitForVerification(verifier.verifyAndDecodeTransaction(input.signedTransaction), input.signal);
|
|
39
|
+
input.signal.throwIfAborted();
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (input.signal.aborted) {
|
|
43
|
+
throw input.signal.reason;
|
|
44
|
+
}
|
|
45
|
+
if (error instanceof VerificationException) {
|
|
46
|
+
if (error.status === VerificationStatus.RETRYABLE_VERIFICATION_FAILURE) {
|
|
47
|
+
throw new AppStoreDependencyUnavailableError('Apple signature checks are temporarily unavailable.', error);
|
|
48
|
+
}
|
|
49
|
+
return { status: 'rejected', reason: 'APP_STORE_SIGNATURE_INVALID' };
|
|
50
|
+
}
|
|
51
|
+
// Only Apple's explicit retryable status is a transient dependency
|
|
52
|
+
// failure. Unexpected adapter/library faults must reach the caller.
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
const payload = decodeOneTimeTransaction(decoded);
|
|
56
|
+
return payload === undefined
|
|
57
|
+
? { status: 'rejected', reason: 'APP_STORE_SIGNED_TRANSACTION_INVALID' }
|
|
58
|
+
: { status: 'verified', payload };
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
async function waitForVerification(operation, signal) {
|
|
63
|
+
signal.throwIfAborted();
|
|
64
|
+
let onAbort;
|
|
65
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
66
|
+
onAbort = () => reject(signal.reason ?? new Error('Verification was aborted.'));
|
|
67
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
68
|
+
if (signal.aborted) {
|
|
69
|
+
onAbort();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
try {
|
|
73
|
+
return await Promise.race([operation, aborted]);
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
if (onAbort !== undefined) {
|
|
77
|
+
signal.removeEventListener('abort', onAbort);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function decodeOneTimeTransaction(decoded) {
|
|
82
|
+
if (!nonEmpty(decoded.transactionId)
|
|
83
|
+
|| !nonEmpty(decoded.originalTransactionId)
|
|
84
|
+
|| !nonEmpty(decoded.bundleId)
|
|
85
|
+
|| !nonEmpty(decoded.productId)
|
|
86
|
+
|| !validTimestamp(decoded.purchaseDate)
|
|
87
|
+
|| !validTimestamp(decoded.signedDate)
|
|
88
|
+
|| (decoded.environment !== 'Production' && decoded.environment !== 'Sandbox')
|
|
89
|
+
|| (decoded.type !== 'Consumable' && decoded.type !== 'Non-Consumable')
|
|
90
|
+
|| (decoded.quantity !== undefined
|
|
91
|
+
&& (!Number.isSafeInteger(decoded.quantity) || decoded.quantity <= 0))
|
|
92
|
+
|| (decoded.appAccountToken !== undefined && !nonEmpty(decoded.appAccountToken))
|
|
93
|
+
|| (decoded.revocationDate !== undefined && !validTimestamp(decoded.revocationDate))
|
|
94
|
+
|| (decoded.expiresDate !== undefined && !validTimestamp(decoded.expiresDate))
|
|
95
|
+
|| (decoded.isUpgraded !== undefined && typeof decoded.isUpgraded !== 'boolean')) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
transactionId: decoded.transactionId,
|
|
100
|
+
originalTransactionId: decoded.originalTransactionId,
|
|
101
|
+
bundleId: decoded.bundleId,
|
|
102
|
+
productId: decoded.productId,
|
|
103
|
+
purchaseDate: decoded.purchaseDate,
|
|
104
|
+
signedDate: decoded.signedDate,
|
|
105
|
+
environment: decoded.environment,
|
|
106
|
+
type: decoded.type,
|
|
107
|
+
...(decoded.quantity === undefined ? {} : { quantity: decoded.quantity }),
|
|
108
|
+
...(decoded.appAccountToken === undefined ? {} : { appAccountToken: decoded.appAccountToken }),
|
|
109
|
+
...(decoded.revocationDate === undefined ? {} : { revocationDate: decoded.revocationDate }),
|
|
110
|
+
...(decoded.expiresDate === undefined ? {} : { expiresDate: decoded.expiresDate }),
|
|
111
|
+
...(decoded.isUpgraded === undefined ? {} : { isUpgraded: decoded.isUpgraded }),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function nonEmpty(value) {
|
|
115
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
116
|
+
}
|
|
117
|
+
function validTimestamp(value) {
|
|
118
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
|
119
|
+
}
|
|
@@ -1,4 +1,23 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PlatformEvidenceEnvelope } from '@mpgd/platform';
|
|
2
|
+
import { type AppStoreEnvironment, type AppStoreInAppPurchaseType, type AppStoreTransactionPayload } from './app-store-verifier.js';
|
|
3
|
+
/** The placeholder JWS the signed-transaction evidence fixture carries; never a real signature. */
|
|
4
|
+
export declare const appStoreConformanceSignedTransaction = "conformance.signed-transaction.jws";
|
|
5
|
+
export interface CreateAppStoreSignedTransactionEvidenceFixtureInput {
|
|
6
|
+
readonly signedTransaction?: string;
|
|
7
|
+
readonly transactionId?: string;
|
|
8
|
+
readonly originalTransactionId?: string;
|
|
9
|
+
readonly bundleId?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Builds the `app-store.signed-transaction.v1` evidence envelope a commerce v2
|
|
13
|
+
* StoreKit client submits, with a placeholder JWS by default. Pair it with a
|
|
14
|
+
* fake `AppStoreSignedTransactionVerifier` that answers
|
|
15
|
+
* `createAppStoreTransactionConformanceFixture` for that string and, under the
|
|
16
|
+
* default `server-api` policy, a fake Server API whose `signedTransactionInfo`
|
|
17
|
+
* decodes to the transaction's current status; the fixture is not signed
|
|
18
|
+
* evidence and contains no Apple credentials.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createAppStoreSignedTransactionEvidenceFixture(input?: CreateAppStoreSignedTransactionEvidenceFixtureInput): PlatformEvidenceEnvelope;
|
|
2
21
|
export interface CreateAppStoreTransactionConformanceFixtureInput {
|
|
3
22
|
readonly transactionId?: string;
|
|
4
23
|
readonly originalTransactionId?: string;
|
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
import { createAppStoreSignedTransactionEvidence, } from './app-store-verifier.js';
|
|
2
|
+
/** The placeholder JWS the signed-transaction evidence fixture carries; never a real signature. */
|
|
3
|
+
export const appStoreConformanceSignedTransaction = 'conformance.signed-transaction.jws';
|
|
4
|
+
/**
|
|
5
|
+
* Builds the `app-store.signed-transaction.v1` evidence envelope a commerce v2
|
|
6
|
+
* StoreKit client submits, with a placeholder JWS by default. Pair it with a
|
|
7
|
+
* fake `AppStoreSignedTransactionVerifier` that answers
|
|
8
|
+
* `createAppStoreTransactionConformanceFixture` for that string and, under the
|
|
9
|
+
* default `server-api` policy, a fake Server API whose `signedTransactionInfo`
|
|
10
|
+
* decodes to the transaction's current status; the fixture is not signed
|
|
11
|
+
* evidence and contains no Apple credentials.
|
|
12
|
+
*/
|
|
13
|
+
export function createAppStoreSignedTransactionEvidenceFixture(input = {}) {
|
|
14
|
+
return createAppStoreSignedTransactionEvidence({
|
|
15
|
+
signedTransaction: input.signedTransaction ?? appStoreConformanceSignedTransaction,
|
|
16
|
+
transactionId: input.transactionId ?? '2000000000000001',
|
|
17
|
+
...(input.originalTransactionId === undefined
|
|
18
|
+
? {}
|
|
19
|
+
: { originalTransactionId: input.originalTransactionId }),
|
|
20
|
+
...(input.bundleId === undefined ? {} : { bundleId: input.bundleId }),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
1
23
|
/**
|
|
2
24
|
* Builds deterministic decoded JWS data for verifier and provider-adapter
|
|
3
25
|
* conformance tests. The fixture is not signed evidence and contains no Apple
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PlatformEvidenceEnvelope } from '@mpgd/platform';
|
|
1
2
|
import type { GameServicesEvidenceVerifier } from './evidence-verification.js';
|
|
2
3
|
export type AppStoreEnvironment = 'Production' | 'Sandbox';
|
|
3
4
|
export declare const appStoreServerApiBaseUrls: Readonly<{
|
|
@@ -72,7 +73,44 @@ export interface CreateAppStoreEvidenceVerifierOptions {
|
|
|
72
73
|
}) => string | Promise<string>;
|
|
73
74
|
readonly now?: () => string;
|
|
74
75
|
readonly clockSkewMs?: number;
|
|
76
|
+
/**
|
|
77
|
+
* How `app-store.signed-transaction.v1` request evidence is used. Defaults
|
|
78
|
+
* to `{ mode: 'server-api' }`: the client JWS is verified offline to
|
|
79
|
+
* establish authenticity and bind the transaction id, and the grant decision
|
|
80
|
+
* is then taken on the transaction's current status fetched from the App
|
|
81
|
+
* Store Server API (`Get Transaction Info`), so a refund that happened after
|
|
82
|
+
* the client cached its JWS is seen. `{ mode: 'offline-only', maxSignedAgeMs }`
|
|
83
|
+
* decides on the client JWS alone and is only for deployments without Server
|
|
84
|
+
* API access: a JWS is a snapshot, so a transaction refunded after it was
|
|
85
|
+
* signed is accepted until `signedDate` falls out of the window. `{ mode:
|
|
86
|
+
* 'ignore' }` fetches by id for every request.
|
|
87
|
+
*/
|
|
88
|
+
readonly signedTransactionEvidence?: AppStoreSignedTransactionEvidencePolicy;
|
|
75
89
|
}
|
|
90
|
+
export type AppStoreSignedTransactionEvidencePolicy = {
|
|
91
|
+
readonly mode: 'ignore';
|
|
92
|
+
} | {
|
|
93
|
+
readonly mode: 'server-api';
|
|
94
|
+
} | {
|
|
95
|
+
readonly mode: 'offline-only';
|
|
96
|
+
/** Maximum age of the JWS `signedDate` at verification time; older evidence is rejected. */
|
|
97
|
+
readonly maxSignedAgeMs: number;
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Evidence schema of a commerce v2 App Store purchase (RFC B3): the payload's
|
|
101
|
+
* `signedTransaction` is the StoreKit-verified JWS the client received. The
|
|
102
|
+
* server verifies it offline and, by default, confirms the transaction's
|
|
103
|
+
* current status through the Server API before a grant.
|
|
104
|
+
*/
|
|
105
|
+
export declare const appStoreSignedTransactionEvidenceSchema = "app-store.signed-transaction.v1";
|
|
106
|
+
export interface AppStoreSignedTransactionEvidencePayload {
|
|
107
|
+
readonly signedTransaction: string;
|
|
108
|
+
readonly transactionId: string;
|
|
109
|
+
readonly originalTransactionId?: string;
|
|
110
|
+
readonly bundleId?: string;
|
|
111
|
+
}
|
|
112
|
+
/** Builds the `app-store.signed-transaction.v1` envelope a client submits with `verifyPurchase`. */
|
|
113
|
+
export declare function createAppStoreSignedTransactionEvidence(payload: AppStoreSignedTransactionEvidencePayload): PlatformEvidenceEnvelope;
|
|
76
114
|
export interface AppStoreFetchResponse {
|
|
77
115
|
readonly status: number;
|
|
78
116
|
readonly body: ReadableStream<Uint8Array> | null;
|
|
@@ -16,6 +16,56 @@ class AppStoreResponseTooLargeError extends Error {
|
|
|
16
16
|
this.name = 'AppStoreResponseTooLargeError';
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Evidence schema of a commerce v2 App Store purchase (RFC B3): the payload's
|
|
21
|
+
* `signedTransaction` is the StoreKit-verified JWS the client received. The
|
|
22
|
+
* server verifies it offline and, by default, confirms the transaction's
|
|
23
|
+
* current status through the Server API before a grant.
|
|
24
|
+
*/
|
|
25
|
+
export const appStoreSignedTransactionEvidenceSchema = 'app-store.signed-transaction.v1';
|
|
26
|
+
/** Builds the `app-store.signed-transaction.v1` envelope a client submits with `verifyPurchase`. */
|
|
27
|
+
export function createAppStoreSignedTransactionEvidence(payload) {
|
|
28
|
+
assertNonEmptyString(payload.signedTransaction, 'signedTransaction');
|
|
29
|
+
assertNonEmptyString(payload.transactionId, 'transactionId');
|
|
30
|
+
return {
|
|
31
|
+
schema: appStoreSignedTransactionEvidenceSchema,
|
|
32
|
+
payload: {
|
|
33
|
+
signedTransaction: payload.signedTransaction,
|
|
34
|
+
transactionId: payload.transactionId,
|
|
35
|
+
...(payload.originalTransactionId === undefined
|
|
36
|
+
? {}
|
|
37
|
+
: { originalTransactionId: payload.originalTransactionId }),
|
|
38
|
+
...(payload.bundleId === undefined ? {} : { bundleId: payload.bundleId }),
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Reads the client JWS from `app-store.signed-transaction.v1` evidence. Any
|
|
44
|
+
* other schema, or no evidence, is `absent` (the Server API path); a present
|
|
45
|
+
* envelope whose payload is malformed, or whose `transactionId` differs from
|
|
46
|
+
* the request, is rejected before any verifier runs.
|
|
47
|
+
*/
|
|
48
|
+
function readSignedTransactionEvidence(request) {
|
|
49
|
+
const evidence = request.evidence;
|
|
50
|
+
if (evidence?.schema !== appStoreSignedTransactionEvidenceSchema) {
|
|
51
|
+
return { status: 'absent' };
|
|
52
|
+
}
|
|
53
|
+
const payload = evidence.payload;
|
|
54
|
+
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
|
|
55
|
+
return { status: 'rejected', reason: 'APP_STORE_SIGNED_TRANSACTION_EVIDENCE_INVALID' };
|
|
56
|
+
}
|
|
57
|
+
const { signedTransaction, transactionId } = payload;
|
|
58
|
+
if (typeof signedTransaction !== 'string'
|
|
59
|
+
|| signedTransaction.length === 0
|
|
60
|
+
|| signedTransaction.length > maxSignedTransactionEvidenceLength) {
|
|
61
|
+
return { status: 'rejected', reason: 'APP_STORE_SIGNED_TRANSACTION_EVIDENCE_INVALID' };
|
|
62
|
+
}
|
|
63
|
+
if (transactionId !== undefined && transactionId !== request.platformTransactionId) {
|
|
64
|
+
return { status: 'rejected', reason: 'APP_STORE_SIGNED_TRANSACTION_ID_MISMATCH' };
|
|
65
|
+
}
|
|
66
|
+
return { status: 'present', signedTransaction };
|
|
67
|
+
}
|
|
68
|
+
const maxSignedTransactionEvidenceLength = 512 * 1024;
|
|
19
69
|
export function createAppStoreServerApiClient(options) {
|
|
20
70
|
const fetch = options.fetch ?? readGlobalFetch();
|
|
21
71
|
const maxResponseBytes = readPositiveSafeInteger(options.maxResponseBytes ?? 128 * 1024, 'maxResponseBytes');
|
|
@@ -82,6 +132,7 @@ export function createAppStoreServerApiClient(options) {
|
|
|
82
132
|
export function createAppStoreGameServicesEvidenceVerifier(options) {
|
|
83
133
|
assertNonEmptyString(options.bundleId, 'bundleId');
|
|
84
134
|
assertAppStoreEnvironment(options.environment);
|
|
135
|
+
assertSignedTransactionEvidencePolicy(options.signedTransactionEvidence);
|
|
85
136
|
const clockSkewMs = readNonNegativeSafeInteger(options.clockSkewMs ?? 5 * 60 * 1000, 'clockSkewMs');
|
|
86
137
|
const now = options.now ?? (() => new Date().toISOString());
|
|
87
138
|
return {
|
|
@@ -103,54 +154,72 @@ async function verifyAppStorePurchase(input, options, now, clockSkewMs) {
|
|
|
103
154
|
if (input.product.type === 'subscription') {
|
|
104
155
|
return { status: 'rejected', reason: 'APP_STORE_SUBSCRIPTION_UNSUPPORTED' };
|
|
105
156
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
157
|
+
// Commerce v2 evidence carries the client's StoreKit-verified JWS. Its
|
|
158
|
+
// signature establishes authenticity and binds the transaction id; the
|
|
159
|
+
// grant decision is then taken on the transaction's current status from
|
|
160
|
+
// the Server API (default), or on the JWS alone within a freshness window
|
|
161
|
+
// (offline-only). Without evidence (current clients) the transaction is
|
|
162
|
+
// fetched by id as before.
|
|
163
|
+
const policy = options.signedTransactionEvidence ?? defaultSignedTransactionEvidencePolicy;
|
|
164
|
+
const evidence = policy.mode === 'ignore'
|
|
165
|
+
? { status: 'absent' }
|
|
166
|
+
: readSignedTransactionEvidence(input.request);
|
|
167
|
+
if (evidence.status === 'rejected') {
|
|
168
|
+
return { status: 'rejected', reason: evidence.reason };
|
|
169
|
+
}
|
|
170
|
+
let offlineTransaction;
|
|
171
|
+
if (evidence.status === 'present') {
|
|
172
|
+
const offline = await verifySignedTransaction(evidence.signedTransaction, input, options);
|
|
173
|
+
if (offline.status !== 'verified') {
|
|
174
|
+
return offline;
|
|
117
175
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
catch {
|
|
124
|
-
return { status: 'pending', reason: 'APP_STORE_SERVER_API_INVALID_RESPONSE' };
|
|
125
|
-
}
|
|
126
|
-
if (transactionResult.status !== 'found') {
|
|
127
|
-
return { status: transactionResult.status, reason: transactionResult.reason };
|
|
128
|
-
}
|
|
129
|
-
let signatureResult;
|
|
130
|
-
try {
|
|
131
|
-
signatureResult = await options.signedTransactionVerifier.verifyAndDecode({
|
|
132
|
-
signedTransaction: transactionResult.signedTransactionInfo,
|
|
133
|
-
environment: options.environment,
|
|
134
|
-
bundleId: options.bundleId,
|
|
135
|
-
signal: input.signal,
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
catch (error) {
|
|
139
|
-
if (!(error instanceof AppStoreDependencyUnavailableError)) {
|
|
140
|
-
throw error;
|
|
176
|
+
if (offline.payload.transactionId !== input.request.platformTransactionId) {
|
|
177
|
+
// A client-supplied JWS for another transaction is a stable rejection,
|
|
178
|
+
// distinct from a Server API answer that disagrees with the request.
|
|
179
|
+
return { status: 'rejected', reason: 'APP_STORE_SIGNED_TRANSACTION_ID_MISMATCH' };
|
|
141
180
|
}
|
|
142
|
-
|
|
181
|
+
offlineTransaction = offline.payload;
|
|
143
182
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
catch {
|
|
148
|
-
return { status: 'rejected', reason: 'APP_STORE_SIGNATURE_RESULT_INVALID' };
|
|
183
|
+
let transaction;
|
|
184
|
+
if (offlineTransaction !== undefined && policy.mode === 'offline-only') {
|
|
185
|
+
transaction = offlineTransaction;
|
|
149
186
|
}
|
|
150
|
-
|
|
151
|
-
|
|
187
|
+
else {
|
|
188
|
+
// A JWS is a snapshot: the current status (refund, revocation) is read
|
|
189
|
+
// from the Server API before any grant.
|
|
190
|
+
let transactionResult;
|
|
191
|
+
try {
|
|
192
|
+
transactionResult = await options.serverApi.getTransactionInfo({
|
|
193
|
+
transactionId: input.request.platformTransactionId,
|
|
194
|
+
environment: options.environment,
|
|
195
|
+
signal: input.signal,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
if (!(error instanceof AppStoreDependencyUnavailableError)) {
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
return { status: 'pending', reason: 'APP_STORE_SERVER_API_UNAVAILABLE' };
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
assertAppStoreServerApiTransactionResult(transactionResult);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return { status: 'pending', reason: 'APP_STORE_SERVER_API_INVALID_RESPONSE' };
|
|
209
|
+
}
|
|
210
|
+
if (transactionResult.status !== 'found') {
|
|
211
|
+
return { status: transactionResult.status, reason: transactionResult.reason };
|
|
212
|
+
}
|
|
213
|
+
const fresh = await verifySignedTransaction(transactionResult.signedTransactionInfo, input, options);
|
|
214
|
+
if (fresh.status !== 'verified') {
|
|
215
|
+
return fresh;
|
|
216
|
+
}
|
|
217
|
+
if (offlineTransaction !== undefined
|
|
218
|
+
&& fresh.payload.transactionId !== offlineTransaction.transactionId) {
|
|
219
|
+
return { status: 'rejected', reason: 'APP_STORE_SIGNED_TRANSACTION_ID_MISMATCH' };
|
|
220
|
+
}
|
|
221
|
+
transaction = fresh.payload;
|
|
152
222
|
}
|
|
153
|
-
const transaction = signatureResult.payload;
|
|
154
223
|
const expectedAccountToken = await resolveExpectedAppAccountToken(input, options);
|
|
155
224
|
if (expectedAccountToken.status !== 'verified') {
|
|
156
225
|
return expectedAccountToken;
|
|
@@ -160,6 +229,13 @@ async function verifyAppStorePurchase(input, options, now, clockSkewMs) {
|
|
|
160
229
|
if (!Number.isFinite(nowMs)) {
|
|
161
230
|
return { status: 'pending', reason: 'APP_STORE_VERIFIER_CLOCK_INVALID' };
|
|
162
231
|
}
|
|
232
|
+
if (offlineTransaction !== undefined
|
|
233
|
+
&& policy.mode === 'offline-only'
|
|
234
|
+
&& nowMs - transaction.signedDate > policy.maxSignedAgeMs) {
|
|
235
|
+
// Offline-only never sees a later refund; a JWS older than the window is
|
|
236
|
+
// not fresh enough to decide a grant on.
|
|
237
|
+
return { status: 'rejected', reason: 'APP_STORE_SIGNED_TRANSACTION_STALE' };
|
|
238
|
+
}
|
|
163
239
|
const rejection = validateTransaction(input, transaction, options, expectedAccountToken.value, nowMs, clockSkewMs);
|
|
164
240
|
if (rejection !== undefined) {
|
|
165
241
|
return { status: 'rejected', reason: rejection };
|
|
@@ -179,6 +255,51 @@ async function verifyAppStorePurchase(input, options, now, clockSkewMs) {
|
|
|
179
255
|
},
|
|
180
256
|
};
|
|
181
257
|
}
|
|
258
|
+
const defaultSignedTransactionEvidencePolicy = {
|
|
259
|
+
mode: 'server-api',
|
|
260
|
+
};
|
|
261
|
+
function assertSignedTransactionEvidencePolicy(input) {
|
|
262
|
+
if (input === undefined) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
assertRecord(input, 'signedTransactionEvidence');
|
|
266
|
+
if (input.mode === 'ignore' || input.mode === 'server-api') {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (input.mode === 'offline-only') {
|
|
270
|
+
readPositiveSafeInteger(input.maxSignedAgeMs, 'signedTransactionEvidence.maxSignedAgeMs');
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
throw new Error('signedTransactionEvidence.mode must be ignore, server-api, or offline-only.');
|
|
274
|
+
}
|
|
275
|
+
/** Verifies one JWS through the signature port and validates the decoded payload shape. */
|
|
276
|
+
async function verifySignedTransaction(signedTransaction, input, options) {
|
|
277
|
+
let signatureResult;
|
|
278
|
+
try {
|
|
279
|
+
signatureResult = await options.signedTransactionVerifier.verifyAndDecode({
|
|
280
|
+
signedTransaction,
|
|
281
|
+
environment: options.environment,
|
|
282
|
+
bundleId: options.bundleId,
|
|
283
|
+
signal: input.signal,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
if (!(error instanceof AppStoreDependencyUnavailableError)) {
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
return { status: 'pending', reason: 'APP_STORE_SIGNATURE_VERIFIER_UNAVAILABLE' };
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
assertAppStoreSignedTransactionVerificationResult(signatureResult);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return { status: 'rejected', reason: 'APP_STORE_SIGNATURE_RESULT_INVALID' };
|
|
297
|
+
}
|
|
298
|
+
if (signatureResult.status === 'rejected') {
|
|
299
|
+
return { status: 'rejected', reason: signatureResult.reason };
|
|
300
|
+
}
|
|
301
|
+
return { status: 'verified', payload: signatureResult.payload };
|
|
302
|
+
}
|
|
182
303
|
async function resolveExpectedAppAccountToken(input, options) {
|
|
183
304
|
try {
|
|
184
305
|
const value = await options.resolveAppAccountToken({
|
|
@@ -1,7 +1,30 @@
|
|
|
1
|
-
import type { PurchaseResult } from '@mpgd/platform';
|
|
1
|
+
import type { PlatformGateway, PurchaseResult } from '@mpgd/platform';
|
|
2
|
+
export interface AdapterSettledGrant {
|
|
3
|
+
readonly ledgerEntryId: string;
|
|
4
|
+
readonly alreadyProcessed?: boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Internal: the ledger grant a checkout result already carries, or undefined
|
|
8
|
+
* when the backend must verify. A gateway with a commerce v2 provider decides
|
|
9
|
+
* from the provider's capability block (RFC "Capabilities":
|
|
10
|
+
* `authority.evidence: 'adapter-settled'`) plus the result's
|
|
11
|
+
* `authoritativeGrant`; a provider whose capabilities cannot be read, or that
|
|
12
|
+
* declares any other evidence, answers undefined so the client verifies. A
|
|
13
|
+
* gateway without a v2 provider keeps the deprecated target-name rule until
|
|
14
|
+
* C4 removes it.
|
|
15
|
+
*/
|
|
16
|
+
export declare function findAdapterSettledGrant(input: {
|
|
17
|
+
readonly gateway: Pick<PlatformGateway, 'commerceV2'>;
|
|
18
|
+
readonly target: string;
|
|
19
|
+
readonly purchase: PurchaseResult;
|
|
20
|
+
}): Promise<AdapterSettledGrant | undefined>;
|
|
2
21
|
/**
|
|
3
22
|
* Internal: the Microsoft Store adapter completes the ledger grant before it returns this
|
|
4
23
|
* checkout result, so no further backend verification is requested for it.
|
|
24
|
+
*
|
|
25
|
+
* @deprecated Settlement is declared by the commerce v2 capability block; use
|
|
26
|
+
* `findAdapterSettledGrant`, which reads it on gateways that expose `commerceV2`. Kept for
|
|
27
|
+
* gateways that still install only the v1 `createMicrosoftStoreCommerceAdapter`; C4 removes it.
|
|
5
28
|
*/
|
|
6
29
|
export declare function isAuthoritativeMicrosoftStoreCompletion(target: string, purchase: PurchaseResult): purchase is PurchaseResult & {
|
|
7
30
|
readonly status: 'completed';
|
|
@@ -1,6 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal: the ledger grant a checkout result already carries, or undefined
|
|
3
|
+
* when the backend must verify. A gateway with a commerce v2 provider decides
|
|
4
|
+
* from the provider's capability block (RFC "Capabilities":
|
|
5
|
+
* `authority.evidence: 'adapter-settled'`) plus the result's
|
|
6
|
+
* `authoritativeGrant`; a provider whose capabilities cannot be read, or that
|
|
7
|
+
* declares any other evidence, answers undefined so the client verifies. A
|
|
8
|
+
* gateway without a v2 provider keeps the deprecated target-name rule until
|
|
9
|
+
* C4 removes it.
|
|
10
|
+
*/
|
|
11
|
+
export async function findAdapterSettledGrant(input) {
|
|
12
|
+
const { purchase } = input;
|
|
13
|
+
const grant = purchase.authoritativeGrant;
|
|
14
|
+
if (purchase.status !== 'completed' || purchase.transactionId === undefined || grant === undefined) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
const provider = input.gateway.commerceV2;
|
|
18
|
+
const settled = provider === undefined
|
|
19
|
+
? isAuthoritativeMicrosoftStoreCompletion(input.target, purchase)
|
|
20
|
+
: (await readEvidenceAuthority(provider)) === 'adapter-settled';
|
|
21
|
+
if (!settled) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
ledgerEntryId: grant.ledgerEntryId,
|
|
26
|
+
...(grant.alreadyProcessed === undefined ? {} : { alreadyProcessed: grant.alreadyProcessed }),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A provider's `authority.evidence` declaration is fixed for its lifetime, so
|
|
31
|
+
* one successful read serves every later checkout; a failed read is not
|
|
32
|
+
* cached, and the next checkout probes again.
|
|
33
|
+
*/
|
|
34
|
+
const evidenceAuthorityByProvider = new WeakMap();
|
|
35
|
+
async function readEvidenceAuthority(provider) {
|
|
36
|
+
const cached = evidenceAuthorityByProvider.get(provider);
|
|
37
|
+
if (cached !== undefined) {
|
|
38
|
+
return cached;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const { evidence } = (await provider.getCapabilities()).authority;
|
|
42
|
+
evidenceAuthorityByProvider.set(provider, evidence);
|
|
43
|
+
return evidence;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
1
49
|
/**
|
|
2
50
|
* Internal: the Microsoft Store adapter completes the ledger grant before it returns this
|
|
3
51
|
* checkout result, so no further backend verification is requested for it.
|
|
52
|
+
*
|
|
53
|
+
* @deprecated Settlement is declared by the commerce v2 capability block; use
|
|
54
|
+
* `findAdapterSettledGrant`, which reads it on gateways that expose `commerceV2`. Kept for
|
|
55
|
+
* gateways that still install only the v1 `createMicrosoftStoreCommerceAdapter`; C4 removes it.
|
|
4
56
|
*/
|
|
5
57
|
export function isAuthoritativeMicrosoftStoreCompletion(target, purchase) {
|
|
6
58
|
return target === 'microsoft-store'
|
package/dist/client.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createORPCClient } from '@orpc/client';
|
|
2
2
|
import { RPCLink } from '@orpc/client/fetch';
|
|
3
3
|
import { createAnalyticsReporter } from '@mpgd/analytics';
|
|
4
|
-
import {
|
|
4
|
+
import { findAdapterSettledGrant } from './authoritative-purchase.js';
|
|
5
5
|
import { resolveClientRewardClaim, } from './client-reward-evidence.js';
|
|
6
6
|
import { createDefaultClientRewardEvidenceRegistry } from './default-client-reward-evidence.js';
|
|
7
7
|
import { observeGameServicesOperation } from './operation-progress.js';
|
|
@@ -110,19 +110,27 @@ export function createGameServicesClient(input) {
|
|
|
110
110
|
purchase,
|
|
111
111
|
};
|
|
112
112
|
}
|
|
113
|
-
|
|
113
|
+
// A provider that declares adapter settlement already wrote the ledger
|
|
114
|
+
// grant before returning; the target-name rule remains only for
|
|
115
|
+
// gateways without a commerce v2 provider (removed in C4).
|
|
116
|
+
const settledGrant = await findAdapterSettledGrant({
|
|
117
|
+
gateway: input.gateway,
|
|
118
|
+
target,
|
|
119
|
+
purchase,
|
|
120
|
+
});
|
|
121
|
+
if (settledGrant !== undefined) {
|
|
114
122
|
const result = {
|
|
115
123
|
status: 'granted',
|
|
116
124
|
purchase,
|
|
117
|
-
ledgerEntryId:
|
|
125
|
+
ledgerEntryId: settledGrant.ledgerEntryId,
|
|
118
126
|
};
|
|
119
127
|
await analytics.track({
|
|
120
128
|
name: 'purchase_granted',
|
|
121
129
|
properties: {
|
|
122
130
|
productId: purchaseInput.productId,
|
|
123
131
|
status: result.status,
|
|
124
|
-
ledgerEntryId:
|
|
125
|
-
alreadyProcessed:
|
|
132
|
+
ledgerEntryId: settledGrant.ledgerEntryId,
|
|
133
|
+
alreadyProcessed: settledGrant.alreadyProcessed,
|
|
126
134
|
},
|
|
127
135
|
});
|
|
128
136
|
return result;
|
|
@@ -38,5 +38,12 @@ 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
|
-
/**
|
|
42
|
-
|
|
41
|
+
/**
|
|
42
|
+
* A non-secret transaction ID for purchases whose Google order ID is absent or
|
|
43
|
+
* delayed: `play-token-sha256:<hex>` of the purchase token.
|
|
44
|
+
*
|
|
45
|
+
* @deprecated Use `createGooglePlayTokenPurchaseKey` from `@mpgd/platform/commerce`;
|
|
46
|
+
* this alias is byte-for-byte the same helper (same key, same `TypeError` for
|
|
47
|
+
* an invalid token) and will be removed in the next major version.
|
|
48
|
+
*/
|
|
49
|
+
export declare const createGooglePlayTokenTransactionId: (purchaseToken: string) => Promise<string>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createGooglePlayTokenPurchaseKey } from '@mpgd/platform/commerce';
|
|
1
2
|
export const googlePlayProductPurchaseEvidenceSchema = 'google-play.product-purchase.v2';
|
|
2
3
|
export function createGooglePlayProductPurchaseBoundary(input) {
|
|
3
4
|
const packageName = assertIdentifier(input.packageName, 'packageName', 256);
|
|
@@ -45,7 +46,7 @@ export function createGooglePlayProductPurchaseBoundary(input) {
|
|
|
45
46
|
purchaseToken,
|
|
46
47
|
expectedProductId: verificationInput.platformProductId,
|
|
47
48
|
expectedProductType: verificationInput.product.type,
|
|
48
|
-
orderMatch: createGooglePlayObservedOrderMatch(verificationInput.request.platformTransactionId, await
|
|
49
|
+
orderMatch: createGooglePlayObservedOrderMatch(verificationInput.request.platformTransactionId, await createGooglePlayTokenPurchaseKey(purchaseToken)),
|
|
49
50
|
accountBinding,
|
|
50
51
|
allowConsumed: false,
|
|
51
52
|
signal: verificationInput.signal,
|
|
@@ -143,7 +144,7 @@ async function finalizeGooglePlayPurchase(input, packageName, purchaseToken, pro
|
|
|
143
144
|
purchaseToken,
|
|
144
145
|
expectedProductId: finalizationInput.platformProductId,
|
|
145
146
|
expectedProductType: productType,
|
|
146
|
-
orderMatch: createGooglePlayFinalizationOrderMatch(verifiedContext, finalizationInput.request.platformTransactionId, await
|
|
147
|
+
orderMatch: createGooglePlayFinalizationOrderMatch(verifiedContext, finalizationInput.request.platformTransactionId, await createGooglePlayTokenPurchaseKey(purchaseToken)),
|
|
147
148
|
accountBinding,
|
|
148
149
|
allowConsumed: true,
|
|
149
150
|
signal: finalizationInput.signal,
|
|
@@ -380,18 +381,15 @@ function createGooglePlayObservedOrderMatch(observedTransactionId, tokenTransact
|
|
|
380
381
|
? { mode: 'token-only' }
|
|
381
382
|
: { mode: 'if-present', orderId: observedTransactionId };
|
|
382
383
|
}
|
|
383
|
-
/**
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
}
|
|
384
|
+
/**
|
|
385
|
+
* A non-secret transaction ID for purchases whose Google order ID is absent or
|
|
386
|
+
* delayed: `play-token-sha256:<hex>` of the purchase token.
|
|
387
|
+
*
|
|
388
|
+
* @deprecated Use `createGooglePlayTokenPurchaseKey` from `@mpgd/platform/commerce`;
|
|
389
|
+
* this alias is byte-for-byte the same helper (same key, same `TypeError` for
|
|
390
|
+
* an invalid token) and will be removed in the next major version.
|
|
391
|
+
*/
|
|
392
|
+
export const createGooglePlayTokenTransactionId = createGooglePlayTokenPurchaseKey;
|
|
395
393
|
function readGooglePlayVerifiedContext(payload, packageName, productId, purchaseTokenDigest) {
|
|
396
394
|
if (payload?.googlePlayPackageName !== packageName
|
|
397
395
|
|| payload.googlePlayProductId !== productId
|
package/dist/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { implement, ORPCError } from '@orpc/server';
|
|
2
2
|
import { BodyLimitHandlerPlugin, RPCHandler } from '@orpc/server/fetch';
|
|
3
3
|
import { createAnalyticsReporter } from '@mpgd/analytics';
|
|
4
|
-
import { resolveAdPlacementPlatformId, resolveProductPlatformId, } from '@mpgd/catalog';
|
|
4
|
+
import { resolveAdPlacementPlatformId, resolveCatalogCurrencies, resolveProductPlatformId, } from '@mpgd/catalog';
|
|
5
5
|
import { gameServicesBackendEndpoints, } from './client.js';
|
|
6
6
|
import { gameServicesContract } from './contract.js';
|
|
7
7
|
import { createRejectingGameServicesEvidenceVerifier, } from './evidence-verification.js';
|
|
@@ -127,6 +127,7 @@ export function createInMemoryGameServicesStore() {
|
|
|
127
127
|
return new InMemoryGameServicesStore();
|
|
128
128
|
}
|
|
129
129
|
export function createGameServicesBackend(input) {
|
|
130
|
+
assertDeclaredCatalogCurrencies(input.catalog, input.placements);
|
|
130
131
|
const store = input.store ?? createInMemoryGameServicesStore();
|
|
131
132
|
const now = input.now ?? (() => new Date().toISOString());
|
|
132
133
|
const version = input.version ?? '0.0.0';
|
|
@@ -223,6 +224,26 @@ export function createGameServicesBackend(input) {
|
|
|
223
224
|
},
|
|
224
225
|
};
|
|
225
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* Every grant and reward the backend can write comes from the catalog or the
|
|
229
|
+
* placements, so an undeclared currency is a configuration error at startup
|
|
230
|
+
* rather than a rejected ledger write on the first purchase.
|
|
231
|
+
*/
|
|
232
|
+
function assertDeclaredCatalogCurrencies(catalog, placements) {
|
|
233
|
+
const currencies = resolveCatalogCurrencies(catalog);
|
|
234
|
+
for (const product of catalog.products) {
|
|
235
|
+
if (product.grant.type === 'currency' && !currencies.has(product.grant.currency)) {
|
|
236
|
+
throw new Error(`Product ${product.id} grants undeclared currency ${product.grant.currency}; `
|
|
237
|
+
+ `declared currencies: ${[...currencies].join(', ')}.`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
for (const placement of placements.placements) {
|
|
241
|
+
if (placement.reward?.type === 'currency' && !currencies.has(placement.reward.currency)) {
|
|
242
|
+
throw new Error(`Ad placement ${placement.id} rewards undeclared currency ${placement.reward.currency}; `
|
|
243
|
+
+ `declared currencies: ${[...currencies].join(', ')}.`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
226
247
|
export function createGameServicesBackendApiHandler(input) {
|
|
227
248
|
const backend = createGameServicesBackend(input);
|
|
228
249
|
return {
|
package/dist/types.js
CHANGED
|
@@ -270,8 +270,11 @@ function assertLedgerSource(input) {
|
|
|
270
270
|
function assertProductGrant(input) {
|
|
271
271
|
assertRecord(input, 'grant');
|
|
272
272
|
if (input.type === 'currency') {
|
|
273
|
-
|
|
274
|
-
|
|
273
|
+
// The currency list is open; the backend checks it against the catalog's
|
|
274
|
+
// declared currencies when it is constructed.
|
|
275
|
+
assertNonEmptyString(input.currency, 'grant.currency');
|
|
276
|
+
if (input.currency.trim() !== input.currency) {
|
|
277
|
+
throw new Error('grant.currency must be a trimmed currency code.');
|
|
275
278
|
}
|
|
276
279
|
assertFiniteNumber(input.amount, 'grant.amount');
|
|
277
280
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/game-services",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -57,6 +57,10 @@
|
|
|
57
57
|
"types": "./dist/app-store-recovery.d.ts",
|
|
58
58
|
"default": "./dist/app-store-recovery.js"
|
|
59
59
|
},
|
|
60
|
+
"./app-store-server": {
|
|
61
|
+
"types": "./dist/app-store-server.d.ts",
|
|
62
|
+
"default": "./dist/app-store-server.js"
|
|
63
|
+
},
|
|
60
64
|
"./app-store-verifier-conformance": {
|
|
61
65
|
"types": "./dist/app-store-verifier-conformance.d.ts",
|
|
62
66
|
"default": "./dist/app-store-verifier-conformance.js"
|
|
@@ -186,14 +190,24 @@
|
|
|
186
190
|
"@orpc/client": "2.0.0-beta.14",
|
|
187
191
|
"@orpc/contract": "2.0.0-beta.14",
|
|
188
192
|
"@orpc/server": "2.0.0-beta.14",
|
|
189
|
-
"@mpgd/
|
|
190
|
-
"@mpgd/
|
|
191
|
-
"@mpgd/
|
|
193
|
+
"@mpgd/catalog": "0.9.1",
|
|
194
|
+
"@mpgd/platform": "0.20.0",
|
|
195
|
+
"@mpgd/analytics": "0.4.6"
|
|
196
|
+
},
|
|
197
|
+
"peerDependencies": {
|
|
198
|
+
"@apple/app-store-server-library": "^3.1.0"
|
|
199
|
+
},
|
|
200
|
+
"peerDependenciesMeta": {
|
|
201
|
+
"@apple/app-store-server-library": {
|
|
202
|
+
"optional": true
|
|
203
|
+
}
|
|
192
204
|
},
|
|
193
205
|
"devDependencies": {
|
|
206
|
+
"@apple/app-store-server-library": "3.1.0",
|
|
194
207
|
"@opentelemetry/api": "1.9.0",
|
|
195
208
|
"ttsc": "0.30.4",
|
|
196
|
-
"typescript": "7.0.2"
|
|
209
|
+
"typescript": "7.0.2",
|
|
210
|
+
"vitest": "^4.0.0"
|
|
197
211
|
},
|
|
198
212
|
"main": "./dist/index.js",
|
|
199
213
|
"types": "./dist/index.d.ts",
|
|
@@ -209,6 +223,7 @@
|
|
|
209
223
|
"test:packed-transport": "pnpm build && node test/runtime-transport-package-import.mjs && node test/reward-evidence-import.mjs",
|
|
210
224
|
"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",
|
|
211
225
|
"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",
|
|
212
|
-
"test": "pnpm test:unit && pnpm test:recovery && pnpm test:packed-transport"
|
|
226
|
+
"test": "pnpm test:unit && pnpm test:app-store-server && pnpm test:recovery && pnpm test:packed-transport",
|
|
227
|
+
"test:app-store-server": "vitest run"
|
|
213
228
|
}
|
|
214
229
|
}
|