@mpgd/game-services 0.33.0 → 0.34.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,594 @@
1
+ import { decodeBase64, decodeBase64Url } from './base64.js';
2
+ import { googlePlayVoidedPurchasesWindowMs, googlePlayVoidedPurchasesWindowSlackMs, revokeGooglePlayVoidedPurchase, } from './google-play-store-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 const googleIdTokenIssuers = Object.freeze([
22
+ 'https://accounts.google.com',
23
+ 'accounts.google.com',
24
+ ]);
25
+ /**
26
+ * Google's published ID token signing keys (the `jwks_uri` of
27
+ * https://accounts.google.com/.well-known/openid-configuration).
28
+ */
29
+ export const googleIdTokenJwksUrl = 'https://www.googleapis.com/oauth2/v3/certs';
30
+ /** Google's keys could not be read: answer non-2xx so Pub/Sub redelivers. */
31
+ export class GoogleIdTokenKeysUnavailableError extends Error {
32
+ name = 'GoogleIdTokenKeysUnavailableError';
33
+ }
34
+ const jwksMaxBytes = 64 * 1024;
35
+ const jwksTimeoutMs = 5_000;
36
+ const jwksDefaultCacheMs = 60 * 60 * 1000;
37
+ const jwksMinCacheMs = 60 * 1000;
38
+ const jwksMaxCacheMs = 24 * 60 * 60 * 1000;
39
+ /**
40
+ * The keys are fetched at most this often, whatever the outcome: a forged
41
+ * `kid` or an endpoint outage cannot drive one fetch per request.
42
+ */
43
+ const jwksMinRefreshMs = 60 * 1000;
44
+ /**
45
+ * How long past its cache expiry a held key still verifies while Google's
46
+ * key endpoint cannot be read (an outage, or the refresh throttle). Six
47
+ * hours rides out a long endpoint outage without dropping pushes, and is the
48
+ * longest a push is retried (`maxUnmatchedVoidRetryMs`); past it a key Google
49
+ * may have withdrawn is no longer trusted and pushes answer 503 until the
50
+ * keys can be read again.
51
+ */
52
+ export const googleIdTokenKeyStaleGraceMs = 6 * 60 * 60 * 1000;
53
+ /**
54
+ * Reads Google's ID token keys from `googleIdTokenJwksUrl`, cached for the
55
+ * response's `Cache-Control: max-age` (one minute to a day; an hour without
56
+ * one) and refreshed early for a key id it does not hold: Google rotates its
57
+ * keys. Fetches, failed ones included, are at least a minute apart. While
58
+ * the keys cannot be read (an outage, or between throttled fetches), a held
59
+ * key answers for `googleIdTokenKeyStaleGraceMs` past its cache expiry; past
60
+ * that, an outage is `GoogleIdTokenKeysUnavailableError` and a throttled
61
+ * lookup finds no key. One instance per isolate shares the cache.
62
+ */
63
+ export function createGoogleIdTokenSigningKeys(options = {}) {
64
+ const requestFetch = options.fetch ?? ((input, init) => fetch(input, init));
65
+ const jwksUrl = options.jwksUrl ?? googleIdTokenJwksUrl;
66
+ const now = options.now ?? Date.now;
67
+ let cached;
68
+ let fetchedAt = Number.NEGATIVE_INFINITY;
69
+ let lastFetchFailed = false;
70
+ let pending;
71
+ const startFetch = (signal) => {
72
+ fetchedAt = now();
73
+ const load = loadGoogleIdTokenKeys(requestFetch, jwksUrl, signal).then(({ keys, maxAgeMs }) => {
74
+ cached = { keys, expiresAt: now() + maxAgeMs };
75
+ lastFetchFailed = false;
76
+ return keys;
77
+ }, (error) => {
78
+ lastFetchFailed = true;
79
+ throw error;
80
+ });
81
+ pending = load;
82
+ const clear = () => {
83
+ if (pending === load) {
84
+ pending = undefined;
85
+ }
86
+ };
87
+ void load.then(clear, clear);
88
+ return load;
89
+ };
90
+ /** A held key within its cache lifetime plus the stale grace. */
91
+ const heldKey = (kid) => cached !== undefined && now() < cached.expiresAt + googleIdTokenKeyStaleGraceMs
92
+ ? cached.keys.get(kid)
93
+ : undefined;
94
+ return {
95
+ async getSigningKey({ kid, signal }) {
96
+ const known = cached?.keys.get(kid);
97
+ if (known !== undefined && cached !== undefined && cached.expiresAt > now()) {
98
+ return known;
99
+ }
100
+ let load = pending;
101
+ if (load === undefined) {
102
+ if (now() - fetchedAt < jwksMinRefreshMs) {
103
+ // Throttled: answer from what is held, within the stale grace.
104
+ // Otherwise the last fetch lacked the key, failed, or the held key
105
+ // is too old to trust.
106
+ const held = heldKey(kid);
107
+ if (held !== undefined) {
108
+ return held;
109
+ }
110
+ if (lastFetchFailed) {
111
+ throw new GoogleIdTokenKeysUnavailableError('Google ID token keys are unavailable; the next fetch waits a minute.');
112
+ }
113
+ return undefined;
114
+ }
115
+ load = startFetch(signal);
116
+ }
117
+ try {
118
+ return (await load).get(kid);
119
+ }
120
+ catch (error) {
121
+ // Google rotates its keys rarely: a key it published before still
122
+ // verifies through an outage, within the stale grace.
123
+ const stale = heldKey(kid);
124
+ if (stale !== undefined) {
125
+ return stale;
126
+ }
127
+ throw error;
128
+ }
129
+ },
130
+ };
131
+ }
132
+ async function loadGoogleIdTokenKeys(requestFetch, jwksUrl, signal) {
133
+ let response;
134
+ try {
135
+ response = await requestFetch(jwksUrl, {
136
+ method: 'GET',
137
+ // Workers reject redirect: 'error'; a manual 3xx/opaque redirect is never ok and fails below.
138
+ redirect: 'manual',
139
+ signal: AbortSignal.any([signal, AbortSignal.timeout(jwksTimeoutMs)]),
140
+ });
141
+ }
142
+ catch {
143
+ throw new GoogleIdTokenKeysUnavailableError('Google ID token keys could not be fetched.');
144
+ }
145
+ if (!response.ok || response.redirected) {
146
+ await response.body?.cancel().catch(() => undefined);
147
+ throw new GoogleIdTokenKeysUnavailableError(`Google ID token keys returned HTTP ${response.status}.`);
148
+ }
149
+ let body;
150
+ try {
151
+ body = JSON.parse(await readBoundedText(response, jwksMaxBytes));
152
+ }
153
+ catch {
154
+ throw new GoogleIdTokenKeysUnavailableError('Google ID token keys are not readable JSON.');
155
+ }
156
+ const entries = isRecord(body) && Array.isArray(body.keys) ? body.keys : undefined;
157
+ if (entries === undefined) {
158
+ throw new GoogleIdTokenKeysUnavailableError('Google ID token keys have an invalid shape.');
159
+ }
160
+ const keys = new Map();
161
+ for (const entry of entries) {
162
+ // Only RSA signing keys for RS256; anything else is skipped, never trusted.
163
+ if (isRecord(entry)
164
+ && entry.kty === 'RSA'
165
+ && typeof entry.kid === 'string'
166
+ && typeof entry.n === 'string'
167
+ && typeof entry.e === 'string'
168
+ && (entry.use === undefined || entry.use === 'sig')
169
+ && (entry.alg === undefined || entry.alg === 'RS256')) {
170
+ keys.set(entry.kid, { kty: 'RSA', n: entry.n, e: entry.e, alg: 'RS256', ext: true });
171
+ }
172
+ }
173
+ if (keys.size === 0) {
174
+ throw new GoogleIdTokenKeysUnavailableError('Google ID token keys hold no RS256 key.');
175
+ }
176
+ return { keys, maxAgeMs: readMaxAgeMs(response.headers.get('cache-control')) };
177
+ }
178
+ function readMaxAgeMs(cacheControl) {
179
+ const match = cacheControl === null
180
+ ? null
181
+ : /(?:^|[,\s])max-age=(\d{1,9})(?:$|[,\s])/iu.exec(cacheControl);
182
+ const seconds = match?.[1] === undefined ? undefined : Number(match[1]);
183
+ return seconds === undefined
184
+ ? jwksDefaultCacheMs
185
+ : Math.min(Math.max(seconds * 1000, jwksMinCacheMs), jwksMaxCacheMs);
186
+ }
187
+ const maxTokenLength = 8 * 1024;
188
+ /**
189
+ * Verifies the OIDC token a Pub/Sub push subscription with authentication
190
+ * sends as `Authorization: Bearer <JWT>`: an RS256 signature by one of
191
+ * Google's published keys, a Google issuer, the subscription's audience, its
192
+ * service account (`email`, `email_verified`), and `exp`/`iat`. Throws
193
+ * `GoogleIdTokenKeysUnavailableError` when Google's keys cannot be read.
194
+ */
195
+ export async function verifyGooglePubSubPushToken(input) {
196
+ const audience = input.audience.trim();
197
+ const serviceAccountEmail = input.serviceAccountEmail.trim().toLowerCase();
198
+ if (audience.length === 0 || serviceAccountEmail.length === 0) {
199
+ throw new TypeError('The push audience and service account email are required.');
200
+ }
201
+ const skewMs = (input.clockSkewSeconds ?? 60) * 1000;
202
+ if (!Number.isFinite(skewMs) || skewMs < 0 || skewMs > 300_000) {
203
+ throw new RangeError('clockSkewSeconds must be from 0 to 300.');
204
+ }
205
+ if (input.authorization === null || input.authorization === undefined) {
206
+ return rejectedToken('GOOGLE_PUBSUB_PUSH_TOKEN_MISSING');
207
+ }
208
+ const match = input.authorization.length > maxTokenLength
209
+ ? null
210
+ : /^Bearer ([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)$/u.exec(input.authorization.trim());
211
+ if (match === null) {
212
+ return rejectedToken('GOOGLE_PUBSUB_PUSH_TOKEN_MALFORMED');
213
+ }
214
+ const [, encodedHeader = '', encodedClaims = '', encodedSignature = ''] = match;
215
+ const header = readJsonSegment(encodedHeader);
216
+ const claims = readJsonSegment(encodedClaims);
217
+ const signature = decodeBase64Url(encodedSignature);
218
+ if (header === undefined || claims === undefined || signature === undefined) {
219
+ return rejectedToken('GOOGLE_PUBSUB_PUSH_TOKEN_MALFORMED');
220
+ }
221
+ if (header.alg !== 'RS256' || typeof header.kid !== 'string' || header.kid.length === 0) {
222
+ return rejectedToken('GOOGLE_PUBSUB_PUSH_TOKEN_ALGORITHM');
223
+ }
224
+ const jwk = await input.keys.getSigningKey({ kid: header.kid, signal: input.signal });
225
+ if (jwk === undefined) {
226
+ return rejectedToken('GOOGLE_PUBSUB_PUSH_TOKEN_KEY_UNKNOWN');
227
+ }
228
+ let valid;
229
+ try {
230
+ const key = await crypto.subtle.importKey('jwk', jwk, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify']);
231
+ valid = await crypto.subtle.verify('RSASSA-PKCS1-v1_5', key, signature, new TextEncoder().encode(`${encodedHeader}.${encodedClaims}`));
232
+ }
233
+ catch {
234
+ valid = false;
235
+ }
236
+ if (!valid) {
237
+ return rejectedToken('GOOGLE_PUBSUB_PUSH_TOKEN_SIGNATURE_INVALID');
238
+ }
239
+ // Signed by Google: the claims can be trusted from here on.
240
+ if (typeof claims.iss !== 'string' || !googleIdTokenIssuers.includes(claims.iss)) {
241
+ return rejectedToken('GOOGLE_PUBSUB_PUSH_TOKEN_ISSUER');
242
+ }
243
+ const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
244
+ if (!audiences.includes(audience)) {
245
+ return rejectedToken('GOOGLE_PUBSUB_PUSH_TOKEN_AUDIENCE');
246
+ }
247
+ // Signature and audience do not pin the sender: any Google service account
248
+ // can mint a token for any audience. The email does.
249
+ if (typeof claims.email !== 'string' || claims.email.toLowerCase() !== serviceAccountEmail) {
250
+ return rejectedToken('GOOGLE_PUBSUB_PUSH_TOKEN_EMAIL');
251
+ }
252
+ if (claims.email_verified !== true && claims.email_verified !== 'true') {
253
+ return rejectedToken('GOOGLE_PUBSUB_PUSH_TOKEN_EMAIL_UNVERIFIED');
254
+ }
255
+ const nowMs = (input.now ?? Date.now)();
256
+ if (typeof claims.exp !== 'number' || !Number.isFinite(claims.exp) || claims.exp * 1000 + skewMs <= nowMs) {
257
+ return rejectedToken('GOOGLE_PUBSUB_PUSH_TOKEN_EXPIRED');
258
+ }
259
+ if (typeof claims.iat !== 'number' || !Number.isFinite(claims.iat) || claims.iat * 1000 - skewMs > nowMs) {
260
+ return rejectedToken('GOOGLE_PUBSUB_PUSH_TOKEN_NOT_YET_VALID');
261
+ }
262
+ return { status: 'verified' };
263
+ }
264
+ function rejectedToken(reason) {
265
+ return { status: 'rejected', reason };
266
+ }
267
+ /** `voidedPurchaseNotification.productType` (RTDN reference). */
268
+ export const googlePlayRtdnProductType = Object.freeze({
269
+ subscription: 1,
270
+ oneTime: 2,
271
+ });
272
+ /** `voidedPurchaseNotification.refundType` (RTDN reference). */
273
+ export const googlePlayRtdnRefundType = Object.freeze({
274
+ fullRefund: 1,
275
+ quantityBasedPartialRefund: 2,
276
+ });
277
+ /**
278
+ * Decodes a Pub/Sub push body into the `DeveloperNotification` it carries.
279
+ * Both push formats are accepted: the default wrapped body (`{ message:
280
+ * { data, messageId }, subscription }`, the notification base64 in `data`)
281
+ * and an unwrapped one (payload unwrapping on: the body is the notification
282
+ * JSON itself, with a top-level `packageName` and no `message`). An
283
+ * unwrapped push carries its message id only in the
284
+ * `x-goog-pubsub-message-id` header when "write metadata" is on; pass it as
285
+ * `messageId`.
286
+ */
287
+ export function decodeGooglePlayDeveloperNotificationPush(body, options = {}) {
288
+ let notification;
289
+ let messageId;
290
+ if (isRecord(body) && isRecord(body.message)) {
291
+ const message = body.message;
292
+ messageId = readMessageId(message.messageId) ?? readMessageId(message.message_id);
293
+ const data = typeof message.data === 'string' ? decodeBase64(message.data) : undefined;
294
+ try {
295
+ notification = data === undefined
296
+ ? undefined
297
+ : JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(data));
298
+ }
299
+ catch {
300
+ notification = undefined;
301
+ }
302
+ }
303
+ else if (isRecord(body) && body.message === undefined && typeof body.packageName === 'string') {
304
+ notification = body;
305
+ messageId = readMessageId(options.messageId);
306
+ }
307
+ else {
308
+ const headerId = readMessageId(options.messageId);
309
+ return {
310
+ status: 'rejected',
311
+ reason: 'GOOGLE_PLAY_RTDN_ENVELOPE_INVALID',
312
+ ...(headerId === undefined ? {} : { messageId: headerId }),
313
+ };
314
+ }
315
+ const withId = messageId === undefined ? {} : { messageId };
316
+ if (!isRecord(notification) || typeof notification.packageName !== 'string') {
317
+ return { status: 'rejected', reason: 'GOOGLE_PLAY_RTDN_NOTIFICATION_INVALID', ...withId };
318
+ }
319
+ const eventMillis = readInteger(notification.eventTimeMillis, 16);
320
+ // A time Date can represent, after the epoch; anything else is dropped.
321
+ const eventTimeMillis = eventMillis !== undefined && eventMillis > 0
322
+ && eventMillis <= 8_640_000_000_000_000
323
+ ? eventMillis
324
+ : undefined;
325
+ return {
326
+ status: 'decoded',
327
+ ...withId,
328
+ notification: {
329
+ ...withoutEventTime(notification),
330
+ packageName: notification.packageName,
331
+ ...(eventTimeMillis === undefined ? {} : { eventTimeMillis }),
332
+ },
333
+ };
334
+ }
335
+ /** Default of `unmatchedVoidRetryMs`. */
336
+ export const defaultUnmatchedVoidRetryMs = 60 * 60 * 1000;
337
+ /** Upper bound of `unmatchedVoidRetryMs`. */
338
+ export const maxUnmatchedVoidRetryMs = 6 * 60 * 60 * 1000;
339
+ /**
340
+ * The lookup window around the notification's event time: this much before
341
+ * it (Google's own clocks), and up to `maxUnmatchedVoidRetryMs` after it
342
+ * (Google lists a void a little after it records it), never past now.
343
+ */
344
+ export const googlePlayRtdnLookupLeadMs = 15 * 60 * 1000;
345
+ /** Voided Purchases API pages one notification reads at most (a page holds up to 1,000 voids). */
346
+ export const googlePlayRtdnLookupMaxPages = 2;
347
+ /**
348
+ * Applies one RTDN push to the ledger. A `voidedPurchaseNotification` of a
349
+ * one-time product with a full refund is confirmed with the Voided Purchases
350
+ * API (one window around the event time, matched on the purchase token) and
351
+ * the listed entry is revoked through `revokeGooglePlayVoidedPurchase`: the
352
+ * sweep's path, idempotency key, reason, source, and void time. A push alone
353
+ * never revokes, so a message published to the topic by anyone but Google
354
+ * cannot reverse a grant Google did not void. A void not listed yet, or
355
+ * whose grant this ledger does not hold, is `retry` while recent
356
+ * (`unmatchedVoidRetryMs`), `ignored` after; a lookup failure is `retry`. A
357
+ * quantity-based partial refund is ignored without a lookup, as the sweep
358
+ * (which lists full refunds only) ignores it. Test, subscription, one-time
359
+ * product purchase, and every other kind are `ignored`: the kit sells no
360
+ * Play subscriptions, and a purchase is granted only when the game verifies
361
+ * it. A thrown error (a store outage) is not an outcome: answer 5xx so
362
+ * Pub/Sub redelivers.
363
+ */
364
+ export async function handleGooglePlayDeveloperNotification(input) {
365
+ const unmatchedVoidRetryMs = input.unmatchedVoidRetryMs ?? defaultUnmatchedVoidRetryMs;
366
+ if (!Number.isSafeInteger(unmatchedVoidRetryMs)
367
+ || unmatchedVoidRetryMs < 0
368
+ || unmatchedVoidRetryMs > maxUnmatchedVoidRetryMs) {
369
+ throw new RangeError(`unmatchedVoidRetryMs must be an integer from 0 to ${maxUnmatchedVoidRetryMs} ms.`);
370
+ }
371
+ const packageName = input.packageName.trim();
372
+ if (packageName.length === 0) {
373
+ throw new TypeError('packageName is required.');
374
+ }
375
+ const decoded = decodeGooglePlayDeveloperNotificationPush(input.body, input.messageId === undefined ? {} : { messageId: input.messageId });
376
+ if (decoded.status === 'rejected') {
377
+ return decoded;
378
+ }
379
+ const { notification } = decoded;
380
+ const withId = decoded.messageId === undefined ? {} : { messageId: decoded.messageId };
381
+ if (notification.packageName !== packageName) {
382
+ return { status: 'ignored', reason: 'GOOGLE_PLAY_RTDN_PACKAGE_MISMATCH', ...withId };
383
+ }
384
+ const voided = notification.voidedPurchaseNotification;
385
+ if (!isRecord(voided)) {
386
+ return { status: 'ignored', reason: ignoredKindReason(notification), ...withId };
387
+ }
388
+ // The push is unconfirmed: nothing from its body (the order id included)
389
+ // reaches an outcome, a log, or the ledger. The order id an outcome names
390
+ // is the listed entry's.
391
+ const purchaseToken = typeof voided.purchaseToken === 'string' ? voided.purchaseToken : '';
392
+ if (purchaseToken.length === 0) {
393
+ return { status: 'rejected', reason: 'GOOGLE_PLAY_RTDN_PURCHASE_TOKEN_MISSING', ...withId };
394
+ }
395
+ // Absent fields keep their documented default; a present value that does
396
+ // not parse is unknown, never read as the default.
397
+ const productType = voided.productType === undefined
398
+ ? undefined
399
+ : (readInteger(voided.productType, 3) ?? -1);
400
+ if (productType === googlePlayRtdnProductType.subscription) {
401
+ return { status: 'ignored', reason: 'GOOGLE_PLAY_RTDN_SUBSCRIPTION_VOIDED', ...withId };
402
+ }
403
+ if (productType !== undefined && productType !== googlePlayRtdnProductType.oneTime) {
404
+ return { status: 'ignored', reason: 'GOOGLE_PLAY_RTDN_PRODUCT_TYPE_UNKNOWN', ...withId };
405
+ }
406
+ // Absent: a notification from before partial refunds, which voided the whole purchase.
407
+ const refundType = voided.refundType === undefined
408
+ ? googlePlayRtdnRefundType.fullRefund
409
+ : (readInteger(voided.refundType, 3) ?? -1);
410
+ if (refundType !== googlePlayRtdnRefundType.fullRefund) {
411
+ return {
412
+ status: 'ignored',
413
+ reason: refundType === googlePlayRtdnRefundType.quantityBasedPartialRefund
414
+ ? 'GOOGLE_PLAY_RTDN_PARTIAL_REFUND'
415
+ : 'GOOGLE_PLAY_RTDN_REFUND_TYPE_UNKNOWN',
416
+ ...withId,
417
+ };
418
+ }
419
+ const now = input.now ?? (() => new Date().toISOString());
420
+ const nowMillis = Date.parse(now());
421
+ if (!Number.isFinite(nowMillis)) {
422
+ throw new RangeError('now() must return an ISO timestamp.');
423
+ }
424
+ const eventTimeMillis = notification.eventTimeMillis;
425
+ // Both ways, so a little clock skew still retries and a far-future time
426
+ // cannot keep the message unacknowledged. Without an event time there is
427
+ // no bound: acknowledge, the sweep reads the void.
428
+ const recent = eventTimeMillis !== undefined
429
+ && Math.abs(nowMillis - eventTimeMillis) < unmatchedVoidRetryMs;
430
+ const unmatched = (reason, confirmed = withId) => recent
431
+ ? { status: 'retry', reason, ...confirmed }
432
+ : { status: 'ignored', reason, ...confirmed };
433
+ let listed;
434
+ try {
435
+ listed = await findVoidedPurchase({
436
+ client: input.voidedPurchases,
437
+ packageName,
438
+ purchaseToken,
439
+ anchorMillis: eventTimeMillis ?? nowMillis,
440
+ nowMillis,
441
+ signal: input.signal,
442
+ });
443
+ }
444
+ catch {
445
+ // The client's errors carry no token or URL; the host logs the outcome.
446
+ return { status: 'retry', reason: 'GOOGLE_PLAY_RTDN_VOIDED_LOOKUP_FAILED', ...withId };
447
+ }
448
+ if (listed === undefined) {
449
+ return unmatched('GOOGLE_PLAY_RTDN_VOID_NOT_LISTED');
450
+ }
451
+ const withOrder = {
452
+ ...withId,
453
+ ...(typeof listed.orderId === 'string' && listed.orderId.length > 0
454
+ ? { orderId: listed.orderId }
455
+ : {}),
456
+ };
457
+ const outcome = await revokeGooglePlayVoidedPurchase({
458
+ store: input.store,
459
+ packageName,
460
+ voided: listed,
461
+ now: () => new Date(nowMillis).toISOString(),
462
+ });
463
+ if (outcome.status === 'revoked') {
464
+ return {
465
+ status: 'revoked',
466
+ ...withOrder,
467
+ ledgerEntryId: outcome.ledgerEntryId,
468
+ alreadyProcessed: outcome.alreadyProcessed,
469
+ };
470
+ }
471
+ if (outcome.status === 'grant_not_found') {
472
+ return unmatched('GOOGLE_PLAY_RTDN_GRANT_NOT_FOUND', withOrder);
473
+ }
474
+ if (outcome.status === 'invalid') {
475
+ return { status: 'rejected', reason: outcome.reason, ...withId };
476
+ }
477
+ return { status: 'conflict', reason: outcome.reason, ...withOrder };
478
+ }
479
+ /**
480
+ * The listed void of this token in a window around the notification, or
481
+ * undefined when Google does not list it (yet). One query per page, at most
482
+ * `googlePlayRtdnLookupMaxPages`; Google allows 6,000 a day and 30 in any 30
483
+ * seconds per package, shared with the sweep.
484
+ */
485
+ async function findVoidedPurchase(input) {
486
+ const oldest = input.nowMillis - googlePlayVoidedPurchasesWindowMs
487
+ + googlePlayVoidedPurchasesWindowSlackMs;
488
+ const anchor = Math.min(Math.max(input.anchorMillis, oldest), input.nowMillis);
489
+ const startTimeMillis = Math.max(anchor - googlePlayRtdnLookupLeadMs, oldest);
490
+ const endTimeMillis = Math.max(Math.min(anchor + maxUnmatchedVoidRetryMs, input.nowMillis), startTimeMillis + 1);
491
+ let pageToken;
492
+ for (let page = 0; page < googlePlayRtdnLookupMaxPages; page += 1) {
493
+ const result = await input.client.listVoidedPurchases({
494
+ packageName: input.packageName,
495
+ startTimeMillis,
496
+ endTimeMillis,
497
+ ...(pageToken === undefined ? {} : { pageToken }),
498
+ signal: input.signal,
499
+ });
500
+ const match = result.voidedPurchases.find((entry) => entry.purchaseToken === input.purchaseToken);
501
+ if (match !== undefined) {
502
+ return match;
503
+ }
504
+ pageToken = result.nextPageToken === undefined || result.nextPageToken.length === 0
505
+ ? undefined
506
+ : result.nextPageToken;
507
+ if (pageToken === undefined) {
508
+ return undefined;
509
+ }
510
+ }
511
+ // More voids in the window than the pages read: not found here; the
512
+ // retry or the sweep reads it.
513
+ return undefined;
514
+ }
515
+ /** The HTTP status to answer Pub/Sub with: 200 acknowledges, 503 asks for a redelivery. */
516
+ export function googlePlayDeveloperNotificationHttpStatus(outcome) {
517
+ return outcome.status === 'retry' ? 503 : 200;
518
+ }
519
+ function ignoredKindReason(notification) {
520
+ if (isRecord(notification.testNotification)) {
521
+ return 'GOOGLE_PLAY_RTDN_TEST';
522
+ }
523
+ if (isRecord(notification.subscriptionNotification)) {
524
+ return 'GOOGLE_PLAY_RTDN_SUBSCRIPTION';
525
+ }
526
+ if (isRecord(notification.oneTimeProductNotification)) {
527
+ return 'GOOGLE_PLAY_RTDN_ONE_TIME_PRODUCT';
528
+ }
529
+ return 'GOOGLE_PLAY_RTDN_KIND_UNHANDLED';
530
+ }
531
+ function readMessageId(input) {
532
+ return typeof input === 'string' && input.length > 0 && input.length <= 128
533
+ && /^[\x21-\x7e]+$/u.test(input)
534
+ ? input
535
+ : undefined;
536
+ }
537
+ function withoutEventTime(notification) {
538
+ const { eventTimeMillis: _eventTimeMillis, ...rest } = notification;
539
+ return rest;
540
+ }
541
+ function readInteger(input, maxDigits) {
542
+ const value = typeof input === 'string' && new RegExp(`^[0-9]{1,${maxDigits}}$`, 'u').test(input)
543
+ ? Number(input)
544
+ : input;
545
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
546
+ }
547
+ function readJsonSegment(segment) {
548
+ const bytes = decodeBase64Url(segment);
549
+ if (bytes === undefined) {
550
+ return undefined;
551
+ }
552
+ try {
553
+ const value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
554
+ return isRecord(value) ? value : undefined;
555
+ }
556
+ catch {
557
+ return undefined;
558
+ }
559
+ }
560
+ async function readBoundedText(response, limit) {
561
+ const declared = Number(response.headers.get('content-length'));
562
+ if (Number.isFinite(declared) && declared > limit) {
563
+ await response.body?.cancel().catch(() => undefined);
564
+ throw new Error('Response exceeds the size limit.');
565
+ }
566
+ if (response.body === null) {
567
+ return '';
568
+ }
569
+ const reader = response.body.getReader();
570
+ const chunks = [];
571
+ let size = 0;
572
+ for (;;) {
573
+ const next = await reader.read();
574
+ if (next.done) {
575
+ break;
576
+ }
577
+ size += next.value.byteLength;
578
+ if (size > limit) {
579
+ await reader.cancel().catch(() => undefined);
580
+ throw new Error('Response exceeds the size limit.');
581
+ }
582
+ chunks.push(next.value);
583
+ }
584
+ const bytes = new Uint8Array(size);
585
+ let offset = 0;
586
+ for (const chunk of chunks) {
587
+ bytes.set(chunk, offset);
588
+ offset += chunk.byteLength;
589
+ }
590
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
591
+ }
592
+ function isRecord(value) {
593
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
594
+ }
package/dist/index.d.ts CHANGED
@@ -20,6 +20,7 @@ export * from './evidence-verification.js';
20
20
  export * from './guest-session.js';
21
21
  export * from './google-play-purchase.js';
22
22
  export * from './google-play-publisher-client.js';
23
+ export * from './google-play-rtdn.js';
23
24
  export * from './google-play-store-server.js';
24
25
  export * from './microsoft-store-identity.js';
25
26
  export * from './microsoft-store-purchase.js';
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ export * from './evidence-verification.js';
20
20
  export * from './guest-session.js';
21
21
  export * from './google-play-purchase.js';
22
22
  export * from './google-play-publisher-client.js';
23
+ export * from './google-play-rtdn.js';
23
24
  export * from './google-play-store-server.js';
24
25
  export * from './microsoft-store-identity.js';
25
26
  export * from './microsoft-store-purchase.js';
@@ -96,18 +96,36 @@ export interface CreateGameServicesNotificationsClientInput {
96
96
  }
97
97
  /** The default `timeoutMs`: a request still unanswered after this is aborted. */
98
98
  export declare const defaultGameServicesNotificationsTimeoutMs = 15000;
99
+ /** A provider entry of the registry this client could not read, and why. */
100
+ export interface GameServicesNotificationUnreadableProvider {
101
+ /** The entry's `provider` id, when the entry has a readable non-empty one. */
102
+ readonly provider?: string;
103
+ /** Why the entry was left out (the validator's text, at most 512 characters). */
104
+ readonly issue: string;
105
+ }
99
106
  /** The registry the Worker publishes at `GET /providers`. */
100
107
  export interface GameServicesNotificationProviders {
101
108
  readonly protocolVersion: string;
109
+ /** The provider entries this client can read. */
102
110
  readonly providers: readonly NotificationDeliveryProviderEntry[];
103
111
  /** Each served target and the provider id that delivers to it. */
104
112
  readonly targets: Readonly<Record<string, string>>;
113
+ /**
114
+ * The provider entries left out of `providers` because this client cannot
115
+ * read them, in registry order; absent when there are none.
116
+ */
117
+ readonly unreadable?: readonly GameServicesNotificationUnreadableProvider[];
105
118
  }
106
119
  export interface GameServicesNotificationsClient extends NotificationRuntimeBackend {
107
120
  /**
108
121
  * The registry the Worker publishes (`GET /providers`, no session needed):
109
122
  * pass `providers.find((entry) => entry.provider === provider.provider)` as
110
- * the runtime's `registryEntry`.
123
+ * the runtime's `registryEntry`. A provider entry this client cannot read
124
+ * (malformed, or shaped by a Worker this client does not know) is left out
125
+ * of `providers`, so the other entries still load, and listed in
126
+ * `unreadable` with its id when readable. A target whose provider is
127
+ * listed there (or any unreadable entry without an id) should be reported,
128
+ * not read as a target the Worker does not serve.
111
129
  */
112
130
  getProviders(): Promise<GameServicesNotificationProviders>;
113
131
  }