@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 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 { AppStoreEnvironment, AppStoreInAppPurchaseType, AppStoreTransactionPayload } from './app-store-verifier.js';
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
- let transactionResult;
107
- try {
108
- transactionResult = await options.serverApi.getTransactionInfo({
109
- transactionId: input.request.platformTransactionId,
110
- environment: options.environment,
111
- signal: input.signal,
112
- });
113
- }
114
- catch (error) {
115
- if (!(error instanceof AppStoreDependencyUnavailableError)) {
116
- throw error;
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
- return { status: 'pending', reason: 'APP_STORE_SERVER_API_UNAVAILABLE' };
119
- }
120
- try {
121
- assertAppStoreServerApiTransactionResult(transactionResult);
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
- return { status: 'pending', reason: 'APP_STORE_SIGNATURE_VERIFIER_UNAVAILABLE' };
181
+ offlineTransaction = offline.payload;
143
182
  }
144
- try {
145
- assertAppStoreSignedTransactionVerificationResult(signatureResult);
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
- if (signatureResult.status === 'rejected') {
151
- return { status: 'rejected', reason: signatureResult.reason };
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 { isAuthoritativeMicrosoftStoreCompletion } from './authoritative-purchase.js';
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
- if (isAuthoritativeMicrosoftStoreCompletion(target, purchase)) {
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: purchase.transactionId,
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: purchase.transactionId,
125
- alreadyProcessed: purchase.authoritativeGrant?.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
- /** A non-secret transaction ID for purchases whose Google order ID is absent or delayed. */
42
- export declare function createGooglePlayTokenTransactionId(purchaseToken: string): Promise<string>;
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 createGooglePlayTokenTransactionId(purchaseToken)),
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 createGooglePlayTokenTransactionId(purchaseToken)),
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
- /** 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
- }
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
- if (input.currency !== 'coin' && input.currency !== 'gem') {
274
- throw new Error('grant.currency must be coin or gem.');
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.19.1",
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/analytics": "0.4.4",
190
- "@mpgd/catalog": "0.8.1",
191
- "@mpgd/platform": "0.18.0"
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
  }