@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.
Files changed (44) hide show
  1. package/README.md +18 -3
  2. package/dist/account-deletion.d.ts +306 -0
  3. package/dist/account-deletion.js +491 -0
  4. package/dist/app-store-recovery.d.ts +6 -0
  5. package/dist/app-store-recovery.js +26 -16
  6. package/dist/app-store-server-notifications.d.ts +158 -0
  7. package/dist/app-store-server-notifications.js +270 -0
  8. package/dist/app-store-server.d.ts +8 -0
  9. package/dist/app-store-server.js +135 -37
  10. package/dist/app-store-verifier.d.ts +34 -0
  11. package/dist/app-store-verifier.js +161 -31
  12. package/dist/commerce-ledger.d.ts +55 -0
  13. package/dist/commerce-ledger.js +198 -0
  14. package/dist/commerce-reconciler.js +47 -7
  15. package/dist/evidence-verification.d.ts +19 -0
  16. package/dist/google-play-publisher-client.d.ts +2 -1
  17. package/dist/google-play-publisher-client.js +80 -0
  18. package/dist/google-play-purchase.d.ts +7 -0
  19. package/dist/google-play-purchase.js +32 -7
  20. package/dist/google-play-store-server.d.ts +160 -0
  21. package/dist/google-play-store-server.js +297 -0
  22. package/dist/guest-session.d.ts +112 -2
  23. package/dist/guest-session.js +196 -1
  24. package/dist/index.d.ts +4 -0
  25. package/dist/index.js +4 -0
  26. package/dist/notifications/delivery-provider.d.ts +7 -0
  27. package/dist/notifications/fcm.d.ts +30 -3
  28. package/dist/notifications/fcm.js +72 -13
  29. package/dist/notifications/service.js +33 -5
  30. package/dist/notifications/store-conformance.js +7 -1
  31. package/dist/notifications/store.d.ts +9 -0
  32. package/dist/notifications/store.js +41 -0
  33. package/dist/operation-results.d.ts +7 -0
  34. package/dist/purchase-sharing.d.ts +41 -0
  35. package/dist/purchase-sharing.js +37 -0
  36. package/dist/runtime.d.ts +6 -0
  37. package/dist/runtime.js +13 -0
  38. package/dist/server.d.ts +60 -1
  39. package/dist/server.js +536 -42
  40. package/dist/types.d.ts +30 -0
  41. package/dist/types.js +22 -0
  42. package/dist/verified-leaderboard.d.ts +13 -0
  43. package/dist/verified-leaderboard.js +31 -0
  44. 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;
@@ -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
- const verifier = new SignedDataVerifier(options.rootCertificates.map((certificate) => Buffer.from(certificate)), options.enableOnlineChecks ?? true, options.environment === 'Production' ? Environment.PRODUCTION : Environment.SANDBOX, options.bundleId, options.appAppleId);
25
- return {
26
- async verifyAndDecode(input) {
27
- if (input.environment !== options.environment || input.bundleId !== options.bundleId) {
28
- return { status: 'rejected', reason: 'APP_STORE_VERIFIER_SCOPE_MISMATCH' };
29
- }
30
- if (typeof input.signedTransaction !== 'string'
31
- || input.signedTransaction.length === 0
32
- || input.signedTransaction.length > 512 * 1024) {
33
- return { status: 'rejected', reason: 'APP_STORE_SIGNED_TRANSACTION_INVALID' };
34
- }
35
- input.signal.throwIfAborted();
36
- let decoded;
37
- try {
38
- decoded = await waitForVerification(verifier.verifyAndDecodeTransaction(input.signedTransaction), input.signal);
39
- input.signal.throwIfAborted();
40
- }
41
- catch (error) {
42
- if (input.signal.aborted) {
43
- throw input.signal.reason;
44
- }
45
- if (error instanceof VerificationException) {
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
- const payload = decodeOneTimeTransaction(decoded);
56
- return payload === undefined
57
- ? { status: 'rejected', reason: 'APP_STORE_SIGNED_TRANSACTION_INVALID' }
58
- : { status: 'verified', payload };
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
  }