@mpgd/game-services 0.30.4 → 0.31.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 +96 -0
- package/dist/client.d.ts +16 -2
- package/dist/client.js +28 -3
- package/dist/commerce-reconciler.d.ts +53 -3
- package/dist/commerce-reconciler.js +240 -14
- package/dist/contract.d.ts +6 -6
- package/dist/monetization-recovery.js +1 -1
- package/dist/orpc-error-compat.d.ts +42 -0
- package/dist/orpc-error-compat.js +102 -0
- package/dist/request-headers.d.ts +22 -0
- package/dist/request-headers.js +34 -1
- package/dist/runtime.d.ts +70 -2
- package/dist/runtime.js +340 -10
- package/dist/server.d.ts +15 -6
- package/dist/server.js +26 -3
- package/package.json +9 -9
package/dist/runtime.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { PlatformGateway, PlatformTarget } from '@mpgd/platform';
|
|
|
4
4
|
import { type GameServicesBackendApi, type GameServicesBackendTransport, type GameServicesClient, type GameServicesHeaderResolver } from './client.js';
|
|
5
5
|
import type { GameServicesLedgerTarget } from './types.js';
|
|
6
6
|
import type { ClientRewardEvidenceRegistry } from './client-reward-evidence.js';
|
|
7
|
-
import type { CommerceReconcilerProduct } from './commerce-reconciler.js';
|
|
7
|
+
import type { CommerceReconcilerProduct, CreateCommerceReconcilerInput } from './commerce-reconciler.js';
|
|
8
8
|
import { type MonetizationOperationStore, type RecoverableMonetizationClient } from './monetization-recovery.js';
|
|
9
9
|
export type GameServicesAuthorityMode = 'production' | 'non-production';
|
|
10
10
|
export type GameServicesRuntimeMode = 'disabled' | 'local' | 'http' | 'orpc';
|
|
@@ -21,7 +21,30 @@ export interface GameServicesRuntime {
|
|
|
21
21
|
* with the runtime's headers; present with an authoritative `baseUrl`.
|
|
22
22
|
*/
|
|
23
23
|
readonly accountDeletion?: AccountDeletionClient;
|
|
24
|
+
/**
|
|
25
|
+
* Closes the runtime for good, then waits until a purchase whose store
|
|
26
|
+
* sheet is open has journaled its answer, a startup or resume recovery
|
|
27
|
+
* pass (`monetizationRecovery.reconcile()`) has finished, and the commerce
|
|
28
|
+
* reconciler has stopped (each wait bounded by `gameServicesDisposeWaitMs`); repeated
|
|
29
|
+
* calls share that wait. Afterwards nothing it holds talks to the backend
|
|
30
|
+
* or the store for its player: a purchase answers `failed` with the
|
|
31
|
+
* retryable diagnostic `gameServicesRuntimeDisposedCode` before any store
|
|
32
|
+
* UI, a rewarded-ad claim answers `unavailable` before any ad, recovery
|
|
33
|
+
* runs nothing, Restore Purchases and leaderboard submissions reject with
|
|
34
|
+
* `GameServicesRuntimeClosedError`, and every backend request (account
|
|
35
|
+
* deletion included) is refused before its headers are resolved. The
|
|
36
|
+
* journal keeps every record; a runtime created later for the same player
|
|
37
|
+
* resumes them. A disabled runtime has nothing to close.
|
|
38
|
+
*/
|
|
39
|
+
dispose(): Promise<void>;
|
|
24
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* How long `dispose()` waits for a purchase whose store sheet is open to
|
|
43
|
+
* journal its answer, and then for the reconciler to stop: 3 minutes, past
|
|
44
|
+
* Google Play's own wait for a purchase result.
|
|
45
|
+
*/
|
|
46
|
+
export declare const gameServicesDisposeWaitMs: number;
|
|
47
|
+
export { gameServicesPlayerChangedCode, gameServicesRuntimeDisposedCode, GameServicesRuntimeClosedError, gameServicesSessionRequiredCode, type GameServicesRuntimeClosedCode, } from './request-headers.js';
|
|
25
48
|
interface CreateGameServicesRuntimeCommonInput {
|
|
26
49
|
readonly gateway: PlatformGateway;
|
|
27
50
|
readonly playerId: string;
|
|
@@ -47,6 +70,52 @@ interface CreateGameServicesRuntimeCommonInput {
|
|
|
47
70
|
* product catalog's `platformProductIds` for the gateway target.
|
|
48
71
|
*/
|
|
49
72
|
readonly commerceProducts?: readonly CommerceReconcilerProduct[];
|
|
73
|
+
/**
|
|
74
|
+
* The player the session's credentials belong to right now, read
|
|
75
|
+
* synchronously: pass it whenever `getHeaders` (or the store account
|
|
76
|
+
* binding) follows a session that can change player within the run. It is
|
|
77
|
+
* checked before every backend request and again once its headers are
|
|
78
|
+
* resolved (no request leaves with another player's credentials), and by
|
|
79
|
+
* the commerce reconciler before it journals, verifies, restores, or
|
|
80
|
+
* finishes anything and right before a store UI opens. The store
|
|
81
|
+
* provider resolves its account binding after that last check, so the
|
|
82
|
+
* binding resolver must follow this player too (bind nothing for anyone
|
|
83
|
+
* else) for a purchase never to be journaled here and bound to another
|
|
84
|
+
* account; a binding refused while the session changed stays retryable.
|
|
85
|
+
* Another
|
|
86
|
+
* player closes the runtime for good, as `dispose()` does, with
|
|
87
|
+
* `gameServicesPlayerChangedCode`; no player refuses with
|
|
88
|
+
* `gameServicesSessionRequiredCode` and leaves the runtime open. Refusals
|
|
89
|
+
* are retryable and end no record, so a purchase of this player is never
|
|
90
|
+
* verified with another player's session (which a backend answers with a
|
|
91
|
+
* permanent account-mismatch rejection) and is resumed by a runtime for
|
|
92
|
+
* this player later. A custom `httpTransport` must send only the headers
|
|
93
|
+
* the runtime resolved for each request.
|
|
94
|
+
*/
|
|
95
|
+
readonly sessionPlayerId?: () => string | undefined;
|
|
96
|
+
/**
|
|
97
|
+
* This player's store account binding, derived from `playerId` the way
|
|
98
|
+
* the store provider binds the player's new purchases (Google Play's
|
|
99
|
+
* obfuscated account id, StoreKit's app account token). With it the
|
|
100
|
+
* commerce reconciler leaves a store purchase bound to another account
|
|
101
|
+
* alone instead of journaling and verifying it for this player (a backend
|
|
102
|
+
* rejects that for good as an account mismatch): after a restart as
|
|
103
|
+
* another player, Google Play still lists the previous player's
|
|
104
|
+
* unacknowledged purchases. The player's Restore Purchases still takes a
|
|
105
|
+
* durable bound to another account (restore sharing). Derive it from
|
|
106
|
+
* `playerId`, not from the live session. Pass it only for a store whose
|
|
107
|
+
* purchases carry this kind of binding: answering `undefined` leaves every
|
|
108
|
+
* bound purchase alone, which is meant only while the binding is briefly
|
|
109
|
+
* unreadable. Omitted, nothing is compared (for example Microsoft Store,
|
|
110
|
+
* whose purchases carry their own claimed scope).
|
|
111
|
+
*/
|
|
112
|
+
readonly commerceAccountBinding?: CreateCommerceReconcilerInput['accountBinding'];
|
|
113
|
+
/**
|
|
114
|
+
* The reconciler's `staleLaunchedAfterMs`: how long a `launched` purchase
|
|
115
|
+
* with no store purchase keeps serializing its product before it ends
|
|
116
|
+
* `failed`. Defaults to 24 hours; `Infinity` never expires it.
|
|
117
|
+
*/
|
|
118
|
+
readonly commerceStaleLaunchedAfterMs?: number;
|
|
50
119
|
}
|
|
51
120
|
export type CreateGameServicesRuntimeInput = CreateGameServicesRuntimeCommonInput & ({
|
|
52
121
|
readonly transport?: 'http';
|
|
@@ -67,4 +136,3 @@ export declare function resolveGameServicesTransport(transport: string | undefin
|
|
|
67
136
|
* notifications client's starter wiring uses it too.
|
|
68
137
|
*/
|
|
69
138
|
export declare function isPublicGameServicesUrl(baseUrl: string): boolean;
|
|
70
|
-
export {};
|
package/dist/runtime.js
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
|
-
import { createAccountDeletionClient } from './account-deletion.js';
|
|
2
|
-
import {
|
|
1
|
+
import { AccountDeletionError, createAccountDeletionClient, } from './account-deletion.js';
|
|
2
|
+
import { CommerceError } from '@mpgd/platform/commerce';
|
|
3
|
+
import { assertCommerceStaleLaunchedAfterMs, createGameServicesClient, createGameServicesFetchBackendTransport, createGameServicesHttpBackendApi, createGameServicesOrpcBackendApi, createGameServicesOrpcClient, resolveGameServicesRequestHeaders, } from './client.js';
|
|
4
|
+
import { gameServicesPlayerChangedCode, GameServicesRuntimeClosedError, gameServicesRuntimeDisposedCode, gameServicesSessionRequiredCode, } from './request-headers.js';
|
|
3
5
|
import { createRecoverableMonetizationClient, } from './monetization-recovery.js';
|
|
6
|
+
/**
|
|
7
|
+
* How long `dispose()` waits for a purchase whose store sheet is open to
|
|
8
|
+
* journal its answer, and then for the reconciler to stop: 3 minutes, past
|
|
9
|
+
* Google Play's own wait for a purchase result.
|
|
10
|
+
*/
|
|
11
|
+
export const gameServicesDisposeWaitMs = 3 * 60 * 1000;
|
|
12
|
+
export { gameServicesPlayerChangedCode, gameServicesRuntimeDisposedCode, GameServicesRuntimeClosedError, gameServicesSessionRequiredCode, } from './request-headers.js';
|
|
4
13
|
export function createGameServicesRuntime(input) {
|
|
5
14
|
assertAuthorityMode(input.authorityMode);
|
|
6
15
|
assertTransport(input.transport);
|
|
16
|
+
assertCommerceStaleLaunchedAfterMs(input.commerceStaleLaunchedAfterMs);
|
|
7
17
|
if (input.transport === 'orpc' && input.httpTransport !== undefined) {
|
|
8
18
|
throw new Error('Game Services httpTransport cannot be used with oRPC.');
|
|
9
19
|
}
|
|
10
20
|
const target = resolveGameServicesLedgerTarget(input.target ?? input.gateway.target);
|
|
21
|
+
const gate = createRuntimeGate(input.playerId, input.sessionPlayerId);
|
|
11
22
|
if (target === null) {
|
|
12
23
|
return disabledRuntime('unsupported_target');
|
|
13
24
|
}
|
|
@@ -30,10 +41,10 @@ export function createGameServicesRuntime(input) {
|
|
|
30
41
|
? createGameServicesOrpcBackendApi(createGameServicesOrpcClient({
|
|
31
42
|
url: baseUrl,
|
|
32
43
|
...(input.headers === undefined ? {} : { headers: input.headers }),
|
|
33
|
-
|
|
44
|
+
getHeaders: guardHeaders(input.getHeaders, gate),
|
|
34
45
|
}))
|
|
35
46
|
: createGameServicesHttpBackendApi({
|
|
36
|
-
transport: withRuntimeHeaders(input.httpTransport ?? createGameServicesFetchBackendTransport({ baseUrl }), input),
|
|
47
|
+
transport: withRuntimeHeaders(input.httpTransport ?? createGameServicesFetchBackendTransport({ baseUrl }), input, gate),
|
|
37
48
|
});
|
|
38
49
|
}
|
|
39
50
|
else {
|
|
@@ -49,8 +60,11 @@ export function createGameServicesRuntime(input) {
|
|
|
49
60
|
mode = 'local';
|
|
50
61
|
backend = input.localBackend;
|
|
51
62
|
}
|
|
63
|
+
// Every backend call is refused before it starts once the runtime is closed.
|
|
64
|
+
backend = guardBackend(backend, gate);
|
|
52
65
|
const clientInput = {
|
|
53
|
-
|
|
66
|
+
// A rewarded ad queued before the runtime closed is not shown after it.
|
|
67
|
+
gateway: guardRewardedAds(input.gateway, gate),
|
|
54
68
|
backend,
|
|
55
69
|
playerId: input.playerId,
|
|
56
70
|
target,
|
|
@@ -64,6 +78,23 @@ export function createGameServicesRuntime(input) {
|
|
|
64
78
|
...(input.now === undefined ? {} : { now: input.now }),
|
|
65
79
|
...(input.rewardEvidenceRegistry === undefined ? {} : { rewardEvidenceRegistry: input.rewardEvidenceRegistry }),
|
|
66
80
|
...(input.commerceProducts === undefined ? {} : { commerceProducts: input.commerceProducts }),
|
|
81
|
+
...(input.commerceAccountBinding === undefined ? {} : { commerceAccountBinding: input.commerceAccountBinding }),
|
|
82
|
+
commerceSessionCheck: () => {
|
|
83
|
+
const refusal = gate.check();
|
|
84
|
+
return refusal === undefined
|
|
85
|
+
? undefined
|
|
86
|
+
: new CommerceError('ACCOUNT_BINDING_REQUIRED', {
|
|
87
|
+
retryable: true,
|
|
88
|
+
storeCode: refusal.code,
|
|
89
|
+
message: refusal.message,
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
// Closed for good, the reconciler starts nothing: a recovery pass that
|
|
93
|
+
// reaches `start()` after `dispose()` stopped it subscribes nothing.
|
|
94
|
+
commerceClosed: () => gate.closed,
|
|
95
|
+
...(input.commerceStaleLaunchedAfterMs === undefined
|
|
96
|
+
? {}
|
|
97
|
+
: { commerceStaleLaunchedAfterMs: input.commerceStaleLaunchedAfterMs }),
|
|
67
98
|
};
|
|
68
99
|
const monetizationRecovery = input.operationStore === undefined
|
|
69
100
|
? undefined
|
|
@@ -88,34 +119,331 @@ export function createGameServicesRuntime(input) {
|
|
|
88
119
|
: { commerceReconciler: monetizationRecovery.commerceReconciler }),
|
|
89
120
|
};
|
|
90
121
|
}
|
|
122
|
+
const reconciler = routedClient.commerceReconciler;
|
|
123
|
+
/**
|
|
124
|
+
* Purchases, rewarded-ad claims, and recovery passes running now:
|
|
125
|
+
* `dispose()` lets them journal first.
|
|
126
|
+
*/
|
|
127
|
+
const purchases = new Set();
|
|
128
|
+
const track = (running) => {
|
|
129
|
+
purchases.add(running);
|
|
130
|
+
void running.finally(() => purchases.delete(running)).catch(() => undefined);
|
|
131
|
+
return running;
|
|
132
|
+
};
|
|
133
|
+
const exposedReconciler = reconciler === undefined
|
|
134
|
+
? undefined
|
|
135
|
+
: guardReconciler(reconciler, track);
|
|
136
|
+
gate.onClose(async () => {
|
|
137
|
+
// A purchase whose store sheet is open journals the store answer first,
|
|
138
|
+
// an open rewarded ad its platform result (both stay this player's to
|
|
139
|
+
// recover), and a recovery pass finishes its start, then the reconciler
|
|
140
|
+
// stops.
|
|
141
|
+
await settleWithin(Promise.allSettled([...purchases]), gameServicesDisposeWaitMs);
|
|
142
|
+
await settleWithin(reconciler?.stop() ?? Promise.resolve(), gameServicesDisposeWaitMs);
|
|
143
|
+
});
|
|
91
144
|
return {
|
|
92
145
|
mode,
|
|
93
146
|
...(baseUrl === undefined ? {} : { baseUrl }),
|
|
94
147
|
target,
|
|
95
|
-
client: routedClient,
|
|
96
|
-
...(monetizationRecovery === undefined
|
|
148
|
+
client: guardClient(routedClient, gate, track, exposedReconciler),
|
|
149
|
+
...(monetizationRecovery === undefined
|
|
150
|
+
? {}
|
|
151
|
+
: { monetizationRecovery: guardRecovery(monetizationRecovery, gate, exposedReconciler, track) }),
|
|
97
152
|
...(baseUrl === undefined
|
|
98
153
|
? {}
|
|
99
154
|
: {
|
|
100
|
-
accountDeletion: createAccountDeletionClient({
|
|
155
|
+
accountDeletion: guardAccountDeletion(createAccountDeletionClient({
|
|
101
156
|
// The deletion route is plain HTTP next to the oRPC handler: an
|
|
102
157
|
// oRPC URL names the handler's `/rpc` prefix, which the route
|
|
103
158
|
// does not sit under.
|
|
104
159
|
baseUrl: input.transport === 'orpc' ? baseUrl.replace(/\/rpc\/?$/u, '') : baseUrl,
|
|
105
160
|
...(input.headers === undefined ? {} : { headers: input.headers }),
|
|
106
|
-
|
|
161
|
+
getHeaders: guardHeaders(input.getHeaders, gate),
|
|
162
|
+
}), gate),
|
|
163
|
+
}),
|
|
164
|
+
dispose: () => gate.close(gameServicesRuntimeDisposedCode),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function createRuntimeGate(playerId, sessionPlayerId) {
|
|
168
|
+
let closed;
|
|
169
|
+
let closing;
|
|
170
|
+
const teardowns = [];
|
|
171
|
+
const gate = {
|
|
172
|
+
check() {
|
|
173
|
+
if (closed !== undefined) {
|
|
174
|
+
return closed;
|
|
175
|
+
}
|
|
176
|
+
if (sessionPlayerId === undefined) {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
let current;
|
|
180
|
+
try {
|
|
181
|
+
current = sessionPlayerId()?.trim();
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
current = undefined;
|
|
185
|
+
}
|
|
186
|
+
if (current === undefined || current === '') {
|
|
187
|
+
return new GameServicesRuntimeClosedError(gameServicesSessionRequiredCode);
|
|
188
|
+
}
|
|
189
|
+
if (current !== playerId.trim()) {
|
|
190
|
+
void gate.close(gameServicesPlayerChangedCode);
|
|
191
|
+
return closed;
|
|
192
|
+
}
|
|
193
|
+
return undefined;
|
|
194
|
+
},
|
|
195
|
+
get closed() {
|
|
196
|
+
return closed !== undefined;
|
|
197
|
+
},
|
|
198
|
+
assert() {
|
|
199
|
+
const refusal = gate.check();
|
|
200
|
+
if (refusal !== undefined) {
|
|
201
|
+
throw refusal;
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
close(code) {
|
|
205
|
+
closed ??= new GameServicesRuntimeClosedError(code);
|
|
206
|
+
closing ??= (async () => {
|
|
207
|
+
for (const teardown of teardowns) {
|
|
208
|
+
try {
|
|
209
|
+
await teardown();
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// Closed regardless: every operation is refused from now on.
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
})();
|
|
216
|
+
return closing;
|
|
217
|
+
},
|
|
218
|
+
onClose(teardown) {
|
|
219
|
+
teardowns.push(teardown);
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
return gate;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Account deletion refused before anything resolves or is sent: signed out
|
|
226
|
+
* answers `UNAUTHORIZED` (no session on this device), a closed runtime the
|
|
227
|
+
* retryable `ACCOUNT_DELETION_UNREACHABLE`. Static headers never stand in
|
|
228
|
+
* for a session the runtime refuses.
|
|
229
|
+
*/
|
|
230
|
+
function guardAccountDeletion(deletion, gate) {
|
|
231
|
+
return {
|
|
232
|
+
...deletion,
|
|
233
|
+
async deleteAccount(request) {
|
|
234
|
+
const refusal = gate.check();
|
|
235
|
+
if (refusal !== undefined) {
|
|
236
|
+
throw new AccountDeletionError(refusal.code === gameServicesSessionRequiredCode
|
|
237
|
+
? 'UNAUTHORIZED'
|
|
238
|
+
: 'ACCOUNT_DELETION_UNREACHABLE', { requestSent: false, cause: refusal });
|
|
239
|
+
}
|
|
240
|
+
return deletion.deleteAccount(request);
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
function guardHeaders(getHeaders, gate) {
|
|
245
|
+
return async () => {
|
|
246
|
+
gate.assert();
|
|
247
|
+
const headers = (await getHeaders?.()) ?? {};
|
|
248
|
+
// A session that changed player while its headers were resolved: they
|
|
249
|
+
// may be the new player's, so they are never sent for this one.
|
|
250
|
+
gate.assert();
|
|
251
|
+
return headers;
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
function guardBackend(backend, gate) {
|
|
255
|
+
const { purchases, adRewards, leaderboard } = backend;
|
|
256
|
+
const ledgerStatus = purchases.getPurchaseLedgerStatus;
|
|
257
|
+
const capabilities = purchases.getCommerceCapabilities;
|
|
258
|
+
return {
|
|
259
|
+
...(backend.version === undefined ? {} : { version: backend.version }),
|
|
260
|
+
purchases: {
|
|
261
|
+
async verifyPurchase(request) {
|
|
262
|
+
gate.assert();
|
|
263
|
+
return purchases.verifyPurchase(request);
|
|
264
|
+
},
|
|
265
|
+
...(ledgerStatus === undefined
|
|
266
|
+
? {}
|
|
267
|
+
: {
|
|
268
|
+
async getPurchaseLedgerStatus(request) {
|
|
269
|
+
gate.assert();
|
|
270
|
+
return ledgerStatus.call(purchases, request);
|
|
271
|
+
},
|
|
272
|
+
}),
|
|
273
|
+
...(capabilities === undefined
|
|
274
|
+
? {}
|
|
275
|
+
: {
|
|
276
|
+
async getCommerceCapabilities() {
|
|
277
|
+
gate.assert();
|
|
278
|
+
return capabilities.call(purchases);
|
|
279
|
+
},
|
|
107
280
|
}),
|
|
281
|
+
},
|
|
282
|
+
adRewards: {
|
|
283
|
+
async claimAdReward(request) {
|
|
284
|
+
gate.assert();
|
|
285
|
+
return adRewards.claimAdReward(request);
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
leaderboard: {
|
|
289
|
+
async recordScore(request) {
|
|
290
|
+
gate.assert();
|
|
291
|
+
return leaderboard.recordScore(request);
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
/** Resolves when `work` settles or after `ms`, whichever is first; never rejects. */
|
|
297
|
+
async function settleWithin(work, ms) {
|
|
298
|
+
let timer;
|
|
299
|
+
try {
|
|
300
|
+
await Promise.race([
|
|
301
|
+
work.then(() => undefined, () => undefined),
|
|
302
|
+
new Promise((resolve) => {
|
|
303
|
+
timer = setTimeout(resolve, ms);
|
|
108
304
|
}),
|
|
305
|
+
]);
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
clearTimeout(timer);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* The gateway with a rewarded-ad dispatch that throws the refusal once the
|
|
313
|
+
* runtime refuses, so no ad opens. It is not an ad outcome: a journaled
|
|
314
|
+
* operation stays unstarted (not ended), and `claimRewardedAd` answers
|
|
315
|
+
* `unavailable`.
|
|
316
|
+
*/
|
|
317
|
+
function guardRewardedAds(gateway, gate) {
|
|
318
|
+
const ads = gateway.ads;
|
|
319
|
+
return {
|
|
320
|
+
...gateway,
|
|
321
|
+
ads: {
|
|
322
|
+
...ads,
|
|
323
|
+
async showRewarded(request) {
|
|
324
|
+
gate.assert();
|
|
325
|
+
return ads.showRewarded(request);
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/** A reward claim through an open runtime; a dispatch the runtime refused answers `unavailable`. */
|
|
331
|
+
async function guardedReward(gate, track, run) {
|
|
332
|
+
if (gate.check() !== undefined) {
|
|
333
|
+
return refusedReward();
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
return await track(run());
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
if (error instanceof GameServicesRuntimeClosedError) {
|
|
340
|
+
return refusedReward();
|
|
341
|
+
}
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
function refusedPurchase(refusal) {
|
|
346
|
+
return {
|
|
347
|
+
status: 'failed',
|
|
348
|
+
purchase: {
|
|
349
|
+
status: 'failed',
|
|
350
|
+
entitlementIds: [],
|
|
351
|
+
diagnostic: { code: refusal.code, retryable: true },
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
function refusedReward() {
|
|
356
|
+
return { status: 'unavailable', reward: { status: 'unavailable', rewardGranted: false } };
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* A purchase through an open runtime. A refusal the reconciler gave because
|
|
360
|
+
* the runtime closed meanwhile answers the runtime's diagnostic, as a
|
|
361
|
+
* refusal at the entry does.
|
|
362
|
+
*/
|
|
363
|
+
async function guardedPurchase(gate, track, run) {
|
|
364
|
+
const refusal = gate.check();
|
|
365
|
+
if (refusal !== undefined) {
|
|
366
|
+
return refusedPurchase(refusal);
|
|
367
|
+
}
|
|
368
|
+
const result = await track(run());
|
|
369
|
+
const late = result.status === 'failed'
|
|
370
|
+
&& result.purchase.diagnostic?.code === 'ACCOUNT_BINDING_REQUIRED'
|
|
371
|
+
? gate.check()
|
|
372
|
+
: undefined;
|
|
373
|
+
return late === undefined ? result : refusedPurchase(late);
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* The reconciler a runtime exposes. Once the runtime closed for good its
|
|
377
|
+
* `start()` subscribes nothing (the reconciler's `closed`); while only signed
|
|
378
|
+
* out it starts as usual and its work waits for the session (its
|
|
379
|
+
* `sessionCheck`). Its purchases are tracked like the client's, so
|
|
380
|
+
* `dispose()` waits for an open store sheet's answer.
|
|
381
|
+
*/
|
|
382
|
+
function guardReconciler(reconciler, track) {
|
|
383
|
+
// The reconciler's getters and methods are closures: the prototype chain serves them.
|
|
384
|
+
return Object.create(reconciler, {
|
|
385
|
+
requestPurchase: {
|
|
386
|
+
value: (request) => track(reconciler.requestPurchase(request)),
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
/** The client of an open runtime; refusals answer before any store UI or ad opens. */
|
|
391
|
+
function guardClient(client, gate, track, reconciler) {
|
|
392
|
+
return {
|
|
393
|
+
...client,
|
|
394
|
+
...(reconciler === undefined ? {} : { commerceReconciler: reconciler }),
|
|
395
|
+
purchase: (purchase, options) => guardedPurchase(gate, track, () => client.purchase(purchase, options)),
|
|
396
|
+
claimRewardedAd: (reward, options) => guardedReward(gate, track, () => client.claimRewardedAd(reward, options)),
|
|
397
|
+
async submitLeaderboardScore(score) {
|
|
398
|
+
gate.assert();
|
|
399
|
+
return client.submitLeaderboardScore(score);
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
function guardRecovery(recovery, gate, reconciler, track) {
|
|
404
|
+
return {
|
|
405
|
+
...recovery,
|
|
406
|
+
...(reconciler === undefined ? {} : { commerceReconciler: reconciler }),
|
|
407
|
+
purchase: (purchase, options) => guardedPurchase(gate, track, () => recovery.purchase(purchase, options)),
|
|
408
|
+
claimRewardedAd: (reward, options) => guardedReward(gate, track, () => recovery.claimRewardedAd(reward, options)),
|
|
409
|
+
// A late ad callback is journaled; its claim is refused until a runtime
|
|
410
|
+
// for the player resends it, so it stays pending, never ended.
|
|
411
|
+
recoverRewardResult: (idempotencyKey, platform) => recovery.recoverRewardResult(idempotencyKey, platform),
|
|
412
|
+
async reconcile() {
|
|
413
|
+
// Startup and resume recovery wait for a runtime the session permits.
|
|
414
|
+
// `dispose()` waits for a pass under way, so the reconciler it starts
|
|
415
|
+
// is stopped after it, not before.
|
|
416
|
+
return gate.check() === undefined ? track(recovery.reconcile()) : [];
|
|
417
|
+
},
|
|
418
|
+
async restorePurchases() {
|
|
419
|
+
gate.assert();
|
|
420
|
+
let restored;
|
|
421
|
+
try {
|
|
422
|
+
restored = await recovery.restorePurchases();
|
|
423
|
+
}
|
|
424
|
+
catch (error) {
|
|
425
|
+
// Closed during the restore: the runtime's refusal says why.
|
|
426
|
+
gate.assert();
|
|
427
|
+
throw error;
|
|
428
|
+
}
|
|
429
|
+
// Closed during the restore: its passes stopped early for this player.
|
|
430
|
+
gate.assert();
|
|
431
|
+
return restored;
|
|
432
|
+
},
|
|
109
433
|
};
|
|
110
434
|
}
|
|
111
|
-
function withRuntimeHeaders(transport, input) {
|
|
435
|
+
function withRuntimeHeaders(transport, input, gate) {
|
|
112
436
|
return {
|
|
113
437
|
async send(request) {
|
|
438
|
+
gate.assert();
|
|
114
439
|
const headers = await resolveGameServicesRequestHeaders({
|
|
115
440
|
headers: input.headers,
|
|
116
441
|
getHeaders: input.getHeaders,
|
|
117
442
|
requestHeaders: request.headers,
|
|
118
443
|
});
|
|
444
|
+
// A session that changed player while its headers were resolved: they
|
|
445
|
+
// may be the new player's, so they are never sent for this one.
|
|
446
|
+
gate.assert();
|
|
119
447
|
return transport.send({
|
|
120
448
|
...request,
|
|
121
449
|
headers,
|
|
@@ -156,6 +484,8 @@ function disabledRuntime(reason, target) {
|
|
|
156
484
|
mode: 'disabled',
|
|
157
485
|
reason,
|
|
158
486
|
...(target === undefined ? {} : { target }),
|
|
487
|
+
// Nothing to close: a disabled runtime holds no client.
|
|
488
|
+
dispose: async () => undefined,
|
|
159
489
|
};
|
|
160
490
|
}
|
|
161
491
|
function normalizeBaseUrl(baseUrl) {
|
package/dist/server.d.ts
CHANGED
|
@@ -152,6 +152,15 @@ export interface CreateGameServicesRouterOptions {
|
|
|
152
152
|
}
|
|
153
153
|
export interface CreateGameServicesRpcFetchHandlerOptions extends CreateGameServicesFetchHandlerOptions {
|
|
154
154
|
readonly prefix?: string;
|
|
155
|
+
/**
|
|
156
|
+
* Writes the pre-beta.34 `inferable: false` flag into oRPC error bodies so game clients
|
|
157
|
+
* from `@mpgd/game-services` 0.30.5 or older (oRPC 2.0.0-beta.14) still decode error
|
|
158
|
+
* codes. A plain oRPC 2.0.0-beta.34+ client rejects that flag: such clients must wrap their
|
|
159
|
+
* fetch with `withLegacyOrpcErrorCompat`, as `createGameServicesOrpcClient` from 0.31.0 or
|
|
160
|
+
* later does. Set this to false once every supported game client comes from 0.31.0 or
|
|
161
|
+
* later. Defaults to true.
|
|
162
|
+
*/
|
|
163
|
+
readonly legacyOrpcErrorFlag?: boolean;
|
|
155
164
|
}
|
|
156
165
|
export interface GameServicesOrpcContext {
|
|
157
166
|
readonly request?: Request;
|
|
@@ -217,17 +226,17 @@ export declare function createGameServicesBackend(input: CreateGameServicesBacke
|
|
|
217
226
|
export declare function createGameServicesBackendApiHandler(input: CreateGameServicesBackendInput): GameServicesBackendApiHandler;
|
|
218
227
|
export declare function createInProcessGameServicesBackendTransport(handler: GameServicesBackendApiHandler): GameServicesBackendTransport;
|
|
219
228
|
export declare function createGameServicesRouter(backend: GameServicesBackendApi, options?: CreateGameServicesRouterOptions): {
|
|
220
|
-
health: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<GameServicesHealthResponse, GameServicesHealthResponse>,
|
|
229
|
+
health: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<GameServicesHealthResponse, GameServicesHealthResponse>, Record<never, never>>;
|
|
221
230
|
commerce: {
|
|
222
|
-
verifyPurchase: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<VerifyPurchaseRequest, VerifyPurchaseRequest>, import("@orpc/contract").Schema<VerifyPurchaseResponse, VerifyPurchaseResponse>,
|
|
223
|
-
getPurchaseLedgerStatus: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<PurchaseLedgerStatusRequest, PurchaseLedgerStatusRequest>, import("@orpc/contract").Schema<import("./operation-results.js").PurchaseLedgerStatusResponse, import("./operation-results.js").PurchaseLedgerStatusResponse>,
|
|
224
|
-
getCapabilities: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<import("@mpgd/platform/commerce").CommerceServiceCapabilities, import("@mpgd/platform/commerce").CommerceServiceCapabilities>,
|
|
231
|
+
verifyPurchase: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<VerifyPurchaseRequest, VerifyPurchaseRequest>, import("@orpc/contract").Schema<VerifyPurchaseResponse, VerifyPurchaseResponse>, Record<never, never>>;
|
|
232
|
+
getPurchaseLedgerStatus: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<PurchaseLedgerStatusRequest, PurchaseLedgerStatusRequest>, import("@orpc/contract").Schema<import("./operation-results.js").PurchaseLedgerStatusResponse, import("./operation-results.js").PurchaseLedgerStatusResponse>, Record<never, never>>;
|
|
233
|
+
getCapabilities: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<import("@mpgd/platform/commerce").CommerceServiceCapabilities, import("@mpgd/platform/commerce").CommerceServiceCapabilities>, Record<never, never>>;
|
|
225
234
|
};
|
|
226
235
|
ads: {
|
|
227
|
-
claimReward: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<ClaimAdRewardRequest, ClaimAdRewardRequest>, import("@orpc/contract").Schema<ClaimAdRewardResponse, ClaimAdRewardResponse>,
|
|
236
|
+
claimReward: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<ClaimAdRewardRequest, ClaimAdRewardRequest>, import("@orpc/contract").Schema<ClaimAdRewardResponse, ClaimAdRewardResponse>, Record<never, never>>;
|
|
228
237
|
};
|
|
229
238
|
leaderboard: {
|
|
230
|
-
recordScore: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<RecordLeaderboardScoreRequest, RecordLeaderboardScoreRequest>, import("@orpc/contract").Schema<RecordLeaderboardScoreResponse, RecordLeaderboardScoreResponse>,
|
|
239
|
+
recordScore: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<RecordLeaderboardScoreRequest, RecordLeaderboardScoreRequest>, import("@orpc/contract").Schema<RecordLeaderboardScoreResponse, RecordLeaderboardScoreResponse>, Record<never, never>>;
|
|
231
240
|
};
|
|
232
241
|
};
|
|
233
242
|
export declare function createGameServicesRpcFetchHandler(router: ReturnType<typeof createGameServicesRouter>, options?: CreateGameServicesRpcFetchHandlerOptions): (request: Request) => Promise<Response>;
|
package/dist/server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { implement, ORPCError } from '@orpc/server';
|
|
2
|
-
import {
|
|
2
|
+
import { RPCHandler } from '@orpc/server/fetch';
|
|
3
|
+
import { RequestLimitHandlerPlugin } from '@orpc/server/plugins';
|
|
3
4
|
import { createAnalyticsReporter } from '@mpgd/analytics';
|
|
4
5
|
import { resolveAdPlacementPlatformId, resolveCatalogCurrencies, resolveProductPlatformId, } from '@mpgd/catalog';
|
|
5
6
|
import { gameServicesBackendEndpoints, } from './client.js';
|
|
@@ -7,6 +8,7 @@ import { resolveCommerceStoreId, } from './commerce-evidence-registry.js';
|
|
|
7
8
|
import { createPurchaseHolderEvidenceVerificationId, findEntitlementRevocationsChunked, findPurchaseWideRevocation, holderRevocationEvidenceId, isEntitlementGrantReversed, isRestoredAcrossPlayers, listPurchaseHolders, readPurchaseLedgerStatuses, readPurchaseVerificationId, RevocationKeyConflictError, revokeEntitlementGrant, } from './commerce-ledger.js';
|
|
8
9
|
import { assertPlayerDataErasureInput, createRetainedLedgerEntryId, detachRetainedLedgerPayload, isRetainedPlayerId, } from './account-deletion.js';
|
|
9
10
|
import { gameServicesContract } from './contract.js';
|
|
11
|
+
import { addLegacyOrpcErrorFlag } from './orpc-error-compat.js';
|
|
10
12
|
import { createRejectingGameServicesEvidenceVerifier, } from './evidence-verification.js';
|
|
11
13
|
import { assertClaimAdRewardRequest, assertClaimAdRewardResponse, assertEntitlementLedgerGrant, assertEntitlementLedgerNote, assertEntitlementLedgerResult, assertEntitlementLedgerRevocation, assertEntitlementRevocationTransaction, assertGameServicesDeploymentTarget, assertLeaderboardScoreTransaction, assertProductGrantTransaction, assertPurchaseGrantFinalization, assertRecordLeaderboardScoreRequest, assertRecordLeaderboardScoreResponse, assertVerifyPurchaseRequest, assertVerifyPurchaseResponse, } from './types.js';
|
|
12
14
|
import { resolvePurchaseRestoreSharingPolicy, } from './purchase-sharing.js';
|
|
@@ -613,13 +615,14 @@ export function createGameServicesRouter(backend, options = {}) {
|
|
|
613
615
|
export function createGameServicesRpcFetchHandler(router, options = {}) {
|
|
614
616
|
const rpcHandler = new RPCHandler(router, {
|
|
615
617
|
plugins: [
|
|
616
|
-
new
|
|
618
|
+
new RequestLimitHandlerPlugin({
|
|
617
619
|
maxBodySize: resolveMaxBodyBytes(options.maxBodyBytes),
|
|
618
620
|
}),
|
|
619
621
|
],
|
|
620
622
|
});
|
|
621
623
|
const prefix = options.prefix ?? '/rpc';
|
|
622
624
|
const healthPath = options.healthPath ?? '/health';
|
|
625
|
+
const legacyOrpcErrorFlag = options.legacyOrpcErrorFlag ?? true;
|
|
623
626
|
return async (request) => {
|
|
624
627
|
const requestUrl = new URL(request.url);
|
|
625
628
|
if (requestUrl.pathname === healthPath) {
|
|
@@ -637,7 +640,11 @@ export function createGameServicesRpcFetchHandler(router, options = {}) {
|
|
|
637
640
|
if (!result.matched) {
|
|
638
641
|
return textResponse('Not Found', 404, options.corsHeaders);
|
|
639
642
|
}
|
|
640
|
-
|
|
643
|
+
// Error bodies keep the pre-beta.34 `inferable` flag so older game clients decode them.
|
|
644
|
+
const response = legacyOrpcErrorFlag
|
|
645
|
+
? await addLegacyOrpcErrorFlag(result.response)
|
|
646
|
+
: result.response;
|
|
647
|
+
return withCors(response, options.corsHeaders);
|
|
641
648
|
};
|
|
642
649
|
}
|
|
643
650
|
export function createGameServicesHttpFetchHandler(handler, options = {}) {
|
|
@@ -985,6 +992,22 @@ async function verifyPurchaseForRetry(request, retryIdentity, context) {
|
|
|
985
992
|
finalization,
|
|
986
993
|
});
|
|
987
994
|
}
|
|
995
|
+
if (product.type === 'consumable'
|
|
996
|
+
&& routedEntry?.status === 'routed'
|
|
997
|
+
&& routedEntry.entry.finish === 'client') {
|
|
998
|
+
// This player's own grant of a consumable the client finishes
|
|
999
|
+
// (StoreKit): the store still lists the transaction, so its finish
|
|
1000
|
+
// never happened or never reached the client's journal, and the
|
|
1001
|
+
// client's record of it is gone (journal retention), so it asks
|
|
1002
|
+
// under the derived key. The recorded grant is reported, never a
|
|
1003
|
+
// second one, so the client can finish the transaction instead of
|
|
1004
|
+
// the store re-delivering it on every launch.
|
|
1005
|
+
return assertVerifyPurchaseResponse({
|
|
1006
|
+
verified: true,
|
|
1007
|
+
ledgerEntryId: existingEvidence.ledgerEntryId,
|
|
1008
|
+
alreadyProcessed: true,
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
988
1011
|
}
|
|
989
1012
|
return assertVerifyPurchaseResponse({
|
|
990
1013
|
verified: false,
|