@mpgd/game-services 0.28.1 → 0.30.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 +18 -3
- package/dist/account-deletion.d.ts +306 -0
- package/dist/account-deletion.js +491 -0
- package/dist/app-store-recovery.d.ts +6 -0
- package/dist/app-store-recovery.js +26 -16
- package/dist/app-store-server-notifications.d.ts +158 -0
- package/dist/app-store-server-notifications.js +270 -0
- package/dist/app-store-server.d.ts +8 -0
- package/dist/app-store-server.js +135 -37
- package/dist/app-store-verifier.d.ts +34 -0
- package/dist/app-store-verifier.js +161 -31
- package/dist/commerce-ledger.d.ts +55 -0
- package/dist/commerce-ledger.js +198 -0
- package/dist/commerce-reconciler.js +47 -7
- package/dist/evidence-verification.d.ts +19 -0
- package/dist/google-play-publisher-client.d.ts +2 -1
- package/dist/google-play-publisher-client.js +80 -0
- package/dist/google-play-purchase.d.ts +7 -0
- package/dist/google-play-purchase.js +32 -7
- package/dist/google-play-store-server.d.ts +160 -0
- package/dist/google-play-store-server.js +297 -0
- package/dist/guest-session.d.ts +112 -2
- package/dist/guest-session.js +196 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/notifications/delivery-provider.d.ts +7 -0
- package/dist/notifications/fcm.d.ts +30 -3
- package/dist/notifications/fcm.js +72 -13
- package/dist/notifications/service.js +33 -5
- package/dist/notifications/store-conformance.js +7 -1
- package/dist/notifications/store.d.ts +9 -0
- package/dist/notifications/store.js +41 -0
- package/dist/operation-results.d.ts +7 -0
- package/dist/purchase-sharing.d.ts +41 -0
- package/dist/purchase-sharing.js +37 -0
- package/dist/runtime.d.ts +6 -0
- package/dist/runtime.js +13 -0
- package/dist/server.d.ts +60 -1
- package/dist/server.js +536 -42
- package/dist/types.d.ts +30 -0
- package/dist/types.js +22 -0
- package/dist/verified-leaderboard.d.ts +13 -0
- package/dist/verified-leaderboard.js +31 -0
- package/package.json +17 -5
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { type AppStoreEnvironment, type AppStoreTransactionPayload } from './app-store-verifier.js';
|
|
2
|
+
import type { GameServicesStore } from './server.js';
|
|
3
|
+
/**
|
|
4
|
+
* App Store Server Notifications V2 for one-time purchases: the store-side
|
|
5
|
+
* half of a refund. Apple posts `{ "signedPayload": "<JWS>" }` to the URL set
|
|
6
|
+
* in App Store Connect; the payload is verified with the same library and
|
|
7
|
+
* root certificates as purchases (`createAppleServerNotificationVerifier` in
|
|
8
|
+
* `@mpgd/game-services/app-store-server`), and a `REFUND` or `REVOKE` for a
|
|
9
|
+
* transaction this ledger granted appends a `revoke` entry through
|
|
10
|
+
* `revokeEntitlementGrant`. This module has no Apple library dependency, so a
|
|
11
|
+
* Worker can run it behind a verifier service binding.
|
|
12
|
+
*
|
|
13
|
+
* https://developer.apple.com/documentation/appstoreservernotifications/responding-to-app-store-server-notifications
|
|
14
|
+
*/
|
|
15
|
+
/** The decoded fields of a verified `responseBodyV2DecodedPayload` this handler reads. */
|
|
16
|
+
export interface AppStoreServerNotification {
|
|
17
|
+
readonly notificationType: string;
|
|
18
|
+
readonly subtype?: string;
|
|
19
|
+
/** Apple's unique id of the notification; a retried delivery repeats it. */
|
|
20
|
+
readonly notificationUUID: string;
|
|
21
|
+
readonly signedDate: number;
|
|
22
|
+
readonly environment: AppStoreEnvironment;
|
|
23
|
+
readonly bundleId: string;
|
|
24
|
+
/**
|
|
25
|
+
* The verified `data.signedTransactionInfo` of a one-time purchase. Absent
|
|
26
|
+
* when the notification carries no transaction (`TEST`, summaries) or one
|
|
27
|
+
* of another kind (subscriptions), which the handler ignores.
|
|
28
|
+
*/
|
|
29
|
+
readonly transaction?: AppStoreTransactionPayload;
|
|
30
|
+
}
|
|
31
|
+
export type AppStoreServerNotificationVerificationResult = {
|
|
32
|
+
readonly status: 'verified';
|
|
33
|
+
readonly notification: AppStoreServerNotification;
|
|
34
|
+
} | {
|
|
35
|
+
readonly status: 'rejected';
|
|
36
|
+
readonly reason: string;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Verifies a `signedPayload` and its signed transaction. Throw
|
|
40
|
+
* `AppStoreDependencyUnavailableError` only for a transient failure (Apple's
|
|
41
|
+
* retryable certificate checks); the handler then asks Apple to redeliver.
|
|
42
|
+
*/
|
|
43
|
+
export interface AppStoreServerNotificationVerifier {
|
|
44
|
+
verifyAndDecodeNotification(input: {
|
|
45
|
+
readonly signedPayload: string;
|
|
46
|
+
readonly environment: AppStoreEnvironment;
|
|
47
|
+
readonly bundleId: string;
|
|
48
|
+
readonly signal: AbortSignal;
|
|
49
|
+
}): Promise<AppStoreServerNotificationVerificationResult>;
|
|
50
|
+
}
|
|
51
|
+
export interface HandleAppStoreServerNotificationInput {
|
|
52
|
+
readonly store: GameServicesStore;
|
|
53
|
+
readonly verifier: AppStoreServerNotificationVerifier;
|
|
54
|
+
/** The request body as received: `{ signedPayload }` parsed from JSON. */
|
|
55
|
+
readonly body: unknown;
|
|
56
|
+
/** The environment of the URL the notification arrived on (production or sandbox). */
|
|
57
|
+
readonly environment: AppStoreEnvironment;
|
|
58
|
+
readonly bundleId: string;
|
|
59
|
+
readonly signal: AbortSignal;
|
|
60
|
+
readonly now?: () => string;
|
|
61
|
+
/**
|
|
62
|
+
* How long after Apple revoked the transaction (its `revocationDate`, else
|
|
63
|
+
* the notification's `signedDate`) a refund for a grant this ledger does not
|
|
64
|
+
* hold yet is answered `retry` instead of `ignored`; 1 hour by default. It
|
|
65
|
+
* covers a purchase whose verification read the transaction before the
|
|
66
|
+
* refund and whose grant is still being written: Apple redelivers (the
|
|
67
|
+
* first production retry is an hour later) and the redelivery finds the
|
|
68
|
+
* grant. A refund for a grant that never appears is acknowledged once the
|
|
69
|
+
* window passes, so Apple stops retrying. `0` acknowledges at once; at
|
|
70
|
+
* most `maxUnmatchedRefundRetryMs` (6 hours).
|
|
71
|
+
*/
|
|
72
|
+
readonly unmatchedRefundRetryMs?: number;
|
|
73
|
+
}
|
|
74
|
+
/** Default of `unmatchedRefundRetryMs`. */
|
|
75
|
+
export declare const defaultUnmatchedRefundRetryMs: number;
|
|
76
|
+
/** Upper bound of `unmatchedRefundRetryMs`. */
|
|
77
|
+
export declare const maxUnmatchedRefundRetryMs: number;
|
|
78
|
+
export type AppStoreServerNotificationOutcome =
|
|
79
|
+
/** A grant was reversed (or already had been, for a redelivery). */
|
|
80
|
+
{
|
|
81
|
+
readonly status: 'revoked';
|
|
82
|
+
readonly notificationUUID: string;
|
|
83
|
+
readonly ledgerEntryId: string;
|
|
84
|
+
readonly alreadyProcessed: boolean;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* `REFUND_REVERSED` for a grant this ledger revoked: the player paid again
|
|
88
|
+
* but still has no item. Nothing is re-granted; the reversal is kept as a
|
|
89
|
+
* ledger note (`recorded`, once per `notificationUUID`) when the store
|
|
90
|
+
* supports notes, and the host should warn so support can re-grant.
|
|
91
|
+
*/
|
|
92
|
+
| {
|
|
93
|
+
readonly status: 'refund_reversed';
|
|
94
|
+
readonly notificationUUID: string;
|
|
95
|
+
readonly playerId: string;
|
|
96
|
+
/** The original grant. */
|
|
97
|
+
readonly ledgerEntryId: string;
|
|
98
|
+
/** The `revoke` entry Apple's reversal undid. */
|
|
99
|
+
readonly revocationLedgerEntryId: string;
|
|
100
|
+
/** False on a store without `recordEntitlementNote`: only the host's log keeps it. */
|
|
101
|
+
readonly recorded: boolean;
|
|
102
|
+
readonly alreadyRecorded: boolean;
|
|
103
|
+
}
|
|
104
|
+
/** Verified, but nothing to do: Apple is answered 200 so it stops retrying. */
|
|
105
|
+
| {
|
|
106
|
+
readonly status: 'ignored';
|
|
107
|
+
readonly reason: string;
|
|
108
|
+
readonly notificationUUID: string;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The ledger refused the revocation (its key or authority identity already
|
|
112
|
+
* reversed another grant). A redelivery cannot fix it, so Apple is answered
|
|
113
|
+
* 200; the host should log or alert on it.
|
|
114
|
+
*/
|
|
115
|
+
| {
|
|
116
|
+
readonly status: 'conflict';
|
|
117
|
+
readonly reason: string;
|
|
118
|
+
readonly notificationUUID: string;
|
|
119
|
+
}
|
|
120
|
+
/** Malformed or not signed by Apple for this app and environment. */
|
|
121
|
+
| {
|
|
122
|
+
readonly status: 'rejected';
|
|
123
|
+
readonly reason: string;
|
|
124
|
+
}
|
|
125
|
+
/** A transient failure, or an order to wait for: answer non-2xx so Apple redelivers. */
|
|
126
|
+
| {
|
|
127
|
+
readonly status: 'retry';
|
|
128
|
+
readonly reason: string;
|
|
129
|
+
readonly notificationUUID?: string;
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* The longest `signedPayload` string accepted; Apple's notifications are a
|
|
133
|
+
* few kilobytes. The host bounds the whole request body itself.
|
|
134
|
+
*/
|
|
135
|
+
export declare const maxAppStoreServerNotificationBytes: number;
|
|
136
|
+
/**
|
|
137
|
+
* Verifies one App Store Server Notification and applies it to the ledger.
|
|
138
|
+
* `REFUND` and `REVOKE` for a granted one-time purchase append one `revoke`
|
|
139
|
+
* entry, keyed by the notification's `notificationUUID` so a redelivery
|
|
140
|
+
* answers the first entry. Every other type, a transaction this ledger never
|
|
141
|
+
* granted, and a subscription transaction are `ignored` (answer 200 so Apple
|
|
142
|
+
* stops retrying). `REFUND_REVERSED` for a grant an App Store refund
|
|
143
|
+
* revoked is answered `refund_reversed`: the reversal is recorded as a ledger
|
|
144
|
+
* note, nothing is re-granted, and the game re-grants on its own terms (a new
|
|
145
|
+
* grant entry). For a grant not revoked yet it is `retry` (Apple redelivers,
|
|
146
|
+
* so a refund still in flight is applied first); for a grant revoked for
|
|
147
|
+
* another reason it is `ignored`.
|
|
148
|
+
*
|
|
149
|
+
* A refund for a grant this ledger does not hold is `retry` while it is
|
|
150
|
+
* recent (`unmatchedRefundRetryMs`, a purchase may be committing a grant it
|
|
151
|
+
* 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.
|
|
153
|
+
* A thrown error (a store outage, an abort) is not an outcome: the host must
|
|
154
|
+
* answer it with a 5xx so Apple redelivers.
|
|
155
|
+
*/
|
|
156
|
+
export declare function handleAppStoreServerNotification(input: HandleAppStoreServerNotificationInput): Promise<AppStoreServerNotificationOutcome>;
|
|
157
|
+
/** The HTTP status to answer Apple with: 200 stops its retries, anything else asks for one. */
|
|
158
|
+
export declare function appStoreServerNotificationHttpStatus(outcome: AppStoreServerNotificationOutcome): 200 | 400 | 503;
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { findEntitlementRevocationsChunked, findPurchaseGrantByEvidenceVerificationId, revokeEntitlementGrant, } from './commerce-ledger.js';
|
|
2
|
+
import { AppStoreDependencyUnavailableError, createAppStoreVerificationId, } from './app-store-verifier.js';
|
|
3
|
+
/** Default of `unmatchedRefundRetryMs`. */
|
|
4
|
+
export const defaultUnmatchedRefundRetryMs = 60 * 60 * 1000;
|
|
5
|
+
/** Upper bound of `unmatchedRefundRetryMs`. */
|
|
6
|
+
export const maxUnmatchedRefundRetryMs = 6 * 60 * 60 * 1000;
|
|
7
|
+
/**
|
|
8
|
+
* The longest `signedPayload` string accepted; Apple's notifications are a
|
|
9
|
+
* few kilobytes. The host bounds the whole request body itself.
|
|
10
|
+
*/
|
|
11
|
+
export const maxAppStoreServerNotificationBytes = 256 * 1024;
|
|
12
|
+
const revocationReasons = {
|
|
13
|
+
// The customer was refunded for the purchase.
|
|
14
|
+
REFUND: 'refund',
|
|
15
|
+
// Access the customer had through an organization ended. A family member's
|
|
16
|
+
// FAMILY_SHARED transaction is ignored before the lookup: it shares the
|
|
17
|
+
// purchaser's original transaction id, and the purchaser keeps the grant.
|
|
18
|
+
REVOKE: 'voided',
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Verifies one App Store Server Notification and applies it to the ledger.
|
|
22
|
+
* `REFUND` and `REVOKE` for a granted one-time purchase append one `revoke`
|
|
23
|
+
* entry, keyed by the notification's `notificationUUID` so a redelivery
|
|
24
|
+
* answers the first entry. Every other type, a transaction this ledger never
|
|
25
|
+
* granted, and a subscription transaction are `ignored` (answer 200 so Apple
|
|
26
|
+
* stops retrying). `REFUND_REVERSED` for a grant an App Store refund
|
|
27
|
+
* revoked is answered `refund_reversed`: the reversal is recorded as a ledger
|
|
28
|
+
* note, nothing is re-granted, and the game re-grants on its own terms (a new
|
|
29
|
+
* grant entry). For a grant not revoked yet it is `retry` (Apple redelivers,
|
|
30
|
+
* so a refund still in flight is applied first); for a grant revoked for
|
|
31
|
+
* another reason it is `ignored`.
|
|
32
|
+
*
|
|
33
|
+
* A refund for a grant this ledger does not hold is `retry` while it is
|
|
34
|
+
* recent (`unmatchedRefundRetryMs`, a purchase may be committing a grant it
|
|
35
|
+
* 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.
|
|
37
|
+
* A thrown error (a store outage, an abort) is not an outcome: the host must
|
|
38
|
+
* answer it with a 5xx so Apple redelivers.
|
|
39
|
+
*/
|
|
40
|
+
export async function handleAppStoreServerNotification(input) {
|
|
41
|
+
// A configuration error, checked before any work so it fails every call alike.
|
|
42
|
+
const unmatchedRefundRetryMs = input.unmatchedRefundRetryMs ?? defaultUnmatchedRefundRetryMs;
|
|
43
|
+
if (!Number.isSafeInteger(unmatchedRefundRetryMs)
|
|
44
|
+
|| unmatchedRefundRetryMs < 0
|
|
45
|
+
|| unmatchedRefundRetryMs > maxUnmatchedRefundRetryMs) {
|
|
46
|
+
throw new RangeError(`unmatchedRefundRetryMs must be an integer from 0 to ${maxUnmatchedRefundRetryMs} ms.`);
|
|
47
|
+
}
|
|
48
|
+
const signedPayload = readSignedPayload(input.body);
|
|
49
|
+
if (signedPayload === undefined) {
|
|
50
|
+
return { status: 'rejected', reason: 'APP_STORE_NOTIFICATION_PAYLOAD_INVALID' };
|
|
51
|
+
}
|
|
52
|
+
let verification;
|
|
53
|
+
try {
|
|
54
|
+
verification = await input.verifier.verifyAndDecodeNotification({
|
|
55
|
+
signedPayload,
|
|
56
|
+
environment: input.environment,
|
|
57
|
+
bundleId: input.bundleId,
|
|
58
|
+
signal: input.signal,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (error instanceof AppStoreDependencyUnavailableError) {
|
|
63
|
+
return { status: 'retry', reason: 'APP_STORE_SIGNATURE_VERIFIER_UNAVAILABLE' };
|
|
64
|
+
}
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
if (verification.status !== 'verified') {
|
|
68
|
+
return { status: 'rejected', reason: verification.reason };
|
|
69
|
+
}
|
|
70
|
+
const notification = verification.notification;
|
|
71
|
+
if (notification.environment !== input.environment
|
|
72
|
+
|| notification.bundleId !== input.bundleId) {
|
|
73
|
+
return { status: 'rejected', reason: 'APP_STORE_NOTIFICATION_SCOPE_MISMATCH' };
|
|
74
|
+
}
|
|
75
|
+
const notificationUUID = notification.notificationUUID;
|
|
76
|
+
const isRefundReversal = notification.notificationType === 'REFUND_REVERSED';
|
|
77
|
+
const reason = revocationReasons[notification.notificationType];
|
|
78
|
+
if (reason === undefined && !isRefundReversal) {
|
|
79
|
+
return { status: 'ignored', reason: 'APP_STORE_NOTIFICATION_TYPE_UNHANDLED', notificationUUID };
|
|
80
|
+
}
|
|
81
|
+
const transaction = notification.transaction;
|
|
82
|
+
if (transaction === undefined) {
|
|
83
|
+
return {
|
|
84
|
+
status: 'ignored',
|
|
85
|
+
reason: 'APP_STORE_NOTIFICATION_TRANSACTION_UNSUPPORTED',
|
|
86
|
+
notificationUUID,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (transaction.environment !== input.environment
|
|
90
|
+
|| transaction.bundleId !== input.bundleId) {
|
|
91
|
+
return { status: 'rejected', reason: 'APP_STORE_NOTIFICATION_SCOPE_MISMATCH' };
|
|
92
|
+
}
|
|
93
|
+
if (transaction.inAppOwnershipType !== undefined
|
|
94
|
+
&& transaction.inAppOwnershipType !== 'PURCHASED') {
|
|
95
|
+
// Shared access is never granted (APP_STORE_FAMILY_SHARED_UNSUPPORTED),
|
|
96
|
+
// so its end must not reverse the purchaser's own grant.
|
|
97
|
+
return {
|
|
98
|
+
status: 'ignored',
|
|
99
|
+
reason: 'APP_STORE_NOTIFICATION_SHARED_ACCESS_IGNORED',
|
|
100
|
+
notificationUUID,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// The grant's identity is the one the purchase verifier recorded:
|
|
104
|
+
// non-consumables by their original transaction, consumables by their own.
|
|
105
|
+
const grantVerificationId = createAppStoreVerificationId({
|
|
106
|
+
environment: transaction.environment,
|
|
107
|
+
bundleId: transaction.bundleId,
|
|
108
|
+
transactionId: transaction.type === 'Non-Consumable'
|
|
109
|
+
? transaction.originalTransactionId
|
|
110
|
+
: transaction.transactionId,
|
|
111
|
+
});
|
|
112
|
+
const now = input.now ?? (() => new Date().toISOString());
|
|
113
|
+
const grant = await findPurchaseGrantByEvidenceVerificationId(input.store, grantVerificationId);
|
|
114
|
+
if (grant === undefined || grant.payload.target !== 'ios') {
|
|
115
|
+
if (grant === undefined
|
|
116
|
+
&& reason !== undefined
|
|
117
|
+
// Both ways, so a little clock skew still retries and a date far in
|
|
118
|
+
// the future (or not representable) cannot keep it unacknowledged.
|
|
119
|
+
&& Math.abs(Date.parse(now()) - (transaction.revocationDate ?? notification.signedDate))
|
|
120
|
+
< unmatchedRefundRetryMs) {
|
|
121
|
+
// A purchase in flight may have read the transaction before the
|
|
122
|
+
// refund and not written its grant yet: let Apple redeliver.
|
|
123
|
+
return {
|
|
124
|
+
status: 'retry',
|
|
125
|
+
reason: 'APP_STORE_NOTIFICATION_GRANT_NOT_FOUND',
|
|
126
|
+
notificationUUID,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
status: 'ignored',
|
|
131
|
+
reason: 'APP_STORE_NOTIFICATION_GRANT_NOT_FOUND',
|
|
132
|
+
notificationUUID,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (reason === undefined) {
|
|
136
|
+
// Only REFUND_REVERSED gets past the type gate without a revocation reason.
|
|
137
|
+
return recordRefundReversal(input.store, {
|
|
138
|
+
notificationUUID,
|
|
139
|
+
grant,
|
|
140
|
+
now: now(),
|
|
141
|
+
payload: {
|
|
142
|
+
target: 'ios',
|
|
143
|
+
store: 'app-store',
|
|
144
|
+
appStoreNotificationType: notification.notificationType,
|
|
145
|
+
appStoreEnvironment: transaction.environment,
|
|
146
|
+
appStoreOriginalTransactionId: transaction.originalTransactionId,
|
|
147
|
+
platformTransactionId: transaction.transactionId,
|
|
148
|
+
platformProductId: transaction.productId,
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
const revokedAt = readTimestamp(transaction.revocationDate) ?? now();
|
|
153
|
+
const result = await revokeEntitlementGrant(input.store, {
|
|
154
|
+
playerId: grant.playerId,
|
|
155
|
+
revokesLedgerEntryId: grant.ledgerEntryId,
|
|
156
|
+
idempotencyKey: `app-store:notification:${notificationUUID}`,
|
|
157
|
+
reason,
|
|
158
|
+
revokedAt,
|
|
159
|
+
evidenceVerificationId: `${grantVerificationId}:revoke`,
|
|
160
|
+
payload: {
|
|
161
|
+
target: 'ios',
|
|
162
|
+
store: 'app-store',
|
|
163
|
+
appStoreNotificationType: notification.notificationType,
|
|
164
|
+
appStoreNotificationUUID: notificationUUID,
|
|
165
|
+
appStoreEnvironment: transaction.environment,
|
|
166
|
+
appStoreBundleId: transaction.bundleId,
|
|
167
|
+
appStoreOriginalTransactionId: transaction.originalTransactionId,
|
|
168
|
+
platformTransactionId: transaction.transactionId,
|
|
169
|
+
platformProductId: transaction.productId,
|
|
170
|
+
...(notification.subtype === undefined ? {} : { appStoreNotificationSubtype: notification.subtype }),
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
if (result.status === 'revoked') {
|
|
174
|
+
return {
|
|
175
|
+
status: 'revoked',
|
|
176
|
+
notificationUUID,
|
|
177
|
+
ledgerEntryId: result.ledgerEntryId,
|
|
178
|
+
alreadyProcessed: result.alreadyProcessed,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
// grant_not_found / grant_mismatch cannot follow the lookup above unless
|
|
182
|
+
// the ledger changed underneath; a key or identity conflict is a ledger
|
|
183
|
+
// invariant a redelivery cannot fix. None of them is worth Apple's retry.
|
|
184
|
+
return {
|
|
185
|
+
status: result.status === 'key_conflict' || result.status === 'evidence_already_processed'
|
|
186
|
+
? 'conflict'
|
|
187
|
+
: 'ignored',
|
|
188
|
+
reason: `APP_STORE_NOTIFICATION_${result.status.toUpperCase()}`,
|
|
189
|
+
notificationUUID,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
async function recordRefundReversal(store, input) {
|
|
193
|
+
const [revocation] = await findEntitlementRevocationsChunked(store, [input.grant.ledgerEntryId]);
|
|
194
|
+
if (revocation === undefined) {
|
|
195
|
+
// The grant is still active. Either the refund never applied here, or
|
|
196
|
+
// its delivery is still being retried and could revoke the grant after
|
|
197
|
+
// this reversal: ask Apple to redeliver (production retries for 72 hours)
|
|
198
|
+
// so the reversal is seen after the refund.
|
|
199
|
+
return {
|
|
200
|
+
status: 'retry',
|
|
201
|
+
reason: 'APP_STORE_NOTIFICATION_GRANT_NOT_REVOKED',
|
|
202
|
+
notificationUUID: input.notificationUUID,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
if (revocation.reason !== 'refund' || revocation.payload.store !== 'app-store') {
|
|
206
|
+
// A REFUND_REVERSED only undoes an App Store refund; a grant revoked for
|
|
207
|
+
// another reason (REVOKE, a chargeback, an admin) stays revoked silently.
|
|
208
|
+
return {
|
|
209
|
+
status: 'ignored',
|
|
210
|
+
reason: 'APP_STORE_NOTIFICATION_REVOCATION_NOT_REFUND',
|
|
211
|
+
notificationUUID: input.notificationUUID,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
let recorded = false;
|
|
215
|
+
let alreadyRecorded = false;
|
|
216
|
+
if (store.recordEntitlementNote !== undefined) {
|
|
217
|
+
const result = await store.recordEntitlementNote({
|
|
218
|
+
noteId: `app-store:notification:${input.notificationUUID}`,
|
|
219
|
+
kind: 'refund_reversed',
|
|
220
|
+
playerId: input.grant.playerId,
|
|
221
|
+
ledgerEntryId: input.grant.ledgerEntryId,
|
|
222
|
+
revocationLedgerEntryId: revocation.ledgerEntryId,
|
|
223
|
+
recordedAt: input.now,
|
|
224
|
+
payload: { ...input.payload, appStoreNotificationUUID: input.notificationUUID },
|
|
225
|
+
});
|
|
226
|
+
recorded = true;
|
|
227
|
+
alreadyRecorded = result.alreadyRecorded;
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
status: 'refund_reversed',
|
|
231
|
+
notificationUUID: input.notificationUUID,
|
|
232
|
+
playerId: input.grant.playerId,
|
|
233
|
+
ledgerEntryId: input.grant.ledgerEntryId,
|
|
234
|
+
revocationLedgerEntryId: revocation.ledgerEntryId,
|
|
235
|
+
recorded,
|
|
236
|
+
alreadyRecorded,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/** The HTTP status to answer Apple with: 200 stops its retries, anything else asks for one. */
|
|
240
|
+
export function appStoreServerNotificationHttpStatus(outcome) {
|
|
241
|
+
switch (outcome.status) {
|
|
242
|
+
case 'revoked':
|
|
243
|
+
case 'refund_reversed':
|
|
244
|
+
case 'ignored':
|
|
245
|
+
case 'conflict':
|
|
246
|
+
return 200;
|
|
247
|
+
case 'rejected':
|
|
248
|
+
return 400;
|
|
249
|
+
case 'retry':
|
|
250
|
+
return 503;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function readTimestamp(value) {
|
|
254
|
+
if (value === undefined) {
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
const date = new Date(value);
|
|
258
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
259
|
+
}
|
|
260
|
+
function readSignedPayload(body) {
|
|
261
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
const signedPayload = body.signedPayload;
|
|
265
|
+
return typeof signedPayload === 'string'
|
|
266
|
+
&& signedPayload.length > 0
|
|
267
|
+
&& signedPayload.length <= maxAppStoreServerNotificationBytes
|
|
268
|
+
? signedPayload
|
|
269
|
+
: undefined;
|
|
270
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type AppStoreServerNotificationVerifier } from './app-store-server-notifications.js';
|
|
1
2
|
import { type AppStoreEnvironment, type AppStoreSignedTransactionVerifier } from './app-store-verifier.js';
|
|
2
3
|
export interface CreateAppleSignedTransactionVerifierOptions {
|
|
3
4
|
/** DER-encoded Apple root certificates, supplied by the server operator. */
|
|
@@ -10,3 +11,10 @@ export interface CreateAppleSignedTransactionVerifierOptions {
|
|
|
10
11
|
}
|
|
11
12
|
/** Verify the App Store JWS before exposing any decoded purchase fields. */
|
|
12
13
|
export declare function createAppleSignedTransactionVerifier(options: CreateAppleSignedTransactionVerifierOptions): AppStoreSignedTransactionVerifier;
|
|
14
|
+
/**
|
|
15
|
+
* Verify an App Store Server Notifications V2 `signedPayload`, and the
|
|
16
|
+
* `signedTransactionInfo` inside it, with the same trust roots and scope as
|
|
17
|
+
* purchases. Use one verifier per environment: the production and sandbox
|
|
18
|
+
* notification URLs each verify against their own environment.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createAppleServerNotificationVerifier(options: CreateAppleSignedTransactionVerifierOptions): AppStoreServerNotificationVerifier;
|
package/dist/app-store-server.js
CHANGED
|
@@ -1,7 +1,99 @@
|
|
|
1
1
|
import { Environment, SignedDataVerifier, VerificationException, VerificationStatus, } from '@apple/app-store-server-library';
|
|
2
|
+
import { maxAppStoreServerNotificationBytes, } from './app-store-server-notifications.js';
|
|
2
3
|
import { AppStoreDependencyUnavailableError, } from './app-store-verifier.js';
|
|
3
4
|
/** Verify the App Store JWS before exposing any decoded purchase fields. */
|
|
4
5
|
export function createAppleSignedTransactionVerifier(options) {
|
|
6
|
+
const verifier = createSignedDataVerifier(options);
|
|
7
|
+
return {
|
|
8
|
+
async verifyAndDecode(input) {
|
|
9
|
+
if (input.environment !== options.environment || input.bundleId !== options.bundleId) {
|
|
10
|
+
return { status: 'rejected', reason: 'APP_STORE_VERIFIER_SCOPE_MISMATCH' };
|
|
11
|
+
}
|
|
12
|
+
if (typeof input.signedTransaction !== 'string'
|
|
13
|
+
|| input.signedTransaction.length === 0
|
|
14
|
+
|| input.signedTransaction.length > 512 * 1024) {
|
|
15
|
+
return { status: 'rejected', reason: 'APP_STORE_SIGNED_TRANSACTION_INVALID' };
|
|
16
|
+
}
|
|
17
|
+
const decoded = await verifyWithApple(() => verifier.verifyAndDecodeTransaction(input.signedTransaction), input.signal);
|
|
18
|
+
if (decoded.status === 'rejected') {
|
|
19
|
+
return decoded;
|
|
20
|
+
}
|
|
21
|
+
const payload = decodeOneTimeTransaction(decoded.value);
|
|
22
|
+
return payload === undefined
|
|
23
|
+
? { status: 'rejected', reason: 'APP_STORE_SIGNED_TRANSACTION_INVALID' }
|
|
24
|
+
: { status: 'verified', payload };
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Verify an App Store Server Notifications V2 `signedPayload`, and the
|
|
30
|
+
* `signedTransactionInfo` inside it, with the same trust roots and scope as
|
|
31
|
+
* purchases. Use one verifier per environment: the production and sandbox
|
|
32
|
+
* notification URLs each verify against their own environment.
|
|
33
|
+
*/
|
|
34
|
+
export function createAppleServerNotificationVerifier(options) {
|
|
35
|
+
const verifier = createSignedDataVerifier(options);
|
|
36
|
+
return {
|
|
37
|
+
async verifyAndDecodeNotification(input) {
|
|
38
|
+
if (input.environment !== options.environment || input.bundleId !== options.bundleId) {
|
|
39
|
+
return { status: 'rejected', reason: 'APP_STORE_VERIFIER_SCOPE_MISMATCH' };
|
|
40
|
+
}
|
|
41
|
+
if (typeof input.signedPayload !== 'string'
|
|
42
|
+
|| input.signedPayload.length === 0
|
|
43
|
+
|| input.signedPayload.length > maxAppStoreServerNotificationBytes) {
|
|
44
|
+
return { status: 'rejected', reason: 'APP_STORE_NOTIFICATION_PAYLOAD_INVALID' };
|
|
45
|
+
}
|
|
46
|
+
const decoded = await verifyWithApple(() => verifier.verifyAndDecodeNotification(input.signedPayload), input.signal);
|
|
47
|
+
if (decoded.status === 'rejected') {
|
|
48
|
+
return decoded;
|
|
49
|
+
}
|
|
50
|
+
const notification = decoded.value;
|
|
51
|
+
const data = notification.data;
|
|
52
|
+
// Summary, app-data, and external-purchase notifications carry no
|
|
53
|
+
// `data`; Apple's verifier already matched their bundle and
|
|
54
|
+
// environment to this verifier's, and they carry no transaction.
|
|
55
|
+
const environment = data === undefined
|
|
56
|
+
? options.environment
|
|
57
|
+
: readEnvironment(data.environment);
|
|
58
|
+
const bundleId = data === undefined ? options.bundleId : data.bundleId;
|
|
59
|
+
if (!nonEmpty(notification.notificationType)
|
|
60
|
+
|| !nonEmpty(notification.notificationUUID)
|
|
61
|
+
|| !validTimestamp(notification.signedDate)
|
|
62
|
+
|| (notification.subtype !== undefined && !nonEmpty(notification.subtype))
|
|
63
|
+
|| environment === undefined
|
|
64
|
+
|| !nonEmpty(bundleId)) {
|
|
65
|
+
return { status: 'rejected', reason: 'APP_STORE_NOTIFICATION_PAYLOAD_INVALID' };
|
|
66
|
+
}
|
|
67
|
+
let transaction;
|
|
68
|
+
if (data?.signedTransactionInfo !== undefined) {
|
|
69
|
+
const signedTransaction = data.signedTransactionInfo;
|
|
70
|
+
const decodedTransaction = await verifyWithApple(() => verifier.verifyAndDecodeTransaction(signedTransaction), input.signal);
|
|
71
|
+
if (decodedTransaction.status === 'rejected') {
|
|
72
|
+
// Named for the embedded transaction, apart from the payload's own checks.
|
|
73
|
+
return {
|
|
74
|
+
status: 'rejected',
|
|
75
|
+
reason: decodedTransaction.reason.replace(/^APP_STORE_/u, 'APP_STORE_NOTIFICATION_TRANSACTION_'),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// A subscription transaction decodes to undefined and is ignored.
|
|
79
|
+
transaction = decodeOneTimeTransaction(decodedTransaction.value);
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
status: 'verified',
|
|
83
|
+
notification: {
|
|
84
|
+
notificationType: notification.notificationType,
|
|
85
|
+
...(notification.subtype === undefined ? {} : { subtype: notification.subtype }),
|
|
86
|
+
notificationUUID: notification.notificationUUID,
|
|
87
|
+
signedDate: notification.signedDate,
|
|
88
|
+
environment,
|
|
89
|
+
bundleId,
|
|
90
|
+
...(transaction === undefined ? {} : { transaction }),
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function createSignedDataVerifier(options) {
|
|
5
97
|
if (!Array.isArray(options.rootCertificates)
|
|
6
98
|
|| options.rootCertificates.length === 0
|
|
7
99
|
|| options.rootCertificates.some((certificate) => !(certificate instanceof Uint8Array)
|
|
@@ -21,43 +113,42 @@ export function createAppleSignedTransactionVerifier(options) {
|
|
|
21
113
|
if (options.environment === 'Production' && options.enableOnlineChecks === false) {
|
|
22
114
|
throw new TypeError('Production App Store verification requires online certificate checks.');
|
|
23
115
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (error.status === VerificationStatus.RETRYABLE_VERIFICATION_FAILURE) {
|
|
47
|
-
throw new AppStoreDependencyUnavailableError('Apple signature checks are temporarily unavailable.', error);
|
|
48
|
-
}
|
|
49
|
-
return { status: 'rejected', reason: 'APP_STORE_SIGNATURE_INVALID' };
|
|
50
|
-
}
|
|
51
|
-
// Only Apple's explicit retryable status is a transient dependency
|
|
52
|
-
// failure. Unexpected adapter/library faults must reach the caller.
|
|
53
|
-
throw error;
|
|
116
|
+
return new SignedDataVerifier(options.rootCertificates.map((certificate) => Buffer.from(certificate)), options.enableOnlineChecks ?? true, options.environment === 'Production' ? Environment.PRODUCTION : Environment.SANDBOX, options.bundleId, options.appAppleId);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Runs one Apple verification. A failed check is a rejection, the
|
|
120
|
+
* environment mismatch is named so the purchase verifier can retry the JWS in
|
|
121
|
+
* the sandbox, Apple's retryable status is a transient dependency error, and
|
|
122
|
+
* cancellation and unexpected library faults propagate.
|
|
123
|
+
*/
|
|
124
|
+
async function verifyWithApple(operation, signal) {
|
|
125
|
+
signal.throwIfAborted();
|
|
126
|
+
try {
|
|
127
|
+
const value = await waitForVerification(operation(), signal);
|
|
128
|
+
signal.throwIfAborted();
|
|
129
|
+
return { status: 'verified', value };
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (signal.aborted) {
|
|
133
|
+
throw signal.reason;
|
|
134
|
+
}
|
|
135
|
+
if (error instanceof VerificationException) {
|
|
136
|
+
if (error.status === VerificationStatus.RETRYABLE_VERIFICATION_FAILURE) {
|
|
137
|
+
throw new AppStoreDependencyUnavailableError('Apple signature checks are temporarily unavailable.', error);
|
|
54
138
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
139
|
+
// The signature checked out but the JWS belongs to the other
|
|
140
|
+
// environment: the evidence verifier may retry it in the sandbox.
|
|
141
|
+
return {
|
|
142
|
+
status: 'rejected',
|
|
143
|
+
reason: error.status === VerificationStatus.INVALID_ENVIRONMENT
|
|
144
|
+
? 'APP_STORE_ENVIRONMENT_MISMATCH'
|
|
145
|
+
: 'APP_STORE_SIGNATURE_INVALID',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
// Only Apple's explicit retryable status is a transient dependency
|
|
149
|
+
// failure. Unexpected adapter/library faults must reach the caller.
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
61
152
|
}
|
|
62
153
|
async function waitForVerification(operation, signal) {
|
|
63
154
|
signal.throwIfAborted();
|
|
@@ -92,7 +183,8 @@ function decodeOneTimeTransaction(decoded) {
|
|
|
92
183
|
|| (decoded.appAccountToken !== undefined && !nonEmpty(decoded.appAccountToken))
|
|
93
184
|
|| (decoded.revocationDate !== undefined && !validTimestamp(decoded.revocationDate))
|
|
94
185
|
|| (decoded.expiresDate !== undefined && !validTimestamp(decoded.expiresDate))
|
|
95
|
-
|| (decoded.isUpgraded !== undefined && typeof decoded.isUpgraded !== 'boolean')
|
|
186
|
+
|| (decoded.isUpgraded !== undefined && typeof decoded.isUpgraded !== 'boolean')
|
|
187
|
+
|| (decoded.inAppOwnershipType !== undefined && !nonEmpty(decoded.inAppOwnershipType))) {
|
|
96
188
|
return undefined;
|
|
97
189
|
}
|
|
98
190
|
return {
|
|
@@ -109,8 +201,14 @@ function decodeOneTimeTransaction(decoded) {
|
|
|
109
201
|
...(decoded.revocationDate === undefined ? {} : { revocationDate: decoded.revocationDate }),
|
|
110
202
|
...(decoded.expiresDate === undefined ? {} : { expiresDate: decoded.expiresDate }),
|
|
111
203
|
...(decoded.isUpgraded === undefined ? {} : { isUpgraded: decoded.isUpgraded }),
|
|
204
|
+
...(decoded.inAppOwnershipType === undefined
|
|
205
|
+
? {}
|
|
206
|
+
: { inAppOwnershipType: decoded.inAppOwnershipType }),
|
|
112
207
|
};
|
|
113
208
|
}
|
|
209
|
+
function readEnvironment(value) {
|
|
210
|
+
return value === 'Production' || value === 'Sandbox' ? value : undefined;
|
|
211
|
+
}
|
|
114
212
|
function nonEmpty(value) {
|
|
115
213
|
return typeof value === 'string' && value.trim() !== '';
|
|
116
214
|
}
|