@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.
- package/dist/admob-ssv.js +6 -26
- package/dist/app-store-verifier-conformance.d.ts +37 -1
- package/dist/app-store-verifier-conformance.js +199 -1
- package/dist/app-store-verifier.d.ts +57 -4
- package/dist/app-store-verifier.js +106 -4
- package/dist/base64.d.ts +10 -0
- package/dist/base64.js +35 -0
- package/dist/google-play-publisher-client.js +24 -1
- package/dist/google-play-rtdn.d.ts +248 -0
- package/dist/google-play-rtdn.js +594 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/notifications/client.d.ts +19 -1
- package/dist/notifications/client.js +24 -2
- package/dist/notifications/text.d.ts +5 -1
- package/dist/notifications/text.js +11 -2
- package/package.json +9 -5
|
@@ -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
|
}
|