@mpgd/game-services 0.31.0 → 0.33.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 +8 -3
- package/dist/another-account.d.ts +65 -0
- package/dist/another-account.js +107 -0
- package/dist/app-store-server-notifications.d.ts +15 -2
- package/dist/app-store-server-notifications.js +43 -9
- package/dist/app-store-verification-id.d.ts +25 -0
- package/dist/app-store-verification-id.js +32 -0
- package/dist/app-store-verifier.d.ts +99 -9
- package/dist/app-store-verifier.js +127 -32
- package/dist/client.js +8 -3
- package/dist/commerce-reconciler.d.ts +55 -1
- package/dist/commerce-reconciler.js +456 -25
- 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/runtime.d.ts +12 -0
- package/dist/runtime.js +23 -0
- package/dist/server.d.ts +16 -1
- package/dist/server.js +99 -12
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -314,9 +314,14 @@ purchases (Google Play's obfuscated account id, StoreKit's app account
|
|
|
314
314
|
token). The reconciler then leaves a store purchase bound to another account
|
|
315
315
|
alone (reported, never journaled or verified for this player). After a
|
|
316
316
|
restart as another player, Google Play still lists the previous player's
|
|
317
|
-
unacknowledged purchases; sending one with this player's session
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
320
325
|
bound to another account (restore sharing). StoreKit's provider already
|
|
321
326
|
filters transactions by app account token the same way.
|
|
322
327
|
|
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AppStoreEnvironment, type AppStoreTransactionPayload } from './app-store-verifier.js';
|
|
1
|
+
import { type AppStoreEnvironment, type AppStoreRevocationRecorder, type AppStoreTransactionPayload } from './app-store-verifier.js';
|
|
2
2
|
import type { GameServicesStore } from './server.js';
|
|
3
3
|
/**
|
|
4
4
|
* App Store Server Notifications V2 for one-time purchases: the store-side
|
|
@@ -70,6 +70,15 @@ export interface HandleAppStoreServerNotificationInput {
|
|
|
70
70
|
* most `maxUnmatchedRefundRetryMs` (6 hours).
|
|
71
71
|
*/
|
|
72
72
|
readonly unmatchedRefundRetryMs?: number;
|
|
73
|
+
/**
|
|
74
|
+
* Records every `REFUND` and `REVOKE` of the customer's own one-time
|
|
75
|
+
* purchase, before the ledger is read, whether or not a grant exists yet.
|
|
76
|
+
* Required in practice for a verifier in `offline-only` mode, which reads
|
|
77
|
+
* the same registry: a refund that arrives before the first grant leaves
|
|
78
|
+
* no grant to reverse, and the client may still hold a JWS signed before
|
|
79
|
+
* it. A recorder outage (`AppStoreDependencyUnavailableError`) is `retry`.
|
|
80
|
+
*/
|
|
81
|
+
readonly revocations?: AppStoreRevocationRecorder;
|
|
73
82
|
}
|
|
74
83
|
/** Default of `unmatchedRefundRetryMs`. */
|
|
75
84
|
export declare const defaultUnmatchedRefundRetryMs: number;
|
|
@@ -135,6 +144,9 @@ export type AppStoreServerNotificationOutcome =
|
|
|
135
144
|
export declare const maxAppStoreServerNotificationBytes: number;
|
|
136
145
|
/**
|
|
137
146
|
* Verifies one App Store Server Notification and applies it to the ledger.
|
|
147
|
+
* With `revocations`, every `REFUND` and `REVOKE` of the customer's own
|
|
148
|
+
* one-time purchase is recorded there first, so an offline-only purchase
|
|
149
|
+
* verifier refuses a JWS signed before it even when no grant existed yet.
|
|
138
150
|
* `REFUND` and `REVOKE` for a granted one-time purchase append one `revoke`
|
|
139
151
|
* entry, keyed by the notification's `notificationUUID` so a redelivery
|
|
140
152
|
* answers the first entry. Every other type, a transaction this ledger never
|
|
@@ -149,7 +161,8 @@ export declare const maxAppStoreServerNotificationBytes: number;
|
|
|
149
161
|
* A refund for a grant this ledger does not hold is `retry` while it is
|
|
150
162
|
* recent (`unmatchedRefundRetryMs`, a purchase may be committing a grant it
|
|
151
163
|
* verified before the refund) and `ignored` after; a purchase request that
|
|
152
|
-
* starts after the refund is refused by the verifier's Server API mode
|
|
164
|
+
* starts after the refund is refused by the verifier's Server API mode, or,
|
|
165
|
+
* in offline-only mode, by the `revocations` record.
|
|
153
166
|
* A thrown error (a store outage, an abort) is not an outcome: the host must
|
|
154
167
|
* answer it with a 5xx so Apple redelivers.
|
|
155
168
|
*/
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { findEntitlementRevocationsChunked, findPurchaseGrantByEvidenceVerificationId, revokeEntitlementGrant, } from './commerce-ledger.js';
|
|
2
|
-
import {
|
|
2
|
+
import { createAppStoreGrantVerificationId } from './app-store-verification-id.js';
|
|
3
|
+
import { AppStoreDependencyUnavailableError, } from './app-store-verifier.js';
|
|
3
4
|
/** Default of `unmatchedRefundRetryMs`. */
|
|
4
5
|
export const defaultUnmatchedRefundRetryMs = 60 * 60 * 1000;
|
|
5
6
|
/** Upper bound of `unmatchedRefundRetryMs`. */
|
|
@@ -19,6 +20,9 @@ const revocationReasons = {
|
|
|
19
20
|
};
|
|
20
21
|
/**
|
|
21
22
|
* Verifies one App Store Server Notification and applies it to the ledger.
|
|
23
|
+
* With `revocations`, every `REFUND` and `REVOKE` of the customer's own
|
|
24
|
+
* one-time purchase is recorded there first, so an offline-only purchase
|
|
25
|
+
* verifier refuses a JWS signed before it even when no grant existed yet.
|
|
22
26
|
* `REFUND` and `REVOKE` for a granted one-time purchase append one `revoke`
|
|
23
27
|
* entry, keyed by the notification's `notificationUUID` so a redelivery
|
|
24
28
|
* answers the first entry. Every other type, a transaction this ledger never
|
|
@@ -33,7 +37,8 @@ const revocationReasons = {
|
|
|
33
37
|
* A refund for a grant this ledger does not hold is `retry` while it is
|
|
34
38
|
* recent (`unmatchedRefundRetryMs`, a purchase may be committing a grant it
|
|
35
39
|
* verified before the refund) and `ignored` after; a purchase request that
|
|
36
|
-
* starts after the refund is refused by the verifier's Server API mode
|
|
40
|
+
* starts after the refund is refused by the verifier's Server API mode, or,
|
|
41
|
+
* in offline-only mode, by the `revocations` record.
|
|
37
42
|
* A thrown error (a store outage, an abort) is not an outcome: the host must
|
|
38
43
|
* answer it with a 5xx so Apple redelivers.
|
|
39
44
|
*/
|
|
@@ -102,14 +107,43 @@ export async function handleAppStoreServerNotification(input) {
|
|
|
102
107
|
}
|
|
103
108
|
// The grant's identity is the one the purchase verifier recorded:
|
|
104
109
|
// non-consumables by their original transaction, consumables by their own.
|
|
105
|
-
const grantVerificationId =
|
|
106
|
-
environment: transaction.environment,
|
|
107
|
-
bundleId: transaction.bundleId,
|
|
108
|
-
transactionId: transaction.type === 'Non-Consumable'
|
|
109
|
-
? transaction.originalTransactionId
|
|
110
|
-
: transaction.transactionId,
|
|
111
|
-
});
|
|
110
|
+
const grantVerificationId = createAppStoreGrantVerificationId(transaction);
|
|
112
111
|
const now = input.now ?? (() => new Date().toISOString());
|
|
112
|
+
if (reason !== undefined && input.revocations !== undefined) {
|
|
113
|
+
// Recorded before the ledger is read, so a grant that does not exist
|
|
114
|
+
// yet is refused by an offline-only verifier instead of only retried.
|
|
115
|
+
try {
|
|
116
|
+
await input.revocations.recordRevocation({
|
|
117
|
+
record: {
|
|
118
|
+
verificationId: grantVerificationId,
|
|
119
|
+
environment: transaction.environment,
|
|
120
|
+
bundleId: transaction.bundleId,
|
|
121
|
+
transactionId: transaction.transactionId,
|
|
122
|
+
originalTransactionId: transaction.originalTransactionId,
|
|
123
|
+
productId: transaction.productId,
|
|
124
|
+
reason,
|
|
125
|
+
// Apple's signed dates, never the handling time: a redelivery of
|
|
126
|
+
// an undated refund must not move the cutoff past a reversal. An
|
|
127
|
+
// unrepresentable date is kept unparseable, which refuses every JWS.
|
|
128
|
+
revokedAt: readTimestamp(transaction.revocationDate ?? notification.signedDate)
|
|
129
|
+
?? 'unparseable',
|
|
130
|
+
notificationType: notification.notificationType,
|
|
131
|
+
notificationUUID,
|
|
132
|
+
},
|
|
133
|
+
signal: input.signal,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (error instanceof AppStoreDependencyUnavailableError) {
|
|
138
|
+
return {
|
|
139
|
+
status: 'retry',
|
|
140
|
+
reason: 'APP_STORE_REVOCATION_REGISTRY_UNAVAILABLE',
|
|
141
|
+
notificationUUID,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
113
147
|
const grant = await findPurchaseGrantByEvidenceVerificationId(input.store, grantVerificationId);
|
|
114
148
|
if (grant === undefined || grant.payload.target !== 'ios') {
|
|
115
149
|
if (grant === undefined
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The App Store grant identity, shared by the purchase verifier (the
|
|
3
|
+
* verification id its grants record) and the server notification handler
|
|
4
|
+
* (the grant it reverses and the revocation it records): both must derive
|
|
5
|
+
* the same key from a transaction. Internal: the public names are
|
|
6
|
+
* re-exported from `app-store-verifier`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function createAppStoreVerificationId(input: {
|
|
9
|
+
readonly environment: 'Production' | 'Sandbox';
|
|
10
|
+
readonly bundleId: string;
|
|
11
|
+
readonly transactionId: string;
|
|
12
|
+
}): string;
|
|
13
|
+
/**
|
|
14
|
+
* The verification id of the grant a transaction makes: a non-consumable by
|
|
15
|
+
* its original transaction (restores and re-downloads share one grant), any
|
|
16
|
+
* other type by its own transaction.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createAppStoreGrantVerificationId(transaction: {
|
|
19
|
+
readonly environment: 'Production' | 'Sandbox';
|
|
20
|
+
readonly bundleId: string;
|
|
21
|
+
readonly type: string;
|
|
22
|
+
readonly transactionId: string;
|
|
23
|
+
readonly originalTransactionId: string;
|
|
24
|
+
}): string;
|
|
25
|
+
export declare function encodeVerificationIdSegment(value: string): string;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The App Store grant identity, shared by the purchase verifier (the
|
|
3
|
+
* verification id its grants record) and the server notification handler
|
|
4
|
+
* (the grant it reverses and the revocation it records): both must derive
|
|
5
|
+
* the same key from a transaction. Internal: the public names are
|
|
6
|
+
* re-exported from `app-store-verifier`.
|
|
7
|
+
*/
|
|
8
|
+
export function createAppStoreVerificationId(input) {
|
|
9
|
+
return [
|
|
10
|
+
'app-store',
|
|
11
|
+
input.environment,
|
|
12
|
+
input.bundleId,
|
|
13
|
+
input.transactionId,
|
|
14
|
+
].map(encodeVerificationIdSegment).join(':');
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The verification id of the grant a transaction makes: a non-consumable by
|
|
18
|
+
* its original transaction (restores and re-downloads share one grant), any
|
|
19
|
+
* other type by its own transaction.
|
|
20
|
+
*/
|
|
21
|
+
export function createAppStoreGrantVerificationId(transaction) {
|
|
22
|
+
return createAppStoreVerificationId({
|
|
23
|
+
environment: transaction.environment,
|
|
24
|
+
bundleId: transaction.bundleId,
|
|
25
|
+
transactionId: transaction.type === 'Non-Consumable'
|
|
26
|
+
? transaction.originalTransactionId
|
|
27
|
+
: transaction.transactionId,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export function encodeVerificationIdSegment(value) {
|
|
31
|
+
return `${value.length}:${encodeURIComponent(value)}`;
|
|
32
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PlatformEvidenceEnvelope } from '@mpgd/platform';
|
|
2
2
|
import type { GameServicesEvidenceVerifier } from './evidence-verification.js';
|
|
3
|
+
import { createAppStoreVerificationId } from './app-store-verification-id.js';
|
|
3
4
|
export type AppStoreEnvironment = 'Production' | 'Sandbox';
|
|
4
5
|
export declare const appStoreServerApiBaseUrls: Readonly<{
|
|
5
6
|
readonly Production: 'https://api.storekit.apple.com';
|
|
@@ -85,10 +86,17 @@ export interface CreateAppStoreEvidenceVerifierOptions {
|
|
|
85
86
|
* establish authenticity and bind the transaction id, and the grant decision
|
|
86
87
|
* is then taken on the transaction's current status fetched from the App
|
|
87
88
|
* Store Server API (`Get Transaction Info`), so a refund that happened after
|
|
88
|
-
* the client cached its JWS is seen. `{ mode: 'offline-only', maxSignedAgeMs
|
|
89
|
-
* decides on the client JWS
|
|
90
|
-
* API access: a JWS is a snapshot, so a transaction refunded
|
|
91
|
-
*
|
|
89
|
+
* the client cached its JWS is seen. `{ mode: 'offline-only', maxSignedAgeMs,
|
|
90
|
+
* revocations }` decides on the client JWS and is only for deployments
|
|
91
|
+
* without Server API access: a JWS is a snapshot, so a transaction refunded
|
|
92
|
+
* after it was signed still reads as purchased. Before a grant it therefore
|
|
93
|
+
* asks `revocations`, the store of refunds and revocations App Store Server
|
|
94
|
+
* Notifications recorded (`handleAppStoreServerNotification({ revocations })`),
|
|
95
|
+
* and refuses a JWS signed at or before a revocation found there (plus
|
|
96
|
+
* `clockSkewMs`) with `APP_STORE_TRANSACTION_REVOKED`; a JWS signed later
|
|
97
|
+
* without a `revocationDate` shows a refund Apple reversed and passes. A
|
|
98
|
+
* refund Apple has not notified yet is still accepted until `signedDate`
|
|
99
|
+
* falls out of the window; the notification then reverses the grant. `{ mode:
|
|
92
100
|
* 'ignore' }` fetches by id for every request.
|
|
93
101
|
*/
|
|
94
102
|
readonly signedTransactionEvidence?: AppStoreSignedTransactionEvidencePolicy;
|
|
@@ -129,7 +137,93 @@ export type AppStoreSignedTransactionEvidencePolicy = {
|
|
|
129
137
|
readonly mode: 'offline-only';
|
|
130
138
|
/** Maximum age of the JWS `signedDate` at verification time; older evidence is rejected. */
|
|
131
139
|
readonly maxSignedAgeMs: number;
|
|
140
|
+
/**
|
|
141
|
+
* Required. The revocations this backend learned from App Store Server
|
|
142
|
+
* Notifications, read before every offline-only grant: a client can keep
|
|
143
|
+
* a JWS signed before a refund, and a refund that reached the backend
|
|
144
|
+
* before the first grant has no ledger grant to reverse. A record
|
|
145
|
+
* refuses only a JWS signed at or before its `revokedAt` plus
|
|
146
|
+
* `clockSkewMs`, so a refund Apple later reversed grants again from a
|
|
147
|
+
* fresh JWS. It must read
|
|
148
|
+
* the durable store the notification handler's `revocations` recorder
|
|
149
|
+
* writes, across every instance that verifies purchases.
|
|
150
|
+
*/
|
|
151
|
+
readonly revocations: AppStoreRevocationLookup;
|
|
132
152
|
};
|
|
153
|
+
/** Why Apple ended a one-time purchase: `REFUND` is `refund`, `REVOKE` is `voided`. */
|
|
154
|
+
export type AppStoreRevocationReason = 'refund' | 'voided';
|
|
155
|
+
/**
|
|
156
|
+
* A refund or revocation of a one-time purchase, recorded from a verified App
|
|
157
|
+
* Store Server Notification whether or not the ledger holds a grant for it.
|
|
158
|
+
*/
|
|
159
|
+
export interface AppStoreRevocationRecord {
|
|
160
|
+
/**
|
|
161
|
+
* The grant identity the purchase verifier records
|
|
162
|
+
* (`createAppStoreVerificationId`): the original transaction id of a
|
|
163
|
+
* non-consumable, the transaction id of a consumable. The lookup key.
|
|
164
|
+
*/
|
|
165
|
+
readonly verificationId: string;
|
|
166
|
+
readonly environment: AppStoreEnvironment;
|
|
167
|
+
readonly bundleId: string;
|
|
168
|
+
readonly transactionId: string;
|
|
169
|
+
readonly originalTransactionId: string;
|
|
170
|
+
readonly productId: string;
|
|
171
|
+
readonly reason: AppStoreRevocationReason;
|
|
172
|
+
/**
|
|
173
|
+
* When Apple revoked it: the transaction's `revocationDate`, else the
|
|
174
|
+
* notification's `signedDate`. Both are Apple's signed values, so a
|
|
175
|
+
* redelivery of the same notification records the same time. A
|
|
176
|
+
* record may be dropped once `revokedAt` is older than the verifier's
|
|
177
|
+
* `maxSignedAgeMs` plus `clockSkewMs`: every JWS the record refuses
|
|
178
|
+
* (signed at or before `revokedAt` plus `clockSkewMs`) is then stale.
|
|
179
|
+
*/
|
|
180
|
+
readonly revokedAt: string;
|
|
181
|
+
readonly notificationType: string;
|
|
182
|
+
readonly notificationUUID: string;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Reads recorded revocations for the offline-only verifier. Answer the record
|
|
186
|
+
* for `verificationId`, or `undefined` when none is recorded. Throw
|
|
187
|
+
* `AppStoreDependencyUnavailableError` only for a transient storage outage
|
|
188
|
+
* (the purchase stays `pending`); any other error propagates.
|
|
189
|
+
*/
|
|
190
|
+
export interface AppStoreRevocationLookup {
|
|
191
|
+
findRevocation(input: {
|
|
192
|
+
readonly verificationId: string;
|
|
193
|
+
readonly environment: AppStoreEnvironment;
|
|
194
|
+
readonly bundleId: string;
|
|
195
|
+
readonly transactionId: string;
|
|
196
|
+
readonly originalTransactionId: string;
|
|
197
|
+
readonly signal: AbortSignal;
|
|
198
|
+
}): Promise<AppStoreRevocationRecord | undefined>;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Durably records a revocation, keeping per `verificationId` the record with
|
|
202
|
+
* the latest `revokedAt`: a purchase can be refunded, reversed, and revoked
|
|
203
|
+
* again, and the lookup must refuse every JWS signed before the latest
|
|
204
|
+
* revocation. A record whose `revokedAt` does not parse counts as the
|
|
205
|
+
* latest. A record no later than the kept one (a redelivery, or an older
|
|
206
|
+
* notification arriving late) keeps it and answers `alreadyRecorded: true`.
|
|
207
|
+
* Throw `AppStoreDependencyUnavailableError` only for a transient storage
|
|
208
|
+
* outage (the notification is answered `retry`, so Apple redelivers).
|
|
209
|
+
*/
|
|
210
|
+
export interface AppStoreRevocationRecorder {
|
|
211
|
+
recordRevocation(input: {
|
|
212
|
+
readonly record: AppStoreRevocationRecord;
|
|
213
|
+
readonly signal: AbortSignal;
|
|
214
|
+
}): Promise<{
|
|
215
|
+
readonly alreadyRecorded: boolean;
|
|
216
|
+
}>;
|
|
217
|
+
}
|
|
218
|
+
export interface AppStoreRevocationRegistry extends AppStoreRevocationLookup, AppStoreRevocationRecorder {
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* A process-local `AppStoreRevocationRegistry` for tests and single-process
|
|
222
|
+
* hosts. It forgets everything on restart and is not shared between
|
|
223
|
+
* instances (a Worker isolate is one), so a deployment that verifies
|
|
224
|
+
* purchases offline needs a durable registry of its own.
|
|
225
|
+
*/
|
|
226
|
+
export declare function createInMemoryAppStoreRevocationRegistry(): AppStoreRevocationRegistry;
|
|
133
227
|
/**
|
|
134
228
|
* Evidence schema of a commerce v2 App Store purchase (RFC B3): the payload's
|
|
135
229
|
* `signedTransaction` is the StoreKit-verified JWS the client received. The
|
|
@@ -156,11 +250,7 @@ export type AppStoreFetch = (url: string, init: {
|
|
|
156
250
|
}) => Promise<AppStoreFetchResponse>;
|
|
157
251
|
export declare function createAppStoreServerApiClient(options: CreateAppStoreServerApiClientOptions): AppStoreServerApiClient;
|
|
158
252
|
export declare function createAppStoreGameServicesEvidenceVerifier(options: CreateAppStoreEvidenceVerifierOptions): GameServicesEvidenceVerifier;
|
|
159
|
-
export
|
|
160
|
-
readonly environment: AppStoreEnvironment;
|
|
161
|
-
readonly bundleId: string;
|
|
162
|
-
readonly transactionId: string;
|
|
163
|
-
}): string;
|
|
253
|
+
export { createAppStoreVerificationId };
|
|
164
254
|
/** Identify IDs emitted by this verifier without duplicating its encoding format. */
|
|
165
255
|
export declare function isAppStoreVerificationId(verificationId: string): boolean;
|
|
166
256
|
export declare function assertAppStoreTransactionPayload(input: unknown): AppStoreTransactionPayload;
|