@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 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 would be
318
- rejected for good, and its derived key would then block the other player's
319
- own recovery. The player's explicit Restore Purchases still takes a durable
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 { AppStoreDependencyUnavailableError, createAppStoreVerificationId, } from './app-store-verifier.js';
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 = createAppStoreVerificationId({
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 alone and is only for deployments without Server
90
- * API access: a JWS is a snapshot, so a transaction refunded after it was
91
- * signed is accepted until `signedDate` falls out of the window. `{ mode:
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 declare function createAppStoreVerificationId(input: {
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;