@mpgd/game-services 0.20.0 → 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/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/package.json +4 -4
|
@@ -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;
|
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": {
|
|
@@ -190,9 +190,9 @@
|
|
|
190
190
|
"@orpc/client": "2.0.0-beta.14",
|
|
191
191
|
"@orpc/contract": "2.0.0-beta.14",
|
|
192
192
|
"@orpc/server": "2.0.0-beta.14",
|
|
193
|
-
"@mpgd/
|
|
194
|
-
"@mpgd/platform": "0.
|
|
195
|
-
"@mpgd/
|
|
193
|
+
"@mpgd/catalog": "0.9.1",
|
|
194
|
+
"@mpgd/platform": "0.20.0",
|
|
195
|
+
"@mpgd/analytics": "0.4.6"
|
|
196
196
|
},
|
|
197
197
|
"peerDependencies": {
|
|
198
198
|
"@apple/app-store-server-library": "^3.1.0"
|