@mpgd/game-services 0.33.1 → 0.35.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.
@@ -0,0 +1,248 @@
1
+ import { type GooglePlayVoidedPurchasesClient } from './google-play-store-server.js';
2
+ import type { GameServicesStore } from './server.js';
3
+ /**
4
+ * Google Play Real-time developer notifications (RTDN) delivered by a Cloud
5
+ * Pub/Sub push subscription: the fast path for revoking a refunded or voided
6
+ * one-time purchase. The Voided Purchases API sweep
7
+ * (`sweepGooglePlayVoidedPurchases`) stays required next to it, because a
8
+ * push can be lost (a misconfigured subscription, a message that outlives
9
+ * its retention); both revoke through `revokeGooglePlayVoidedPurchase`, keyed
10
+ * by the purchase token, so whichever comes second is a no-op.
11
+ *
12
+ * - RTDN reference: https://developer.android.com/google/play/billing/rtdn-reference
13
+ * - Push authentication: https://cloud.google.com/pubsub/docs/authenticate-push-subscriptions
14
+ * - Push acknowledgement (102, 200, 201, 202, 204 ack; anything else is
15
+ * redelivered): https://cloud.google.com/pubsub/docs/push
16
+ * - Payload unwrapping: https://cloud.google.com/pubsub/docs/payload-unwrapping
17
+ * - Voided Purchases API (6,000 queries a day, 30 in any 30 seconds):
18
+ * https://developers.google.com/android-publisher/voided-purchases
19
+ */
20
+ /** Issuers of Google-signed ID tokens (Google's OpenID Connect guide). */
21
+ export declare const googleIdTokenIssuers: readonly string[];
22
+ /**
23
+ * Google's published ID token signing keys (the `jwks_uri` of
24
+ * https://accounts.google.com/.well-known/openid-configuration).
25
+ */
26
+ export declare const googleIdTokenJwksUrl = "https://www.googleapis.com/oauth2/v3/certs";
27
+ /** Google's keys could not be read: answer non-2xx so Pub/Sub redelivers. */
28
+ export declare class GoogleIdTokenKeysUnavailableError extends Error {
29
+ readonly name = "GoogleIdTokenKeysUnavailableError";
30
+ }
31
+ /** The source of Google's RS256 ID token signing keys; inject a fake in tests. */
32
+ export interface GoogleIdTokenSigningKeys {
33
+ /**
34
+ * The published public key with this key id, or undefined when Google does
35
+ * not publish one. Throws `GoogleIdTokenKeysUnavailableError` when the keys
36
+ * cannot be read.
37
+ */
38
+ getSigningKey(input: {
39
+ readonly kid: string;
40
+ readonly signal: AbortSignal;
41
+ }): Promise<JsonWebKey | undefined>;
42
+ }
43
+ export interface GoogleIdTokenSigningKeysOptions {
44
+ readonly fetch?: typeof fetch;
45
+ readonly jwksUrl?: string;
46
+ /** Milliseconds since the epoch; for the cache. */
47
+ readonly now?: () => number;
48
+ }
49
+ /**
50
+ * How long past its cache expiry a held key still verifies while Google's
51
+ * key endpoint cannot be read (an outage, or the refresh throttle). Six
52
+ * hours rides out a long endpoint outage without dropping pushes, and is the
53
+ * longest a push is retried (`maxUnmatchedVoidRetryMs`); past it a key Google
54
+ * may have withdrawn is no longer trusted and pushes answer 503 until the
55
+ * keys can be read again.
56
+ */
57
+ export declare const googleIdTokenKeyStaleGraceMs: number;
58
+ /**
59
+ * Reads Google's ID token keys from `googleIdTokenJwksUrl`, cached for the
60
+ * response's `Cache-Control: max-age` (one minute to a day; an hour without
61
+ * one) and refreshed early for a key id it does not hold: Google rotates its
62
+ * keys. Fetches, failed ones included, are at least a minute apart. While
63
+ * the keys cannot be read (an outage, or between throttled fetches), a held
64
+ * key answers for `googleIdTokenKeyStaleGraceMs` past its cache expiry; past
65
+ * that, an outage is `GoogleIdTokenKeysUnavailableError` and a throttled
66
+ * lookup finds no key. One instance per isolate shares the cache.
67
+ */
68
+ export declare function createGoogleIdTokenSigningKeys(options?: GoogleIdTokenSigningKeysOptions): GoogleIdTokenSigningKeys;
69
+ export interface GooglePubSubPushAuthentication {
70
+ /** The audience set on the push subscription (`--push-auth-token-audience`). */
71
+ readonly audience: string;
72
+ /** The service account the push subscription signs as (`--push-auth-service-account`). */
73
+ readonly serviceAccountEmail: string;
74
+ readonly keys: GoogleIdTokenSigningKeys;
75
+ /** Clock skew allowed on `exp` and `iat`, in seconds; 60 by default. */
76
+ readonly clockSkewSeconds?: number;
77
+ }
78
+ export type GooglePubSubPushTokenVerification = {
79
+ readonly status: 'verified';
80
+ } | {
81
+ readonly status: 'rejected';
82
+ readonly reason: string;
83
+ };
84
+ /**
85
+ * Verifies the OIDC token a Pub/Sub push subscription with authentication
86
+ * sends as `Authorization: Bearer <JWT>`: an RS256 signature by one of
87
+ * Google's published keys, a Google issuer, the subscription's audience, its
88
+ * service account (`email`, `email_verified`), and `exp`/`iat`. Throws
89
+ * `GoogleIdTokenKeysUnavailableError` when Google's keys cannot be read.
90
+ */
91
+ export declare function verifyGooglePubSubPushToken(input: GooglePubSubPushAuthentication & {
92
+ readonly authorization: string | null | undefined;
93
+ readonly signal: AbortSignal;
94
+ /** Milliseconds since the epoch. */
95
+ readonly now?: () => number;
96
+ }): Promise<GooglePubSubPushTokenVerification>;
97
+ /** `voidedPurchaseNotification.productType` (RTDN reference). */
98
+ export declare const googlePlayRtdnProductType: Readonly<{
99
+ readonly subscription: 1;
100
+ readonly oneTime: 2;
101
+ }>;
102
+ /** `voidedPurchaseNotification.refundType` (RTDN reference). */
103
+ export declare const googlePlayRtdnRefundType: Readonly<{
104
+ readonly fullRefund: 1;
105
+ readonly quantityBasedPartialRefund: 2;
106
+ }>;
107
+ /** The decoded `message.data`: one notification kind besides `version`, `packageName`, `eventTimeMillis`. */
108
+ export interface GooglePlayDeveloperNotification {
109
+ readonly version?: string;
110
+ readonly packageName: string;
111
+ /** Milliseconds since the epoch (an int64 string or a number in the JSON). */
112
+ readonly eventTimeMillis?: number;
113
+ readonly voidedPurchaseNotification?: {
114
+ readonly purchaseToken?: unknown;
115
+ readonly orderId?: unknown;
116
+ readonly productType?: unknown;
117
+ readonly refundType?: unknown;
118
+ };
119
+ readonly oneTimeProductNotification?: Readonly<Record<string, unknown>>;
120
+ readonly subscriptionNotification?: Readonly<Record<string, unknown>>;
121
+ readonly testNotification?: Readonly<Record<string, unknown>>;
122
+ readonly [kind: string]: unknown;
123
+ }
124
+ export type GooglePlayDeveloperNotificationPush = {
125
+ readonly status: 'decoded';
126
+ readonly messageId?: string;
127
+ readonly notification: GooglePlayDeveloperNotification;
128
+ } | {
129
+ readonly status: 'rejected';
130
+ readonly reason: string;
131
+ readonly messageId?: string;
132
+ };
133
+ /**
134
+ * Decodes a Pub/Sub push body into the `DeveloperNotification` it carries.
135
+ * Both push formats are accepted: the default wrapped body (`{ message:
136
+ * { data, messageId }, subscription }`, the notification base64 in `data`)
137
+ * and an unwrapped one (payload unwrapping on: the body is the notification
138
+ * JSON itself, with a top-level `packageName` and no `message`). An
139
+ * unwrapped push carries its message id only in the
140
+ * `x-goog-pubsub-message-id` header when "write metadata" is on; pass it as
141
+ * `messageId`.
142
+ */
143
+ export declare function decodeGooglePlayDeveloperNotificationPush(body: unknown, options?: {
144
+ readonly messageId?: string | null;
145
+ }): GooglePlayDeveloperNotificationPush;
146
+ export interface HandleGooglePlayDeveloperNotificationInput {
147
+ readonly store: GameServicesStore;
148
+ /** The app's package; a notification for another package is acknowledged and ignored. */
149
+ readonly packageName: string;
150
+ /**
151
+ * The Voided Purchases API (the sweep's client). A voided notification is
152
+ * only a trigger: the void is revoked once this lists it, with the listed
153
+ * entry, so the ledger records what the sweep would.
154
+ */
155
+ readonly voidedPurchases: GooglePlayVoidedPurchasesClient;
156
+ /** The push request body parsed from JSON. Authenticate the push before calling this. */
157
+ readonly body: unknown;
158
+ /** An unwrapped push's `x-goog-pubsub-message-id` header, for logs. */
159
+ readonly messageId?: string | null;
160
+ /** Bounds the Voided Purchases API lookup. */
161
+ readonly signal: AbortSignal;
162
+ readonly now?: () => string;
163
+ /**
164
+ * How long after the notification's `eventTimeMillis` a void that the
165
+ * Voided Purchases API does not list yet, or whose grant this ledger does
166
+ * not hold, is answered `retry` instead of `ignored`; 1 hour by default, at
167
+ * most `maxUnmatchedVoidRetryMs` (6 hours), `0` acknowledges at once. It
168
+ * covers Google's lag in listing the void and a purchase whose grant is
169
+ * still being written; past it the message is acknowledged and the sweep
170
+ * reads the void again.
171
+ */
172
+ readonly unmatchedVoidRetryMs?: number;
173
+ }
174
+ /** Default of `unmatchedVoidRetryMs`. */
175
+ export declare const defaultUnmatchedVoidRetryMs: number;
176
+ /** Upper bound of `unmatchedVoidRetryMs`. */
177
+ export declare const maxUnmatchedVoidRetryMs: number;
178
+ /**
179
+ * The lookup window around the notification's event time: this much before
180
+ * it (Google's own clocks), and up to `maxUnmatchedVoidRetryMs` after it
181
+ * (Google lists a void a little after it records it), never past now.
182
+ */
183
+ export declare const googlePlayRtdnLookupLeadMs: number;
184
+ /** Voided Purchases API pages one notification reads at most (a page holds up to 1,000 voids). */
185
+ export declare const googlePlayRtdnLookupMaxPages = 2;
186
+ /**
187
+ * Every outcome's `orderId` is the order id of the matched Voided Purchases
188
+ * API entry, never the push body's: it is absent until Google lists the void.
189
+ */
190
+ export type GooglePlayDeveloperNotificationOutcome =
191
+ /** The grant was reversed (or already had been: a redelivery, or the sweep came first). */
192
+ {
193
+ readonly status: 'revoked';
194
+ readonly messageId?: string;
195
+ readonly ledgerEntryId: string;
196
+ readonly alreadyProcessed: boolean;
197
+ readonly orderId?: string;
198
+ }
199
+ /** Nothing to do (a test, a subscription, a partial refund, another package): acknowledge. */
200
+ | {
201
+ readonly status: 'ignored';
202
+ readonly reason: string;
203
+ readonly messageId?: string;
204
+ readonly orderId?: string;
205
+ }
206
+ /** The ledger refused the revocation; a redelivery cannot fix it, so acknowledge and alert. */
207
+ | {
208
+ readonly status: 'conflict';
209
+ readonly reason: string;
210
+ readonly messageId?: string;
211
+ readonly orderId?: string;
212
+ }
213
+ /** Malformed: acknowledged so Pub/Sub does not redeliver it until retention runs out. */
214
+ | {
215
+ readonly status: 'rejected';
216
+ readonly reason: string;
217
+ readonly messageId?: string;
218
+ }
219
+ /**
220
+ * Answer non-2xx so Pub/Sub redelivers: the Voided Purchases API failed or
221
+ * does not list the void yet, or this ledger may still be writing its grant.
222
+ */
223
+ | {
224
+ readonly status: 'retry';
225
+ readonly reason: string;
226
+ readonly messageId?: string;
227
+ readonly orderId?: string;
228
+ };
229
+ /**
230
+ * Applies one RTDN push to the ledger. A `voidedPurchaseNotification` of a
231
+ * one-time product with a full refund is confirmed with the Voided Purchases
232
+ * API (one window around the event time, matched on the purchase token) and
233
+ * the listed entry is revoked through `revokeGooglePlayVoidedPurchase`: the
234
+ * sweep's path, idempotency key, reason, source, and void time. A push alone
235
+ * never revokes, so a message published to the topic by anyone but Google
236
+ * cannot reverse a grant Google did not void. A void not listed yet, or
237
+ * whose grant this ledger does not hold, is `retry` while recent
238
+ * (`unmatchedVoidRetryMs`), `ignored` after; a lookup failure is `retry`. A
239
+ * quantity-based partial refund is ignored without a lookup, as the sweep
240
+ * (which lists full refunds only) ignores it. Test, subscription, one-time
241
+ * product purchase, and every other kind are `ignored`: the kit sells no
242
+ * Play subscriptions, and a purchase is granted only when the game verifies
243
+ * it. A thrown error (a store outage) is not an outcome: answer 5xx so
244
+ * Pub/Sub redelivers.
245
+ */
246
+ export declare function handleGooglePlayDeveloperNotification(input: HandleGooglePlayDeveloperNotificationInput): Promise<GooglePlayDeveloperNotificationOutcome>;
247
+ /** The HTTP status to answer Pub/Sub with: 200 acknowledges, 503 asks for a redelivery. */
248
+ export declare function googlePlayDeveloperNotificationHttpStatus(outcome: GooglePlayDeveloperNotificationOutcome): 200 | 503;