@mpgd/game-services 0.30.5 → 0.32.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 +101 -0
- package/dist/another-account.d.ts +65 -0
- package/dist/another-account.js +107 -0
- package/dist/app-store-verifier.js +24 -5
- package/dist/client.d.ts +16 -2
- package/dist/client.js +33 -5
- package/dist/commerce-reconciler.d.ts +89 -4
- package/dist/commerce-reconciler.js +467 -35
- package/dist/contract.d.ts +6 -6
- package/dist/evidence-verification.d.ts +14 -2
- package/dist/google-play-purchase.js +22 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- 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 +82 -2
- package/dist/runtime.js +363 -10
- package/dist/server.d.ts +31 -7
- package/dist/server.js +125 -15
- package/package.json +9 -9
package/README.md
CHANGED
|
@@ -183,6 +183,37 @@ and retry policy are not redesigned by this observer API.
|
|
|
183
183
|
The root `pnpm test` pipeline includes compiled client JS and public declaration
|
|
184
184
|
consumption checks alongside the client and target conformance suites.
|
|
185
185
|
|
|
186
|
+
## oRPC version and error compatibility
|
|
187
|
+
|
|
188
|
+
The oRPC transport (`createGameServicesOrpcClient`, `createGameServicesRouter`,
|
|
189
|
+
`createGameServicesRpcFetchHandler`) is built on oRPC `2.0.0-beta.46`, pinned
|
|
190
|
+
exactly. Pin the same exact version if a game or Worker also uses oRPC directly.
|
|
191
|
+
|
|
192
|
+
oRPC 2.0.0-beta.34 removed the `inferable` flag from RPC error bodies, and each
|
|
193
|
+
side rejects the other's shape: an older client would see a malformed response
|
|
194
|
+
instead of `BAD_REQUEST` or `NOT_FOUND`, and so would a newer client talking to
|
|
195
|
+
an older Worker. Games and Workers deploy separately, so this package bridges
|
|
196
|
+
the change in `src/orpc-error-compat.ts`:
|
|
197
|
+
|
|
198
|
+
- `createGameServicesRpcFetchHandler` adds `inferable: false` to oRPC error
|
|
199
|
+
bodies, so clients from `@mpgd/game-services` 0.30.5 or older (oRPC
|
|
200
|
+
2.0.0-beta.14) still decode error codes;
|
|
201
|
+
- `createGameServicesOrpcClient` (0.31.0 or later) removes a boolean `inferable`
|
|
202
|
+
from error bodies before oRPC validates them, so it still decodes errors from
|
|
203
|
+
Workers on 0.30.5 or older.
|
|
204
|
+
|
|
205
|
+
Success responses are unchanged. Any other oRPC 2.0.0-beta.34+ client that calls
|
|
206
|
+
a game-services Worker's `/rpc` route (for example a plain `RPCLink` in a game)
|
|
207
|
+
must wrap its fetch with `withLegacyOrpcErrorCompat` from
|
|
208
|
+
`@mpgd/game-services/client`, or it sees the legacy flag as a malformed error. A
|
|
209
|
+
Worker that no longer serves clients from 0.30.5 or older can pass
|
|
210
|
+
`legacyOrpcErrorFlag: false` to `createGameServicesRpcFetchHandler`. The shim is
|
|
211
|
+
removed once the oldest supported game client and every supported Worker come
|
|
212
|
+
from `@mpgd/game-services` 0.31.0 or later (oRPC 2.0.0-beta.46). Other oRPC changes picked up with it: RPC routes accept
|
|
213
|
+
only POST, PUT, PATCH, and DELETE (a GET answers 404, and the kit clients always
|
|
214
|
+
POST), and `Infinity` inputs are encoded with their own meta marker, which a Worker
|
|
215
|
+
on 0.30.5 or older (oRPC 2.0.0-beta.14) cannot decode.
|
|
216
|
+
|
|
186
217
|
## Runtime backend transport
|
|
187
218
|
|
|
188
219
|
`createGameServicesRuntime` accepts `httpTransport` for its HTTP JSON backend
|
|
@@ -233,6 +264,76 @@ them into analytics or logs.
|
|
|
233
264
|
All published entrypoints use explicit internal ESM module paths and are smoke-tested
|
|
234
265
|
with native Node imports, without a bundler or TypeScript runtime loader.
|
|
235
266
|
|
|
267
|
+
## One player per runtime
|
|
268
|
+
|
|
269
|
+
A runtime, its purchase journal records, and its reconciler belong to
|
|
270
|
+
`playerId` for their whole life, while `getHeaders` and a store provider's
|
|
271
|
+
account binding follow the live session. When the session can change player
|
|
272
|
+
within a run (an account switch), pass `sessionPlayerId: () => session.current()?.playerId`:
|
|
273
|
+
|
|
274
|
+
- It is read before every backend request and again once that request's
|
|
275
|
+
headers are resolved, and by the commerce reconciler before each pass,
|
|
276
|
+
queued event, record step, store read, restore, and finish, and right
|
|
277
|
+
before a store UI opens.
|
|
278
|
+
- Another player closes the runtime for good: nothing more is verified,
|
|
279
|
+
restored, or finished for this player with the other player's credentials
|
|
280
|
+
(a backend answers that with a permanent account-mismatch rejection), and
|
|
281
|
+
no store UI opens for a purchase journaled here once the session names
|
|
282
|
+
someone else. The provider resolves its account binding a few steps after
|
|
283
|
+
that last check, so the binding resolver must follow the runtime's player
|
|
284
|
+
too (bind nothing for anyone else, as the generated game's
|
|
285
|
+
`pinStarterCommercePlayer` does) for a purchase never to be
|
|
286
|
+
journaled here and bound to the other account; a binding refused while the
|
|
287
|
+
session changed keeps the attempt retryable. Purchases
|
|
288
|
+
answer `failed` with the retryable diagnostic `GAME_SERVICES_PLAYER_CHANGED`
|
|
289
|
+
before any store UI, rewarded-ad claims answer `unavailable` before any
|
|
290
|
+
ad, recovery runs nothing, and Restore Purchases rejects with
|
|
291
|
+
`GameServicesRuntimeClosedError`.
|
|
292
|
+
- No player (signed out) refuses the same way with
|
|
293
|
+
`GAME_SERVICES_SESSION_REQUIRED` and leaves the runtime open for its player.
|
|
294
|
+
|
|
295
|
+
Refusals end no journal record: a runtime created for the player later
|
|
296
|
+
resumes them. `runtime.dispose()` closes a runtime the same way
|
|
297
|
+
(`GAME_SERVICES_RUNTIME_DISPOSED`) and resolves once a purchase whose store
|
|
298
|
+
sheet is open has journaled its answer, a recovery pass under way
|
|
299
|
+
(`monetizationRecovery.reconcile()`) has finished, and the reconciler has
|
|
300
|
+
stopped (each wait bounded by `gameServicesDisposeWaitMs`, 3 minutes); call
|
|
301
|
+
it, and wait, before creating the runtime for another player or reloading.
|
|
302
|
+
A recovery pass that outlives that wait starts no store subscription
|
|
303
|
+
afterwards. A custom
|
|
304
|
+
`httpTransport` must send only the headers the runtime resolved for each
|
|
305
|
+
request, or the after-resolution check cannot cover it. A refusal keeps its
|
|
306
|
+
`GameServicesRuntimeClosedError` through header resolution and the HTTP
|
|
307
|
+
transport (it is not turned into `GameServicesHeaderResolutionError` or
|
|
308
|
+
`GameServicesBackendTransportError`). A reconciler started while the session
|
|
309
|
+
is signed out still subscribes to the store; its work waits for the session.
|
|
310
|
+
|
|
311
|
+
Pass `commerceAccountBinding` too: this player's store account binding,
|
|
312
|
+
derived from `playerId` the way the store provider binds the player's new
|
|
313
|
+
purchases (Google Play's obfuscated account id, StoreKit's app account
|
|
314
|
+
token). The reconciler then leaves a store purchase bound to another account
|
|
315
|
+
alone (reported, never journaled or verified for this player). After a
|
|
316
|
+
restart as another player, Google Play still lists the previous player's
|
|
317
|
+
unacknowledged purchases; sending one with this player's session only gets
|
|
318
|
+
the backend's another-account answer (409 `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT`),
|
|
319
|
+
which parks the record without a grant. The reconciler parks it whenever the
|
|
320
|
+
backend is reached anyway (no binding input, an older client's record): the
|
|
321
|
+
record stays `verifying`, is never resent automatically, and leaves its
|
|
322
|
+
derived key to the purchase's own account on the install
|
|
323
|
+
([another account's purchase](../../docs/GAME_SERVICES_BACKEND.md#another-accounts-purchase)).
|
|
324
|
+
The player's explicit Restore Purchases still takes a durable
|
|
325
|
+
bound to another account (restore sharing). StoreKit's provider already
|
|
326
|
+
filters transactions by app account token the same way.
|
|
327
|
+
|
|
328
|
+
`dispose()` is a required member of `GameServicesRuntime`: a runtime built by
|
|
329
|
+
hand (a test fake, a custom wrapper) adds `dispose: async () => undefined`.
|
|
330
|
+
|
|
331
|
+
A game adopting this creates its runtime from its server session's player
|
|
332
|
+
(never a platform placeholder), passes `sessionPlayerId` and
|
|
333
|
+
`commerceAccountBinding`, keeps its store account binding resolver on the
|
|
334
|
+
same player (bind nothing for anyone else), and disposes the runtime (and
|
|
335
|
+
waits) before it creates one for another player.
|
|
336
|
+
|
|
236
337
|
## Guest session and account binding foundation
|
|
237
338
|
|
|
238
339
|
`createGuestSessionCoordinator` from `@mpgd/game-services/guest-session`
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Another account's purchase (RFC "Another account's purchase"): a verify
|
|
3
|
+
* whose store binding (Google Play `obfuscatedAccountId` /
|
|
4
|
+
* `obfuscatedProfileId`, StoreKit `appAccountToken`) names another account
|
|
5
|
+
* than the requesting player's. It is not granted, nothing is recorded, and
|
|
6
|
+
* the store transaction stays recoverable by the account it is bound to, so
|
|
7
|
+
* the backend answers it as a retryable error instead of a `rejected`
|
|
8
|
+
* receipt: HTTP 409 `{ error: 'PURCHASE_BOUND_TO_ANOTHER_ACCOUNT', reason }`,
|
|
9
|
+
* or the oRPC error code `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT` (status 409,
|
|
10
|
+
* `data.reason`). A client from before this answer treats it as a transport
|
|
11
|
+
* failure and keeps the record open; the commerce reconciler parks it.
|
|
12
|
+
*
|
|
13
|
+
* No schema, DOM, or transport dependencies: the server, the client, and a
|
|
14
|
+
* game server wrapping the reference verifiers share these helpers.
|
|
15
|
+
*/
|
|
16
|
+
/** The error code of the answer, on HTTP (`error`) and oRPC (`code`) alike. */
|
|
17
|
+
export declare const purchaseBoundToAnotherAccountCode: 'PURCHASE_BOUND_TO_ANOTHER_ACCOUNT';
|
|
18
|
+
/** The HTTP status of the answer: the purchase conflicts with the requesting player's account. */
|
|
19
|
+
export declare const purchaseBoundToAnotherAccountStatus = 409;
|
|
20
|
+
/**
|
|
21
|
+
* The binding mismatch reasons of the reference verifiers. Each one means
|
|
22
|
+
* the purchase carries another account's binding: a purchase without one
|
|
23
|
+
* answers `GOOGLE_PLAY_ACCOUNT_ID_MISSING`, `GOOGLE_PLAY_PROFILE_ID_MISSING`,
|
|
24
|
+
* `GOOGLE_PLAY_ACCOUNT_BINDING_UNEXPECTED`, or `APP_STORE_ACCOUNT_TOKEN_MISSING`
|
|
25
|
+
* instead, which stay `rejected`. A backend from before this answer, or a game
|
|
26
|
+
* server that maps every refusal to `rejected`, still answers these reasons
|
|
27
|
+
* as a `rejected` receipt; the commerce reconciler reads that receipt as this
|
|
28
|
+
* answer too.
|
|
29
|
+
*/
|
|
30
|
+
export declare const storeAccountMismatchReasons: readonly ["GOOGLE_PLAY_ACCOUNT_MISMATCH", "GOOGLE_PLAY_PROFILE_MISMATCH", "APP_STORE_ACCOUNT_TOKEN_MISMATCH"];
|
|
31
|
+
export type StoreAccountMismatchReason = (typeof storeAccountMismatchReasons)[number];
|
|
32
|
+
export declare function isStoreAccountMismatchReason(reason: unknown): reason is StoreAccountMismatchReason;
|
|
33
|
+
/**
|
|
34
|
+
* Thrown by the reference backend's `verifyPurchase` for another account's
|
|
35
|
+
* purchase; the HTTP handler and the oRPC router turn it into the 409
|
|
36
|
+
* answer. A game server that calls `backend.purchases.verifyPurchase` itself
|
|
37
|
+
* answers it the same way (see `docs/GAME_SERVICES_BACKEND.md`). Its
|
|
38
|
+
* `message` starts with the code, so a Workers RPC caller whose runtime
|
|
39
|
+
* keeps only the message still recognizes it.
|
|
40
|
+
*/
|
|
41
|
+
export declare class GameServicesPurchaseBoundToAnotherAccountError extends Error {
|
|
42
|
+
readonly code: "PURCHASE_BOUND_TO_ANOTHER_ACCOUNT";
|
|
43
|
+
readonly status = 409;
|
|
44
|
+
/** The store's binding mismatch, for example `GOOGLE_PLAY_ACCOUNT_MISMATCH`. */
|
|
45
|
+
readonly reason: string;
|
|
46
|
+
constructor(reason: string);
|
|
47
|
+
}
|
|
48
|
+
/** The body of the HTTP 409 answer. */
|
|
49
|
+
export interface PurchaseBoundToAnotherAccountBody {
|
|
50
|
+
readonly error: typeof purchaseBoundToAnotherAccountCode;
|
|
51
|
+
readonly reason: string;
|
|
52
|
+
}
|
|
53
|
+
export declare function createPurchaseBoundToAnotherAccountBody(reason: string): PurchaseBoundToAnotherAccountBody;
|
|
54
|
+
/**
|
|
55
|
+
* The binding mismatch of an error that carries the answer, or undefined for
|
|
56
|
+
* any other error. It reads every shape a client meets: the thrown
|
|
57
|
+
* `GameServicesPurchaseBoundToAnotherAccountError` (an in-process backend),
|
|
58
|
+
* an oRPC error (`code`, `data.reason`), a `GameServicesBackendError` of the
|
|
59
|
+
* HTTP transport (`status` 409 and `body.error`), and an error rebuilt from
|
|
60
|
+
* its message alone (a Workers RPC caller without enhanced error
|
|
61
|
+
* serialization).
|
|
62
|
+
*/
|
|
63
|
+
export declare function readPurchaseBoundToAnotherAccount(error: unknown): {
|
|
64
|
+
readonly reason: string;
|
|
65
|
+
} | undefined;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Another account's purchase (RFC "Another account's purchase"): a verify
|
|
3
|
+
* whose store binding (Google Play `obfuscatedAccountId` /
|
|
4
|
+
* `obfuscatedProfileId`, StoreKit `appAccountToken`) names another account
|
|
5
|
+
* than the requesting player's. It is not granted, nothing is recorded, and
|
|
6
|
+
* the store transaction stays recoverable by the account it is bound to, so
|
|
7
|
+
* the backend answers it as a retryable error instead of a `rejected`
|
|
8
|
+
* receipt: HTTP 409 `{ error: 'PURCHASE_BOUND_TO_ANOTHER_ACCOUNT', reason }`,
|
|
9
|
+
* or the oRPC error code `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT` (status 409,
|
|
10
|
+
* `data.reason`). A client from before this answer treats it as a transport
|
|
11
|
+
* failure and keeps the record open; the commerce reconciler parks it.
|
|
12
|
+
*
|
|
13
|
+
* No schema, DOM, or transport dependencies: the server, the client, and a
|
|
14
|
+
* game server wrapping the reference verifiers share these helpers.
|
|
15
|
+
*/
|
|
16
|
+
/** The error code of the answer, on HTTP (`error`) and oRPC (`code`) alike. */
|
|
17
|
+
export const purchaseBoundToAnotherAccountCode = 'PURCHASE_BOUND_TO_ANOTHER_ACCOUNT';
|
|
18
|
+
/** The HTTP status of the answer: the purchase conflicts with the requesting player's account. */
|
|
19
|
+
export const purchaseBoundToAnotherAccountStatus = 409;
|
|
20
|
+
/**
|
|
21
|
+
* The binding mismatch reasons of the reference verifiers. Each one means
|
|
22
|
+
* the purchase carries another account's binding: a purchase without one
|
|
23
|
+
* answers `GOOGLE_PLAY_ACCOUNT_ID_MISSING`, `GOOGLE_PLAY_PROFILE_ID_MISSING`,
|
|
24
|
+
* `GOOGLE_PLAY_ACCOUNT_BINDING_UNEXPECTED`, or `APP_STORE_ACCOUNT_TOKEN_MISSING`
|
|
25
|
+
* instead, which stay `rejected`. A backend from before this answer, or a game
|
|
26
|
+
* server that maps every refusal to `rejected`, still answers these reasons
|
|
27
|
+
* as a `rejected` receipt; the commerce reconciler reads that receipt as this
|
|
28
|
+
* answer too.
|
|
29
|
+
*/
|
|
30
|
+
export const storeAccountMismatchReasons = Object.freeze([
|
|
31
|
+
'GOOGLE_PLAY_ACCOUNT_MISMATCH',
|
|
32
|
+
'GOOGLE_PLAY_PROFILE_MISMATCH',
|
|
33
|
+
'APP_STORE_ACCOUNT_TOKEN_MISMATCH',
|
|
34
|
+
]);
|
|
35
|
+
export function isStoreAccountMismatchReason(reason) {
|
|
36
|
+
return typeof reason === 'string'
|
|
37
|
+
&& storeAccountMismatchReasons.includes(reason);
|
|
38
|
+
}
|
|
39
|
+
const reasonPattern = /^[A-Za-z0-9_.:-]{1,128}$/u;
|
|
40
|
+
function readReason(value) {
|
|
41
|
+
return typeof value === 'string' && reasonPattern.test(value) ? value : undefined;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Thrown by the reference backend's `verifyPurchase` for another account's
|
|
45
|
+
* purchase; the HTTP handler and the oRPC router turn it into the 409
|
|
46
|
+
* answer. A game server that calls `backend.purchases.verifyPurchase` itself
|
|
47
|
+
* answers it the same way (see `docs/GAME_SERVICES_BACKEND.md`). Its
|
|
48
|
+
* `message` starts with the code, so a Workers RPC caller whose runtime
|
|
49
|
+
* keeps only the message still recognizes it.
|
|
50
|
+
*/
|
|
51
|
+
export class GameServicesPurchaseBoundToAnotherAccountError extends Error {
|
|
52
|
+
code = purchaseBoundToAnotherAccountCode;
|
|
53
|
+
status = purchaseBoundToAnotherAccountStatus;
|
|
54
|
+
/** The store's binding mismatch, for example `GOOGLE_PLAY_ACCOUNT_MISMATCH`. */
|
|
55
|
+
reason;
|
|
56
|
+
constructor(reason) {
|
|
57
|
+
const checked = readReason(reason) ?? purchaseBoundToAnotherAccountCode;
|
|
58
|
+
super(`${purchaseBoundToAnotherAccountCode}: ${checked}`);
|
|
59
|
+
this.name = 'GameServicesPurchaseBoundToAnotherAccountError';
|
|
60
|
+
this.reason = checked;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function createPurchaseBoundToAnotherAccountBody(reason) {
|
|
64
|
+
return {
|
|
65
|
+
error: purchaseBoundToAnotherAccountCode,
|
|
66
|
+
reason: readReason(reason) ?? purchaseBoundToAnotherAccountCode,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The binding mismatch of an error that carries the answer, or undefined for
|
|
71
|
+
* any other error. It reads every shape a client meets: the thrown
|
|
72
|
+
* `GameServicesPurchaseBoundToAnotherAccountError` (an in-process backend),
|
|
73
|
+
* an oRPC error (`code`, `data.reason`), a `GameServicesBackendError` of the
|
|
74
|
+
* HTTP transport (`status` 409 and `body.error`), and an error rebuilt from
|
|
75
|
+
* its message alone (a Workers RPC caller without enhanced error
|
|
76
|
+
* serialization).
|
|
77
|
+
*/
|
|
78
|
+
export function readPurchaseBoundToAnotherAccount(error) {
|
|
79
|
+
if (typeof error !== 'object' || error === null) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
const candidate = error;
|
|
83
|
+
const fallback = { reason: purchaseBoundToAnotherAccountCode };
|
|
84
|
+
if (candidate.code === purchaseBoundToAnotherAccountCode) {
|
|
85
|
+
const data = candidate.data;
|
|
86
|
+
const dataReason = typeof data === 'object' && data !== null
|
|
87
|
+
? readReason(data.reason)
|
|
88
|
+
: undefined;
|
|
89
|
+
const reason = readReason(candidate.reason) ?? dataReason;
|
|
90
|
+
return reason === undefined ? fallback : { reason };
|
|
91
|
+
}
|
|
92
|
+
const body = candidate.body;
|
|
93
|
+
if (candidate.status === purchaseBoundToAnotherAccountStatus
|
|
94
|
+
&& typeof body === 'object'
|
|
95
|
+
&& body !== null
|
|
96
|
+
&& body.error === purchaseBoundToAnotherAccountCode) {
|
|
97
|
+
const reason = readReason(body.reason);
|
|
98
|
+
return reason === undefined ? fallback : { reason };
|
|
99
|
+
}
|
|
100
|
+
const message = candidate.message;
|
|
101
|
+
const prefix = `${purchaseBoundToAnotherAccountCode}: `;
|
|
102
|
+
if (typeof message === 'string' && message.startsWith(prefix)) {
|
|
103
|
+
const reason = readReason(message.slice(prefix.length));
|
|
104
|
+
return reason === undefined ? fallback : { reason };
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
@@ -250,19 +250,38 @@ async function verifyAppStorePurchase(input, options, now, clockSkewMs) {
|
|
|
250
250
|
// when every other check passes: the backend's restore sharing policy
|
|
251
251
|
// decides (RFC "Restore sharing"). The client's StoreKit-verified JWS is
|
|
252
252
|
// required, so a transaction id alone never shares a purchase.
|
|
253
|
-
const
|
|
253
|
+
const tokenMismatch = rejection === 'APP_STORE_ACCOUNT_TOKEN_MISMATCH';
|
|
254
|
+
if (tokenMismatch) {
|
|
255
|
+
// Every other check still decides: only a purchase that passes them all
|
|
256
|
+
// is reported as restorable or as another account's.
|
|
257
|
+
rejection = validateTransaction(input, transaction, options.bundleId, environment, undefined, nowMs, clockSkewMs);
|
|
258
|
+
}
|
|
259
|
+
const foreignBinding = tokenMismatch
|
|
254
260
|
&& input.request.intent === 'restore'
|
|
255
261
|
&& input.product.type === 'non_consumable'
|
|
256
262
|
&& offlineTransaction !== undefined;
|
|
257
|
-
if (foreignBinding) {
|
|
258
|
-
rejection = validateTransaction(input, transaction, options.bundleId, environment, undefined, nowMs, clockSkewMs);
|
|
259
|
-
}
|
|
260
263
|
if (rejection !== undefined) {
|
|
261
264
|
return { status: 'rejected', reason: rejection };
|
|
262
265
|
}
|
|
266
|
+
if (tokenMismatch && !foreignBinding) {
|
|
267
|
+
// A transaction without an appAccountToken (bought outside the app, or
|
|
268
|
+
// by a build that set none) is no account's: refused for every player.
|
|
269
|
+
// One with another token is that account's, which can still recover it
|
|
270
|
+
// (RFC "Another account's purchase").
|
|
271
|
+
return transaction.appAccountToken === undefined
|
|
272
|
+
? { status: 'rejected', reason: 'APP_STORE_ACCOUNT_TOKEN_MISSING' }
|
|
273
|
+
: { status: 'rejected', reason: 'APP_STORE_ACCOUNT_TOKEN_MISMATCH', anotherAccount: true };
|
|
274
|
+
}
|
|
263
275
|
return {
|
|
264
276
|
...(foreignBinding
|
|
265
|
-
? {
|
|
277
|
+
? {
|
|
278
|
+
status: 'restorable',
|
|
279
|
+
// Bound to no account, or to another one: the backend refuses the
|
|
280
|
+
// former for good when no share covers it.
|
|
281
|
+
reason: transaction.appAccountToken === undefined
|
|
282
|
+
? 'APP_STORE_ACCOUNT_TOKEN_MISSING'
|
|
283
|
+
: 'APP_STORE_ACCOUNT_TOKEN_MISMATCH',
|
|
284
|
+
}
|
|
266
285
|
: { status: 'verified' }),
|
|
267
286
|
verificationId: createVerificationId(transaction),
|
|
268
287
|
verifiedAt: new Date(nowMs).toISOString(),
|
package/dist/client.d.ts
CHANGED
|
@@ -2,11 +2,12 @@ import { type AnalyticsSink } from '@mpgd/analytics';
|
|
|
2
2
|
import type { LeaderboardScoreInput, PlatformGateway, PlatformTarget } from '@mpgd/platform';
|
|
3
3
|
import type { CommerceServiceCapabilities } from '@mpgd/platform/commerce';
|
|
4
4
|
import { type ClientRewardEvidenceRegistry } from './client-reward-evidence.js';
|
|
5
|
-
import { type CommerceReconciler, type CommerceReconcilerEvent, type CommerceReconcilerProduct } from './commerce-reconciler.js';
|
|
5
|
+
import { type CommerceReconciler, type CommerceReconcilerEvent, type CommerceReconcilerProduct, type CreateCommerceReconcilerInput } from './commerce-reconciler.js';
|
|
6
6
|
import type { MonetizationOperationStore } from './monetization-recovery.js';
|
|
7
7
|
import type { GameServicesOperationClient } from './operations.js';
|
|
8
8
|
export type { GameServicesOperationClient, GameServicesPurchaseInput, GameServicesPurchaseResult, GameServicesRewardedAdInput, GameServicesRewardedAdResult, } from './operations.js';
|
|
9
9
|
import type { GameServicesContractClient } from './contract.js';
|
|
10
|
+
export { withLegacyOrpcErrorCompat } from './orpc-error-compat.js';
|
|
10
11
|
import { type GameServicesHeaderResolver } from './request-headers.js';
|
|
11
12
|
import type { ClaimAdRewardRequest, ClaimAdRewardResponse, GameServicesLedgerTarget, PurchaseLedgerStatusRequest, PurchaseLedgerStatusResponse, RecordLeaderboardScoreRequest, RecordLeaderboardScoreResponse, VerifyPurchaseRequest, VerifyPurchaseResponse } from './types.js';
|
|
12
13
|
export type { GameServicesOperationKind, GameServicesOperationLocation, GameServicesOperationOptions, GameServicesOperationProgress, GameServicesPurchaseProgress, GameServicesRewardedAdProgress, } from './operation-progress.js';
|
|
@@ -133,8 +134,21 @@ export interface CreateGameServicesClientInput {
|
|
|
133
134
|
readonly warn?: (message: string) => void;
|
|
134
135
|
/** Catalog kinds and SKUs the reconciler resolves without a store read. */
|
|
135
136
|
readonly commerceProducts?: readonly CommerceReconcilerProduct[];
|
|
137
|
+
/** The reconciler's `staleLaunchedAfterMs` (24 hours by default). */
|
|
138
|
+
readonly commerceStaleLaunchedAfterMs?: number;
|
|
136
139
|
readonly onCommerceEvent?: (event: CommerceReconcilerEvent) => void;
|
|
137
|
-
|
|
140
|
+
/** The reconciler's `sessionCheck` (see `CreateCommerceReconcilerInput`). */
|
|
141
|
+
readonly commerceSessionCheck?: CreateCommerceReconcilerInput['sessionCheck'];
|
|
142
|
+
/** The reconciler's `closed` (see `CreateCommerceReconcilerInput`). */
|
|
143
|
+
readonly commerceClosed?: CreateCommerceReconcilerInput['closed'];
|
|
144
|
+
/** The reconciler's `accountBinding` (see `CreateCommerceReconcilerInput`). */
|
|
145
|
+
readonly commerceAccountBinding?: CreateCommerceReconcilerInput['accountBinding'];
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* `commerceStaleLaunchedAfterMs` is checked where it enters, so a bad value
|
|
149
|
+
* fails even when no reconciler is built (a gateway without `commerce`).
|
|
150
|
+
*/
|
|
151
|
+
export declare function assertCommerceStaleLaunchedAfterMs(value: number | undefined): void;
|
|
138
152
|
export interface GameServicesLeaderboardInput extends LeaderboardScoreInput {
|
|
139
153
|
}
|
|
140
154
|
export interface GameServicesLeaderboardResult {
|
package/dist/client.js
CHANGED
|
@@ -6,7 +6,9 @@ import { createCommerceReconciler, toGameServicesPurchaseResult, } from './comme
|
|
|
6
6
|
import { createDefaultClientRewardEvidenceRegistry } from './default-client-reward-evidence.js';
|
|
7
7
|
import { createInMemoryMonetizationOperationStore } from './monetization-operation-store.js';
|
|
8
8
|
import { observeGameServicesOperation } from './operation-progress.js';
|
|
9
|
-
import {
|
|
9
|
+
import { withLegacyOrpcErrorCompat } from './orpc-error-compat.js';
|
|
10
|
+
export { withLegacyOrpcErrorCompat } from './orpc-error-compat.js';
|
|
11
|
+
import { GameServicesHeaderResolutionError, GameServicesRuntimeClosedError, resolveGameServicesRequestHeaders, } from './request-headers.js';
|
|
10
12
|
export { GameServicesHeaderResolutionError, resolveGameServicesRequestHeaders, } from './request-headers.js';
|
|
11
13
|
export const gameServicesBackendEndpoints = {
|
|
12
14
|
verifyPurchase: '/game-services/purchases/verify',
|
|
@@ -35,7 +37,17 @@ export class GameServicesBackendTransportError extends Error {
|
|
|
35
37
|
this.endpoint = endpoint;
|
|
36
38
|
}
|
|
37
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* `commerceStaleLaunchedAfterMs` is checked where it enters, so a bad value
|
|
42
|
+
* fails even when no reconciler is built (a gateway without `commerce`).
|
|
43
|
+
*/
|
|
44
|
+
export function assertCommerceStaleLaunchedAfterMs(value) {
|
|
45
|
+
if (value !== undefined && !(typeof value === 'number' && value > 0)) {
|
|
46
|
+
throw new RangeError('commerceStaleLaunchedAfterMs must be a positive duration in milliseconds, or Infinity.');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
38
49
|
export function createGameServicesClient(input) {
|
|
50
|
+
assertCommerceStaleLaunchedAfterMs(input.commerceStaleLaunchedAfterMs);
|
|
39
51
|
const now = input.now ?? (() => new Date().toISOString());
|
|
40
52
|
const requestNow = input.requestNow ?? now;
|
|
41
53
|
const rewardEvidenceRegistry = input.rewardEvidenceRegistry ?? createDefaultClientRewardEvidenceRegistry();
|
|
@@ -62,6 +74,12 @@ export function createGameServicesClient(input) {
|
|
|
62
74
|
target: input.target,
|
|
63
75
|
...(input.deploymentTarget === undefined ? {} : { deploymentTarget: input.deploymentTarget }),
|
|
64
76
|
...(input.commerceProducts === undefined ? {} : { products: input.commerceProducts }),
|
|
77
|
+
...(input.commerceSessionCheck === undefined ? {} : { sessionCheck: input.commerceSessionCheck }),
|
|
78
|
+
...(input.commerceClosed === undefined ? {} : { closed: input.commerceClosed }),
|
|
79
|
+
...(input.commerceAccountBinding === undefined ? {} : { accountBinding: input.commerceAccountBinding }),
|
|
80
|
+
...(input.commerceStaleLaunchedAfterMs === undefined
|
|
81
|
+
? {}
|
|
82
|
+
: { staleLaunchedAfterMs: input.commerceStaleLaunchedAfterMs }),
|
|
65
83
|
now: requestNow,
|
|
66
84
|
});
|
|
67
85
|
if (input.onCommerceEvent !== undefined) {
|
|
@@ -173,7 +191,8 @@ export function createGameServicesClient(input) {
|
|
|
173
191
|
}
|
|
174
192
|
else if (!serverResulted) {
|
|
175
193
|
serverResulted = true;
|
|
176
|
-
|
|
194
|
+
// Another account's purchase is a server answer with no grant.
|
|
195
|
+
progress.serverResult(event.type === 'verify-result' && event.response.verified);
|
|
177
196
|
}
|
|
178
197
|
});
|
|
179
198
|
try {
|
|
@@ -217,7 +236,9 @@ export function createGameServicesClient(input) {
|
|
|
217
236
|
alreadyProcessed: result.verification?.alreadyProcessed,
|
|
218
237
|
reason: result.status === 'pending'
|
|
219
238
|
? 'purchase_pending'
|
|
220
|
-
: (result.verification?.reason
|
|
239
|
+
: (result.verification?.reason
|
|
240
|
+
?? record?.anotherAccount?.reason
|
|
241
|
+
?? record?.error?.code),
|
|
221
242
|
},
|
|
222
243
|
});
|
|
223
244
|
}
|
|
@@ -424,9 +445,16 @@ export function createGameServicesFetchBackendTransport(input) {
|
|
|
424
445
|
};
|
|
425
446
|
}
|
|
426
447
|
export function createGameServicesOrpcClient(input) {
|
|
448
|
+
const fetcher = input.fetch;
|
|
427
449
|
const link = new RPCLink({
|
|
428
450
|
origin: input.url,
|
|
429
|
-
|
|
451
|
+
// Every response passes the oRPC error-body compatibility shim so errors from a Worker on
|
|
452
|
+
// an older oRPC beta keep their codes; the default fetch is resolved per call, as oRPC does.
|
|
453
|
+
fetch: withLegacyOrpcErrorCompat((url, init, ...rest) => {
|
|
454
|
+
return fetcher === undefined
|
|
455
|
+
? globalThis.fetch(url, init)
|
|
456
|
+
: fetcher(url, init, ...rest);
|
|
457
|
+
}),
|
|
430
458
|
...(input.headers === undefined && input.getHeaders === undefined
|
|
431
459
|
? {}
|
|
432
460
|
: {
|
|
@@ -490,7 +518,7 @@ async function sendGameServicesBackendRequest(transport, endpoint, body) {
|
|
|
490
518
|
});
|
|
491
519
|
}
|
|
492
520
|
catch (error) {
|
|
493
|
-
if (error instanceof GameServicesHeaderResolutionError) {
|
|
521
|
+
if (error instanceof GameServicesHeaderResolutionError || error instanceof GameServicesRuntimeClosedError) {
|
|
494
522
|
throw error;
|
|
495
523
|
}
|
|
496
524
|
// Do not leak SDK or native transport exceptions (which can contain
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { LogicalProductId } from '@mpgd/platform';
|
|
2
|
-
import { type CommerceCapabilities, type CommerceErrorInfo, type CommerceProvider, type CommercePurchaseState, type CommercePurchaseSummaryStatus, type FetchProductsInput, type PurchaseKind, type RequestPurchaseInput, type RequestPurchaseResult, type StoreId, type StoreProduct, type StorePurchase } from '@mpgd/platform/commerce';
|
|
2
|
+
import { CommerceError, type CommerceCapabilities, type CommerceErrorInfo, type CommerceProvider, type CommercePurchaseState, type CommercePurchaseSummaryStatus, type FetchProductsInput, type PurchaseKind, type RequestPurchaseInput, type RequestPurchaseResult, type StoreId, type StoreProduct, type StorePurchase, type StorePurchaseAccountBinding } from '@mpgd/platform/commerce';
|
|
3
3
|
import type { PurchaseVerificationApi } from './client.js';
|
|
4
4
|
import { type CommerceServiceAlignment } from './commerce-evidence-registry.js';
|
|
5
5
|
import type { MonetizationOperationStore } from './monetization-recovery.js';
|
|
@@ -55,6 +55,23 @@ export interface CommercePurchaseOperationRecord {
|
|
|
55
55
|
/** A retryable error on `requested` means the provider proved checkout never started. */
|
|
56
56
|
readonly error?: CommerceErrorInfo;
|
|
57
57
|
readonly revokedBy?: string;
|
|
58
|
+
/**
|
|
59
|
+
* The backend answered that the store binds this purchase to another
|
|
60
|
+
* account than this record's player (RFC "Another account's purchase"):
|
|
61
|
+
* `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT`, or a `rejected` receipt with a
|
|
62
|
+
* store binding mismatch reason from a backend before that answer. The
|
|
63
|
+
* record is parked in `verifying`: never sent again automatically, not
|
|
64
|
+
* listed among the player's recoverable records, summarized as `rejected`
|
|
65
|
+
* for this player, and its shared key no longer refuses another player
|
|
66
|
+
* of the install, who journals the purchase under its own player-scoped
|
|
67
|
+
* key. Only this player's Restore Purchases, when the store reports the
|
|
68
|
+
* purchase again, sends it once more.
|
|
69
|
+
*/
|
|
70
|
+
readonly anotherAccount?: {
|
|
71
|
+
/** The store's binding mismatch, for example `GOOGLE_PLAY_ACCOUNT_MISMATCH`. */
|
|
72
|
+
readonly reason: string;
|
|
73
|
+
readonly reportedAt: string;
|
|
74
|
+
};
|
|
58
75
|
}
|
|
59
76
|
/**
|
|
60
77
|
* The store key of a commerce v2 purchase record: one namespace per ledger
|
|
@@ -65,15 +82,23 @@ export interface CommercePurchaseOperationRecord {
|
|
|
65
82
|
*/
|
|
66
83
|
export declare function createCommercePurchaseOperationKey(target: string, idempotencyKey: string, playerId?: string): string;
|
|
67
84
|
export declare function isCommercePurchaseOperationRecord(record: unknown): record is CommercePurchaseOperationRecord;
|
|
68
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* Summary status of a record: the RFC mapping, with `revoked` kept distinct.
|
|
87
|
+
* A record parked as another account's purchase is `rejected` for its
|
|
88
|
+
* player: nothing was granted to it, and nothing will be.
|
|
89
|
+
*/
|
|
69
90
|
export declare function summarizeCommercePurchaseOperation(record: CommercePurchaseOperationRecord): CommercePurchaseSummaryStatus;
|
|
91
|
+
/** A `verifying` record the backend answered as another account's purchase. */
|
|
92
|
+
export declare function isParkedForAnotherAccount(record: CommercePurchaseOperationRecord): boolean;
|
|
70
93
|
/**
|
|
71
94
|
* Only `granted` and `finishing` records that hold the backend's ledger entry
|
|
72
95
|
* id and a store transaction may be finished. The StoreKit SDK keeps no
|
|
73
96
|
* finish allow-list of its own (the removed v1 driver paired transactions in
|
|
74
97
|
* `finishGrantedTransaction`), so this is the finish gate for every `client`
|
|
75
98
|
* and `host` owner: the ledger entry id comes from the journal revision the
|
|
76
|
-
* backend grant wrote.
|
|
99
|
+
* backend grant wrote. The one other finish call is a `finished` consumable a
|
|
100
|
+
* `client` store reports again, finished with the same journaled ledger entry
|
|
101
|
+
* (see `finishReportedAgain` in `createCommerceReconciler`).
|
|
77
102
|
*/
|
|
78
103
|
export declare function assertCommerceFinishAllowed(record: CommercePurchaseOperationRecord): {
|
|
79
104
|
readonly transactionId: string;
|
|
@@ -87,7 +112,9 @@ export interface CommerceReconcilerProduct {
|
|
|
87
112
|
readonly kind: PurchaseKind;
|
|
88
113
|
readonly storeSku?: string;
|
|
89
114
|
}
|
|
90
|
-
export type CommerceReconcilerFailureStage = 'capabilities' | 'available' | 'catalog' | 'match' | 'journal' | 'verify' | 'finish' | 'ledger-status' | 'restore'
|
|
115
|
+
export type CommerceReconcilerFailureStage = 'capabilities' | 'available' | 'catalog' | 'match' | 'journal' | 'verify' | 'finish' | 'ledger-status' | 'restore'
|
|
116
|
+
/** `sessionCheck` refused: the reconciler's player is not the session's now. */
|
|
117
|
+
| 'session';
|
|
91
118
|
export type CommerceReconcilerEvent = {
|
|
92
119
|
readonly type: 'transition';
|
|
93
120
|
readonly record: CommercePurchaseOperationRecord;
|
|
@@ -100,6 +127,18 @@ export type CommerceReconcilerEvent = {
|
|
|
100
127
|
readonly type: 'verify-result';
|
|
101
128
|
readonly record: CommercePurchaseOperationRecord;
|
|
102
129
|
readonly response: VerifyPurchaseResponse;
|
|
130
|
+
} | {
|
|
131
|
+
/**
|
|
132
|
+
* The backend answered the record's verify as another account's
|
|
133
|
+
* purchase (RFC "Another account's purchase"): the record is parked,
|
|
134
|
+
* nothing was granted, and it is not a failure. `response` is the
|
|
135
|
+
* receipt of a backend before that answer (`rejected` with a store
|
|
136
|
+
* binding mismatch reason); absent for the 409 answer.
|
|
137
|
+
*/
|
|
138
|
+
readonly type: 'another-account';
|
|
139
|
+
readonly record: CommercePurchaseOperationRecord;
|
|
140
|
+
readonly reason: string;
|
|
141
|
+
readonly response?: VerifyPurchaseResponse;
|
|
103
142
|
} | {
|
|
104
143
|
readonly type: 'failure';
|
|
105
144
|
readonly stage: CommerceReconcilerFailureStage;
|
|
@@ -153,6 +192,52 @@ export interface CreateCommerceReconcilerInput {
|
|
|
153
192
|
* ten seconds.
|
|
154
193
|
*/
|
|
155
194
|
readonly serviceCapabilitiesTimeoutMs?: number;
|
|
195
|
+
/**
|
|
196
|
+
* Whether the reconciler may act for `playerId` right now; an error says
|
|
197
|
+
* why not (`createGameServicesRuntime` passes its session player check).
|
|
198
|
+
* It is read synchronously before every pass, before each queued event and
|
|
199
|
+
* each record a pass handles, before a store read, restore, or finish, and
|
|
200
|
+
* for a purchase at its start, right before its record is reserved, and
|
|
201
|
+
* right before the store UI opens. A refusal never ends a record: a pass stops with the
|
|
202
|
+
* journal as it is and the events still queued (a `failure` event with
|
|
203
|
+
* stage `session` reports it), a purchase answers the error with no store
|
|
204
|
+
* UI (a reserved attempt stays retryable), and `restorePurchases` rejects
|
|
205
|
+
* with it. So one player's journaled purchases are never verified,
|
|
206
|
+
* restored, or finished while another player's session headers or account
|
|
207
|
+
* binding are current. Return a retryable `CommerceError`.
|
|
208
|
+
*/
|
|
209
|
+
readonly sessionCheck?: () => CommerceError | undefined;
|
|
210
|
+
/**
|
|
211
|
+
* Whether the reconciler's owner closed for good (`createGameServicesRuntime`
|
|
212
|
+
* passes its disposed or player-changed state). Read synchronously when
|
|
213
|
+
* `start()` is called and again right before it subscribes (after the
|
|
214
|
+
* journal read): while it answers true, `start()` subscribes nothing and
|
|
215
|
+
* starts nothing, and resolves without an error. So a caller that reaches
|
|
216
|
+
* `start()` after `stop()` (a recovery pass that outlived its owner's
|
|
217
|
+
* dispose), or a start that was still reading the journal when its owner
|
|
218
|
+
* stopped waiting for it, leaves no store subscription.
|
|
219
|
+
* Unlike `sessionCheck`, a signed-out session is not closed: the reconciler
|
|
220
|
+
* still subscribes then and its work waits for the session. Omitted, a
|
|
221
|
+
* stopped reconciler can be started again.
|
|
222
|
+
*/
|
|
223
|
+
readonly closed?: () => boolean;
|
|
224
|
+
/**
|
|
225
|
+
* This player's store account binding: the value the store provider binds
|
|
226
|
+
* the player's new purchases to (Google Play's obfuscated account id,
|
|
227
|
+
* StoreKit's app account token). A store purchase the journal does not
|
|
228
|
+
* hold yet whose binding names another account is another player's: it is
|
|
229
|
+
* reported (stage `match`) and left alone, never journaled or verified for
|
|
230
|
+
* this player (a backend would reject it for good as an account mismatch,
|
|
231
|
+
* and its derived key would then block that player's own recovery). The
|
|
232
|
+
* player's explicit Restore Purchases pass still takes a durable bound to
|
|
233
|
+
* another account (restore sharing). A purchase without a binding, or with
|
|
234
|
+
* a binding of another kind, is not compared. Answer `undefined` (or
|
|
235
|
+
* throw) only transiently, while this player's binding cannot be read
|
|
236
|
+
* yet: a bound purchase reported then is kept and asked about again on
|
|
237
|
+
* the next pass (a resume, an event, an explicit trigger). A runtime for a store or target whose purchases this player's
|
|
238
|
+
* binding never covers must omit the input; omitted, nothing is compared.
|
|
239
|
+
*/
|
|
240
|
+
readonly accountBinding?: () => StorePurchaseAccountBinding | undefined | Promise<StorePurchaseAccountBinding | undefined>;
|
|
156
241
|
}
|
|
157
242
|
export declare const defaultStaleLaunchedAfterMs: number;
|
|
158
243
|
/** How long one backend capabilities read may take before the last comparison is kept. */
|