@mpgd/game-services 0.28.1 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -3
- package/dist/account-deletion.d.ts +306 -0
- package/dist/account-deletion.js +491 -0
- package/dist/app-store-recovery.d.ts +6 -0
- package/dist/app-store-recovery.js +26 -16
- package/dist/app-store-server-notifications.d.ts +158 -0
- package/dist/app-store-server-notifications.js +270 -0
- package/dist/app-store-server.d.ts +8 -0
- package/dist/app-store-server.js +135 -37
- package/dist/app-store-verifier.d.ts +34 -0
- package/dist/app-store-verifier.js +161 -31
- package/dist/commerce-ledger.d.ts +55 -0
- package/dist/commerce-ledger.js +198 -0
- package/dist/commerce-reconciler.js +47 -7
- package/dist/evidence-verification.d.ts +19 -0
- package/dist/google-play-publisher-client.d.ts +2 -1
- package/dist/google-play-publisher-client.js +80 -0
- package/dist/google-play-purchase.d.ts +7 -0
- package/dist/google-play-purchase.js +32 -7
- package/dist/google-play-store-server.d.ts +160 -0
- package/dist/google-play-store-server.js +297 -0
- package/dist/guest-session.d.ts +112 -2
- package/dist/guest-session.js +196 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/notifications/delivery-provider.d.ts +7 -0
- package/dist/notifications/fcm.d.ts +30 -3
- package/dist/notifications/fcm.js +72 -13
- package/dist/notifications/service.js +33 -5
- package/dist/notifications/store-conformance.js +7 -1
- package/dist/notifications/store.d.ts +9 -0
- package/dist/notifications/store.js +41 -0
- package/dist/operation-results.d.ts +7 -0
- package/dist/purchase-sharing.d.ts +41 -0
- package/dist/purchase-sharing.js +37 -0
- package/dist/runtime.d.ts +6 -0
- package/dist/runtime.js +13 -0
- package/dist/server.d.ts +60 -1
- package/dist/server.js +536 -42
- package/dist/types.d.ts +30 -0
- package/dist/types.js +22 -0
- package/dist/verified-leaderboard.d.ts +13 -0
- package/dist/verified-leaderboard.js +31 -0
- package/package.json +17 -5
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { createGooglePlayTokenPurchaseKey } from '@mpgd/platform/commerce';
|
|
2
|
+
import { findPurchaseGrantByEvidenceVerificationId, isEntitlementGrantReversed, readPurchaseVerificationId, revokeEntitlementGrant, } from './commerce-ledger.js';
|
|
3
|
+
import { createGooglePlayProductVerificationId, googlePlayProductPurchaseEvidenceSchema, } from './google-play-purchase.js';
|
|
4
|
+
/** Google keeps voided purchases for 30 days. */
|
|
5
|
+
export const googlePlayVoidedPurchasesWindowMs = 30 * 24 * 60 * 60 * 1000;
|
|
6
|
+
/**
|
|
7
|
+
* Kept off the oldest edge of that window: Google refuses a `startTime` older
|
|
8
|
+
* than 30 days at the time it receives the request, which is a little after
|
|
9
|
+
* the caller's clock read.
|
|
10
|
+
*/
|
|
11
|
+
export const googlePlayVoidedPurchasesWindowSlackMs = 60_000;
|
|
12
|
+
/**
|
|
13
|
+
* Appends the `revoke` entry for one voided purchase. The grant is found by
|
|
14
|
+
* the purchase token's verification id, so the revocation is keyed by the
|
|
15
|
+
* token: a purchase seen again by a later sweep (or an RTDN message) answers
|
|
16
|
+
* the first entry with `alreadyProcessed`.
|
|
17
|
+
*/
|
|
18
|
+
export async function revokeGooglePlayVoidedPurchase(input) {
|
|
19
|
+
const { voided } = input;
|
|
20
|
+
if (typeof voided?.purchaseToken !== 'string' || voided.purchaseToken.length === 0) {
|
|
21
|
+
return { status: 'invalid', reason: 'GOOGLE_PLAY_VOIDED_PURCHASE_TOKEN_MISSING' };
|
|
22
|
+
}
|
|
23
|
+
const verificationId = await createGooglePlayProductVerificationId(input.packageName, voided.purchaseToken);
|
|
24
|
+
const grant = await findPurchaseGrantByEvidenceVerificationId(input.store, verificationId);
|
|
25
|
+
if (grant === undefined || grant.payload.target !== 'android') {
|
|
26
|
+
return { status: 'grant_not_found' };
|
|
27
|
+
}
|
|
28
|
+
const voidedAt = readMillis(voided.voidedTimeMillis);
|
|
29
|
+
const voidedReason = readCode(voided.voidedReason);
|
|
30
|
+
const voidedSource = readCode(voided.voidedSource);
|
|
31
|
+
const result = await revokeEntitlementGrant(input.store, {
|
|
32
|
+
playerId: grant.playerId,
|
|
33
|
+
revokesLedgerEntryId: grant.ledgerEntryId,
|
|
34
|
+
idempotencyKey: `google-play:voided:${verificationId}`,
|
|
35
|
+
reason: revocationReasonOf(voidedReason),
|
|
36
|
+
revokedAt: voidedAt === undefined
|
|
37
|
+
? (input.now ?? (() => new Date().toISOString()))()
|
|
38
|
+
: new Date(voidedAt).toISOString(),
|
|
39
|
+
evidenceVerificationId: `${verificationId}:revoke`,
|
|
40
|
+
payload: {
|
|
41
|
+
target: 'android',
|
|
42
|
+
store: 'google-play',
|
|
43
|
+
googlePlayPackageName: input.packageName,
|
|
44
|
+
...(voided.orderId === undefined ? {} : { googlePlayOrderId: voided.orderId }),
|
|
45
|
+
...(voidedReason === undefined ? {} : { googlePlayVoidedReason: voidedReason }),
|
|
46
|
+
...(voidedSource === undefined ? {} : { googlePlayVoidedSource: voidedSource }),
|
|
47
|
+
...(voidedAt === undefined ? {} : { googlePlayVoidedTimeMillis: voidedAt }),
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
if (result.status === 'revoked') {
|
|
51
|
+
return {
|
|
52
|
+
status: 'revoked',
|
|
53
|
+
ledgerEntryId: result.ledgerEntryId,
|
|
54
|
+
alreadyProcessed: result.alreadyProcessed,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (result.status === 'grant_not_found') {
|
|
58
|
+
return { status: 'grant_not_found' };
|
|
59
|
+
}
|
|
60
|
+
return { status: 'conflict', reason: `GOOGLE_PLAY_VOIDED_${result.status.toUpperCase()}` };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Reads the voided purchases of one time window and revokes their grants.
|
|
64
|
+
* The sweep is the reliable path even next to RTDN, because a Pub/Sub push
|
|
65
|
+
* can be missed: run it on a schedule with a window that overlaps the last
|
|
66
|
+
* complete one (revocations are idempotent per token). An incomplete sweep
|
|
67
|
+
* (page budget, `shouldContinue`) must be repeated from the same start.
|
|
68
|
+
*/
|
|
69
|
+
export async function sweepGooglePlayVoidedPurchases(input) {
|
|
70
|
+
const maxPages = input.maxPages ?? 10;
|
|
71
|
+
if (!Number.isSafeInteger(maxPages) || maxPages < 1) {
|
|
72
|
+
throw new RangeError('maxPages must be a positive integer.');
|
|
73
|
+
}
|
|
74
|
+
const requestedStart = input.startTimeMillis;
|
|
75
|
+
const endTimeMillis = input.endTimeMillis;
|
|
76
|
+
const nowMillis = input.now === undefined ? Date.now() : Date.parse(input.now());
|
|
77
|
+
if (!Number.isSafeInteger(requestedStart) || !Number.isSafeInteger(endTimeMillis)) {
|
|
78
|
+
throw new RangeError('The voided purchases window must be integer milliseconds.');
|
|
79
|
+
}
|
|
80
|
+
if (!Number.isFinite(nowMillis)) {
|
|
81
|
+
throw new RangeError('now() must return an ISO timestamp.');
|
|
82
|
+
}
|
|
83
|
+
const oldest = nowMillis - googlePlayVoidedPurchasesWindowMs + googlePlayVoidedPurchasesWindowSlackMs;
|
|
84
|
+
const startTimeMillis = Math.max(requestedStart, oldest);
|
|
85
|
+
if (startTimeMillis > endTimeMillis) {
|
|
86
|
+
throw new RangeError('The voided purchases window is empty or invalid.');
|
|
87
|
+
}
|
|
88
|
+
const clamped = startTimeMillis > requestedStart;
|
|
89
|
+
const shouldContinue = input.shouldContinue ?? (() => true);
|
|
90
|
+
const totals = {
|
|
91
|
+
pages: 0,
|
|
92
|
+
revoked: 0,
|
|
93
|
+
alreadyProcessed: 0,
|
|
94
|
+
notFound: 0,
|
|
95
|
+
failed: 0,
|
|
96
|
+
errored: 0,
|
|
97
|
+
};
|
|
98
|
+
let pageToken;
|
|
99
|
+
do {
|
|
100
|
+
if (totals.pages >= maxPages || !shouldContinue('page')) {
|
|
101
|
+
return { complete: false, clamped, ...totals };
|
|
102
|
+
}
|
|
103
|
+
const page = await input.client.listVoidedPurchases({
|
|
104
|
+
packageName: input.packageName,
|
|
105
|
+
startTimeMillis,
|
|
106
|
+
endTimeMillis,
|
|
107
|
+
...(pageToken === undefined ? {} : { pageToken }),
|
|
108
|
+
signal: input.signal,
|
|
109
|
+
});
|
|
110
|
+
totals.pages += 1;
|
|
111
|
+
totals.failed += page.droppedEntries ?? 0;
|
|
112
|
+
for (const voided of page.voidedPurchases) {
|
|
113
|
+
if (!shouldContinue('purchase')) {
|
|
114
|
+
return { complete: false, clamped, ...totals };
|
|
115
|
+
}
|
|
116
|
+
const { purchaseToken: _token, ...loggable } = voided;
|
|
117
|
+
let outcome;
|
|
118
|
+
try {
|
|
119
|
+
outcome = await revokeGooglePlayVoidedPurchase({
|
|
120
|
+
store: input.store,
|
|
121
|
+
packageName: input.packageName,
|
|
122
|
+
voided,
|
|
123
|
+
...(input.now === undefined ? {} : { now: input.now }),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (input.signal.aborted) {
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
// One entry's store failure does not hide the rest of the window.
|
|
131
|
+
totals.errored += 1;
|
|
132
|
+
input.onOutcome?.(loggable, {
|
|
133
|
+
status: 'error',
|
|
134
|
+
reason: 'GOOGLE_PLAY_VOIDED_STORE_ERROR',
|
|
135
|
+
detail: error instanceof Error
|
|
136
|
+
? `${error.name}: ${error.message}`.slice(0, 200)
|
|
137
|
+
: 'unknown error',
|
|
138
|
+
});
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (outcome.status === 'revoked') {
|
|
142
|
+
totals[outcome.alreadyProcessed ? 'alreadyProcessed' : 'revoked'] += 1;
|
|
143
|
+
}
|
|
144
|
+
else if (outcome.status === 'grant_not_found') {
|
|
145
|
+
totals.notFound += 1;
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
totals.failed += 1;
|
|
149
|
+
}
|
|
150
|
+
input.onOutcome?.(loggable, outcome);
|
|
151
|
+
}
|
|
152
|
+
pageToken = page.nextPageToken === undefined || page.nextPageToken.length === 0
|
|
153
|
+
? undefined
|
|
154
|
+
: page.nextPageToken;
|
|
155
|
+
} while (pageToken !== undefined);
|
|
156
|
+
return { complete: totals.errored === 0, clamped, ...totals };
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Retries the acknowledge or consume of a durable Play grant from the ledger
|
|
160
|
+
* entry and the purchase token the Worker kept for it (the ledger itself
|
|
161
|
+
* stores only the token digest). The finalizer is the same Play boundary the
|
|
162
|
+
* purchase path uses, so the provider state is re-read before acting and an
|
|
163
|
+
* already completed purchase answers `completed`. A grant that was reversed
|
|
164
|
+
* is never finalized: Google is refunding it.
|
|
165
|
+
*/
|
|
166
|
+
export async function retryGooglePlayPurchaseFinalization(input) {
|
|
167
|
+
const grant = await input.store.getEntitlementTransaction(input.ledgerEntryId);
|
|
168
|
+
if (grant === undefined || grant.source !== 'purchase' || grant.payload.target !== 'android') {
|
|
169
|
+
return { status: 'abandoned', reason: 'GOOGLE_PLAY_FINALIZATION_GRANT_NOT_FOUND' };
|
|
170
|
+
}
|
|
171
|
+
if (await isEntitlementGrantReversed(input.store, grant.ledgerEntryId)) {
|
|
172
|
+
return { status: 'abandoned', reason: 'GOOGLE_PLAY_FINALIZATION_GRANT_REVERSED' };
|
|
173
|
+
}
|
|
174
|
+
const payload = grant.payload;
|
|
175
|
+
const productType = readOneTimeProductType(payload.productType);
|
|
176
|
+
const platformProductId = payload.platformProductId;
|
|
177
|
+
// An orderless purchase (Google omitted the order id) was recorded without
|
|
178
|
+
// one; the boundary matches it by token, as the client's token key does.
|
|
179
|
+
const platformTransactionId = typeof payload.platformTransactionId === 'string'
|
|
180
|
+
&& payload.platformTransactionId.length > 0
|
|
181
|
+
? payload.platformTransactionId
|
|
182
|
+
: await createGooglePlayTokenPurchaseKey(input.purchaseToken);
|
|
183
|
+
const purchasedAt = payload.purchasedAt;
|
|
184
|
+
// A grant restore sharing wrote is acknowledged as the purchase it records.
|
|
185
|
+
const evidenceVerificationId = readPurchaseVerificationId(grant);
|
|
186
|
+
if (productType === undefined
|
|
187
|
+
|| typeof platformProductId !== 'string' || platformProductId.length === 0
|
|
188
|
+
|| typeof purchasedAt !== 'string' || purchasedAt.length === 0
|
|
189
|
+
|| typeof evidenceVerificationId !== 'string'
|
|
190
|
+
|| grant.grant === undefined) {
|
|
191
|
+
return { status: 'abandoned', reason: 'GOOGLE_PLAY_FINALIZATION_CONTEXT_UNAVAILABLE' };
|
|
192
|
+
}
|
|
193
|
+
if (evidenceVerificationId
|
|
194
|
+
!== await createGooglePlayProductVerificationId(typeof payload.googlePlayPackageName === 'string' ? payload.googlePlayPackageName : '', input.purchaseToken)) {
|
|
195
|
+
// The kept token is not the one this grant was verified with.
|
|
196
|
+
return { status: 'abandoned', reason: 'GOOGLE_PLAY_FINALIZATION_EVIDENCE_MISMATCH' };
|
|
197
|
+
}
|
|
198
|
+
const deploymentTarget = typeof payload.deploymentTarget === 'string'
|
|
199
|
+
? payload.deploymentTarget
|
|
200
|
+
: undefined;
|
|
201
|
+
const store = typeof payload.store === 'string' ? payload.store : undefined;
|
|
202
|
+
const request = {
|
|
203
|
+
target: 'android',
|
|
204
|
+
...(store === undefined ? {} : { store }),
|
|
205
|
+
...(deploymentTarget === undefined ? {} : { deploymentTarget }),
|
|
206
|
+
playerId: grant.playerId,
|
|
207
|
+
productId: grant.grantId,
|
|
208
|
+
platformTransactionId,
|
|
209
|
+
idempotencyKey: grant.idempotencyKey,
|
|
210
|
+
purchasedAt,
|
|
211
|
+
evidence: {
|
|
212
|
+
schema: googlePlayProductPurchaseEvidenceSchema,
|
|
213
|
+
payload: { purchaseToken: input.purchaseToken },
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
const product = {
|
|
217
|
+
id: grant.grantId,
|
|
218
|
+
type: productType,
|
|
219
|
+
grant: grant.grant,
|
|
220
|
+
platformProductIds: { android: platformProductId },
|
|
221
|
+
};
|
|
222
|
+
const finalizationInput = {
|
|
223
|
+
request,
|
|
224
|
+
product,
|
|
225
|
+
platformProductId,
|
|
226
|
+
evidenceVerificationId,
|
|
227
|
+
evidencePayload: payload,
|
|
228
|
+
ledgerEntryId: grant.ledgerEntryId,
|
|
229
|
+
alreadyProcessed: true,
|
|
230
|
+
timeoutMs: input.timeoutMs,
|
|
231
|
+
};
|
|
232
|
+
if (input.finalizer.supportsPurchaseGrant?.(finalizationInput) === false) {
|
|
233
|
+
return { status: 'abandoned', reason: 'GOOGLE_PLAY_FINALIZATION_UNSUPPORTED' };
|
|
234
|
+
}
|
|
235
|
+
const result = await finalizeWithin(input.finalizer, finalizationInput, input.signal, input.timeoutMs);
|
|
236
|
+
if (result.status === 'pending' && result.reason === 'GOOGLE_PLAY_FINALIZATION_EVIDENCE_MISMATCH') {
|
|
237
|
+
// The boundary is configured for another package than the grant: no
|
|
238
|
+
// retry can succeed.
|
|
239
|
+
return { status: 'abandoned', reason: result.reason };
|
|
240
|
+
}
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Runs the finalizer within `timeoutMs` like the purchase path does: the
|
|
245
|
+
* provider signal is aborted at the deadline and the retry answers pending,
|
|
246
|
+
* even when a provider call ignores the abort.
|
|
247
|
+
*/
|
|
248
|
+
async function finalizeWithin(finalizer, input, signal, timeoutMs) {
|
|
249
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
|
250
|
+
throw new RangeError('timeoutMs must be a positive integer.');
|
|
251
|
+
}
|
|
252
|
+
const controller = new AbortController();
|
|
253
|
+
const abort = () => controller.abort(signal.reason);
|
|
254
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
255
|
+
if (signal.aborted) {
|
|
256
|
+
abort();
|
|
257
|
+
}
|
|
258
|
+
let timer;
|
|
259
|
+
try {
|
|
260
|
+
return await Promise.race([
|
|
261
|
+
finalizer.finalizePurchaseGrant({ ...input, signal: controller.signal }),
|
|
262
|
+
new Promise((resolve) => {
|
|
263
|
+
timer = setTimeout(() => {
|
|
264
|
+
controller.abort();
|
|
265
|
+
resolve({
|
|
266
|
+
status: 'pending',
|
|
267
|
+
alreadyCompleted: false,
|
|
268
|
+
reason: 'GOOGLE_PLAY_FINALIZATION_TIMEOUT',
|
|
269
|
+
});
|
|
270
|
+
}, timeoutMs);
|
|
271
|
+
}),
|
|
272
|
+
]);
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
if (timer !== undefined) {
|
|
276
|
+
clearTimeout(timer);
|
|
277
|
+
}
|
|
278
|
+
signal.removeEventListener('abort', abort);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function readOneTimeProductType(input) {
|
|
282
|
+
return input === 'consumable' || input === 'non_consumable' ? input : undefined;
|
|
283
|
+
}
|
|
284
|
+
function revocationReasonOf(voidedReason) {
|
|
285
|
+
return voidedReason === 7 ? 'chargeback' : 'voided';
|
|
286
|
+
}
|
|
287
|
+
function readMillis(input) {
|
|
288
|
+
const value = typeof input === 'string' && /^[0-9]{1,16}$/u.test(input) ? Number(input) : input;
|
|
289
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0
|
|
290
|
+
&& value <= 8_640_000_000_000_000
|
|
291
|
+
? value
|
|
292
|
+
: undefined;
|
|
293
|
+
}
|
|
294
|
+
function readCode(input) {
|
|
295
|
+
const value = typeof input === 'string' && /^[0-9]{1,3}$/u.test(input) ? Number(input) : input;
|
|
296
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
297
|
+
}
|
package/dist/guest-session.d.ts
CHANGED
|
@@ -75,13 +75,50 @@ export interface GuestSessionBackend {
|
|
|
75
75
|
readonly externalProof: string;
|
|
76
76
|
readonly idempotencyKey: string;
|
|
77
77
|
}): Promise<AccountBindingOutcome>;
|
|
78
|
+
/**
|
|
79
|
+
* A short-lived, single-use account deletion confirmation for the refresh
|
|
80
|
+
* token's player, which the game-services deletion route requires besides
|
|
81
|
+
* the access token (`accountDeletionConfirmationHeader`). It must
|
|
82
|
+
* authenticate the refresh token like `refresh`, without rotating it, and
|
|
83
|
+
* throw `GuestSessionRejectedError` for one refused for good. Optional: a
|
|
84
|
+
* backend without it cannot delete accounts through the starter.
|
|
85
|
+
*/
|
|
86
|
+
requestAccountDeletionConfirmation?(input: {
|
|
87
|
+
readonly refreshToken: string;
|
|
88
|
+
}): Promise<AccountDeletionConfirmationGrant>;
|
|
78
89
|
}
|
|
79
|
-
|
|
90
|
+
/** A deletion confirmation, as the identity service answers it. */
|
|
91
|
+
export interface AccountDeletionConfirmationGrant {
|
|
92
|
+
/** Opaque; sent once, in `accountDeletionConfirmationHeader`. */
|
|
93
|
+
readonly confirmation: string;
|
|
94
|
+
/** When it stops being accepted (ISO 8601). */
|
|
95
|
+
readonly expiresAt: string;
|
|
96
|
+
}
|
|
97
|
+
export type GuestSessionCoordinatorErrorCode = 'GUEST_SESSION_CLOSED' | 'GUEST_SESSION_NOT_READY' | 'GUEST_SESSION_INVALID_INPUT' | 'GUEST_SESSION_INVALID_RESPONSE' | 'GUEST_SESSION_OWNERSHIP_CONFLICT' | 'GUEST_SESSION_BACKEND_FAILED'
|
|
98
|
+
/**
|
|
99
|
+
* The identity service refused the refresh token: revoked (logout,
|
|
100
|
+
* replay), expired after inactivity, or unknown. That session cannot come
|
|
101
|
+
* back: the coordinator stops using it (`current()` is `undefined`, and
|
|
102
|
+
* `start()`, `refresh()` and `getHeaders()` fail with
|
|
103
|
+
* `GUEST_SESSION_NOT_READY` without calling the backend). Log out (which
|
|
104
|
+
* removes the stored token) and start a new coordinator for a new guest.
|
|
105
|
+
*/
|
|
106
|
+
| 'GUEST_SESSION_REJECTED' | 'GUEST_SESSION_CREDENTIAL_LOAD_FAILED' | 'GUEST_SESSION_CREDENTIAL_SAVE_FAILED' | 'GUEST_SESSION_LOGOUT_UNCERTAIN'
|
|
107
|
+
/** The backend has no `requestAccountDeletionConfirmation`. */
|
|
108
|
+
| 'GUEST_SESSION_UNSUPPORTED';
|
|
80
109
|
/** Error messages intentionally contain no token, proof, or installation ID. */
|
|
81
110
|
export declare class GuestSessionCoordinatorError extends Error {
|
|
82
111
|
readonly code: GuestSessionCoordinatorErrorCode;
|
|
83
112
|
constructor(code: GuestSessionCoordinatorErrorCode);
|
|
84
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* A `GuestSessionBackend` throws this when the identity service refused the
|
|
116
|
+
* refresh token for good (revoked, expired, replayed, or unknown), as
|
|
117
|
+
* opposed to an outage. The coordinator reports it as `GUEST_SESSION_REJECTED`.
|
|
118
|
+
*/
|
|
119
|
+
export declare class GuestSessionRejectedError extends Error {
|
|
120
|
+
constructor();
|
|
121
|
+
}
|
|
85
122
|
export interface GuestSessionCoordinator {
|
|
86
123
|
start(): Promise<ServerGuestSessionView>;
|
|
87
124
|
/** The active session's view, or `undefined` before `start()` settles, after a failure, and after logout. */
|
|
@@ -103,6 +140,18 @@ export interface GuestSessionCoordinator {
|
|
|
103
140
|
readonly status: 'bound' | 'already-bound' | 'conflict';
|
|
104
141
|
}>;
|
|
105
142
|
getHeaders(): Readonly<Record<'authorization', string>>;
|
|
143
|
+
/**
|
|
144
|
+
* Asks the backend for an account deletion confirmation with the active
|
|
145
|
+
* session's refresh token (queued with refreshes, so it never presents a
|
|
146
|
+
* token a rotation in flight replaces). Fails with
|
|
147
|
+
* `GUEST_SESSION_REJECTED` when the service refused the session for good,
|
|
148
|
+
* now or in an earlier refresh (as `refresh` does, the coordinator then
|
|
149
|
+
* stops using it), `GUEST_SESSION_NOT_READY` without an active session for
|
|
150
|
+
* any other reason (not started, a failed credential save),
|
|
151
|
+
* `GUEST_SESSION_CLOSED` after logout, and `GUEST_SESSION_UNSUPPORTED`
|
|
152
|
+
* when the backend cannot issue one.
|
|
153
|
+
*/
|
|
154
|
+
requestAccountDeletionConfirmation(): Promise<string>;
|
|
106
155
|
logout(): Promise<void>;
|
|
107
156
|
}
|
|
108
157
|
/**
|
|
@@ -165,7 +214,7 @@ export interface GuestServerSession {
|
|
|
165
214
|
/**
|
|
166
215
|
* The `Authorization` header. An expired access token is refreshed first,
|
|
167
216
|
* and a failed refresh throws its error (`GUEST_SESSION_BACKEND_FAILED`,
|
|
168
|
-
* `GUEST_SESSION_OWNERSHIP_CONFLICT`, ...). A token that expires within
|
|
217
|
+
* `GUEST_SESSION_REJECTED`, `GUEST_SESSION_OWNERSHIP_CONFLICT`, ...). A token that expires within
|
|
169
218
|
* `refreshAheadMs` is answered at once while a refresh runs in the
|
|
170
219
|
* background: once per token, and at most once per
|
|
171
220
|
* `guestServerSessionRefreshAheadIntervalMs`, so a backend whose tokens
|
|
@@ -173,6 +222,13 @@ export interface GuestServerSession {
|
|
|
173
222
|
* background refresh is retried when the token has expired.
|
|
174
223
|
*/
|
|
175
224
|
getHeaders(): Promise<Readonly<Record<'authorization', string>>>;
|
|
225
|
+
/**
|
|
226
|
+
* An account deletion confirmation for the current session
|
|
227
|
+
* (`GuestSessionCoordinator.requestAccountDeletionConfirmation`), which the
|
|
228
|
+
* game-services deletion route requires. Optional, so another session
|
|
229
|
+
* holder (a development session) may leave it out.
|
|
230
|
+
*/
|
|
231
|
+
requestAccountDeletionConfirmation?(): Promise<string>;
|
|
176
232
|
}
|
|
177
233
|
/** How long before its access token expires `createGuestServerSession` refreshes a session: one minute. */
|
|
178
234
|
export declare const defaultGuestServerSessionRefreshAheadMs = 60000;
|
|
@@ -190,3 +246,57 @@ export declare function createGuestServerSession(coordinator: GuestSessionCoordi
|
|
|
190
246
|
readonly refreshAheadMs?: number;
|
|
191
247
|
readonly now?: () => number;
|
|
192
248
|
}): GuestServerSession;
|
|
249
|
+
/**
|
|
250
|
+
* The HTTP routes of a guest identity service, relative to its base URL:
|
|
251
|
+
* the contract between `createGuestSessionHttpBackend` and the reference
|
|
252
|
+
* identity Worker (`apps/game-identity-worker`). Every route takes a JSON
|
|
253
|
+
* `POST` body. `issueGuest` and `refresh` answer `200` with
|
|
254
|
+
* `ServerGuestSessionCredentials`; `revoke` answers `200` with `{}`, also for
|
|
255
|
+
* a token it does not know (RFC 7009 section 2.2); a refusal answers
|
|
256
|
+
* `{ error: GuestIdentityHttpErrorCode }`. `bindAccount` answers `200` with
|
|
257
|
+
* an `AccountBindingOutcome` (a `conflict` too), or a refusal; the reference
|
|
258
|
+
* Worker binds nothing yet and answers `ACCOUNT_BINDING_UNSUPPORTED`.
|
|
259
|
+
* `requestAccountDeletionConfirmation` takes `{ refreshToken }` and answers
|
|
260
|
+
* `200` with an `AccountDeletionConfirmationGrant`. A refresh token any
|
|
261
|
+
* route refuses for good is `GUEST_SESSION_REJECTED`.
|
|
262
|
+
*/
|
|
263
|
+
export declare const guestIdentityHttpRoutes: Readonly<{
|
|
264
|
+
readonly issueGuest: '/identity/v1/guest';
|
|
265
|
+
readonly refresh: '/identity/v1/refresh';
|
|
266
|
+
readonly revoke: '/identity/v1/revoke';
|
|
267
|
+
readonly bindAccount: '/identity/v1/bind-account';
|
|
268
|
+
readonly requestAccountDeletionConfirmation: '/identity/v1/account-deletion-confirmation';
|
|
269
|
+
}>;
|
|
270
|
+
/**
|
|
271
|
+
* - `INVALID_REQUEST` (400): a body the route does not take.
|
|
272
|
+
* - `GUEST_SESSION_REJECTED` (401): the refresh token is revoked, expired,
|
|
273
|
+
* replayed, or unknown; the session cannot come back.
|
|
274
|
+
* - `RATE_LIMITED` (429): too many guests for this installation or address.
|
|
275
|
+
* - `ACCOUNT_BINDING_UNSUPPORTED` (501): the service binds no external account.
|
|
276
|
+
* - `UNAVAILABLE` (503): the service could not answer; retry later.
|
|
277
|
+
*/
|
|
278
|
+
export type GuestIdentityHttpErrorCode = 'INVALID_REQUEST' | 'GUEST_SESSION_REJECTED' | 'RATE_LIMITED' | 'ACCOUNT_BINDING_UNSUPPORTED' | 'UNAVAILABLE';
|
|
279
|
+
/** A failed identity request. The message names the route and the code, never a token. */
|
|
280
|
+
export declare class GuestSessionHttpError extends Error {
|
|
281
|
+
readonly status: number;
|
|
282
|
+
readonly code: string;
|
|
283
|
+
constructor(route: string, status: number, code: string);
|
|
284
|
+
}
|
|
285
|
+
/** How long one identity request may take by default: 15 seconds. */
|
|
286
|
+
export declare const defaultGuestSessionHttpTimeoutMs = 15000;
|
|
287
|
+
/**
|
|
288
|
+
* A `GuestSessionBackend` over a guest identity service's HTTP routes
|
|
289
|
+
* (`guestIdentityHttpRoutes`). A `refresh` the service refuses with
|
|
290
|
+
* `GUEST_SESSION_REJECTED` throws `GuestSessionRejectedError` (the
|
|
291
|
+
* coordinator's `GUEST_SESSION_REJECTED`); any other failure throws
|
|
292
|
+
* `GuestSessionHttpError` or the fetch error, which the coordinator reports
|
|
293
|
+
* as `GUEST_SESSION_BACKEND_FAILED`. `baseUrl` must be `https:`, except on a
|
|
294
|
+
* local development host (`localhost`, `*.localhost`, `127.0.0.1`, `[::1]`,
|
|
295
|
+
* `10.0.2.2`). Requests never follow a redirect, so a token is only ever sent
|
|
296
|
+
* to `baseUrl`'s origin.
|
|
297
|
+
*/
|
|
298
|
+
export declare function createGuestSessionHttpBackend(input: {
|
|
299
|
+
readonly baseUrl: string;
|
|
300
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
301
|
+
readonly timeoutMs?: number;
|
|
302
|
+
}): GuestSessionBackend;
|
package/dist/guest-session.js
CHANGED
|
@@ -7,6 +7,17 @@ export class GuestSessionCoordinatorError extends Error {
|
|
|
7
7
|
this.code = code;
|
|
8
8
|
}
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* A `GuestSessionBackend` throws this when the identity service refused the
|
|
12
|
+
* refresh token for good (revoked, expired, replayed, or unknown), as
|
|
13
|
+
* opposed to an outage. The coordinator reports it as `GUEST_SESSION_REJECTED`.
|
|
14
|
+
*/
|
|
15
|
+
export class GuestSessionRejectedError extends Error {
|
|
16
|
+
constructor() {
|
|
17
|
+
super('The identity service rejected the guest session.');
|
|
18
|
+
this.name = 'GuestSessionRejectedError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
10
21
|
const defaultCredentialKey = 'mpgd.guest.refresh';
|
|
11
22
|
/**
|
|
12
23
|
* Serializes token mutation and closes synchronously on logout. Native secure
|
|
@@ -49,6 +60,8 @@ export function createGuestSessionCoordinator(input) {
|
|
|
49
60
|
let savedFamily;
|
|
50
61
|
const listeners = new Set();
|
|
51
62
|
let status = 'idle';
|
|
63
|
+
/** The identity service refused the session for good (as opposed to a failed save). */
|
|
64
|
+
let rejected = false;
|
|
52
65
|
let tail = Promise.resolve();
|
|
53
66
|
let startInFlight;
|
|
54
67
|
let refreshInFlight;
|
|
@@ -85,7 +98,16 @@ export function createGuestSessionCoordinator(input) {
|
|
|
85
98
|
try {
|
|
86
99
|
return await operation();
|
|
87
100
|
}
|
|
88
|
-
catch {
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (error instanceof GuestSessionRejectedError) {
|
|
103
|
+
// The session can never come back: stop using it (current() is
|
|
104
|
+
// undefined, start() and getHeaders() fail fast) until logout.
|
|
105
|
+
if (status !== 'closed') {
|
|
106
|
+
status = 'failed';
|
|
107
|
+
}
|
|
108
|
+
rejected = true;
|
|
109
|
+
throw new GuestSessionCoordinatorError('GUEST_SESSION_REJECTED');
|
|
110
|
+
}
|
|
89
111
|
// Backend exceptions can contain URLs, proofs, or tokens. The public
|
|
90
112
|
// coordinator surface intentionally exposes only a stable error code.
|
|
91
113
|
throw new GuestSessionCoordinatorError('GUEST_SESSION_BACKEND_FAILED');
|
|
@@ -247,6 +269,30 @@ export function createGuestSessionCoordinator(input) {
|
|
|
247
269
|
return { status: result.status };
|
|
248
270
|
});
|
|
249
271
|
},
|
|
272
|
+
async requestAccountDeletionConfirmation() {
|
|
273
|
+
assertOpen();
|
|
274
|
+
const request = input.backend.requestAccountDeletionConfirmation?.bind(input.backend);
|
|
275
|
+
if (request === undefined) {
|
|
276
|
+
throw new GuestSessionCoordinatorError('GUEST_SESSION_UNSUPPORTED');
|
|
277
|
+
}
|
|
278
|
+
return enqueue(async () => {
|
|
279
|
+
assertOpen();
|
|
280
|
+
// Refused earlier (a refresh): say so, not merely "not ready", so the
|
|
281
|
+
// caller can tell a session that ended on the server from a failed save.
|
|
282
|
+
if (rejected) {
|
|
283
|
+
throw new GuestSessionCoordinatorError('GUEST_SESSION_REJECTED');
|
|
284
|
+
}
|
|
285
|
+
const active = requireCurrent();
|
|
286
|
+
const grant = await fromBackend(() => request({ refreshToken: active.refreshToken }));
|
|
287
|
+
// Logged out meanwhile: no confirmation for a closed session.
|
|
288
|
+
assertOpen();
|
|
289
|
+
if (!isRecord(grant) || typeof grant.confirmation !== 'string' || grant.confirmation.trim() === ''
|
|
290
|
+
|| typeof grant.expiresAt !== 'string' || !Number.isFinite(Date.parse(grant.expiresAt))) {
|
|
291
|
+
throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_RESPONSE');
|
|
292
|
+
}
|
|
293
|
+
return grant.confirmation;
|
|
294
|
+
});
|
|
295
|
+
},
|
|
250
296
|
current() {
|
|
251
297
|
return status === 'active' && current !== undefined ? view(current) : undefined;
|
|
252
298
|
},
|
|
@@ -396,6 +442,7 @@ export function createGuestServerSession(coordinator, options = {}) {
|
|
|
396
442
|
listener(next);
|
|
397
443
|
});
|
|
398
444
|
},
|
|
445
|
+
requestAccountDeletionConfirmation: () => coordinator.requestAccountDeletionConfirmation(),
|
|
399
446
|
async getHeaders() {
|
|
400
447
|
const active = coordinator.current();
|
|
401
448
|
if (active !== undefined) {
|
|
@@ -421,3 +468,151 @@ export function createGuestServerSession(coordinator, options = {}) {
|
|
|
421
468
|
},
|
|
422
469
|
};
|
|
423
470
|
}
|
|
471
|
+
/**
|
|
472
|
+
* The HTTP routes of a guest identity service, relative to its base URL:
|
|
473
|
+
* the contract between `createGuestSessionHttpBackend` and the reference
|
|
474
|
+
* identity Worker (`apps/game-identity-worker`). Every route takes a JSON
|
|
475
|
+
* `POST` body. `issueGuest` and `refresh` answer `200` with
|
|
476
|
+
* `ServerGuestSessionCredentials`; `revoke` answers `200` with `{}`, also for
|
|
477
|
+
* a token it does not know (RFC 7009 section 2.2); a refusal answers
|
|
478
|
+
* `{ error: GuestIdentityHttpErrorCode }`. `bindAccount` answers `200` with
|
|
479
|
+
* an `AccountBindingOutcome` (a `conflict` too), or a refusal; the reference
|
|
480
|
+
* Worker binds nothing yet and answers `ACCOUNT_BINDING_UNSUPPORTED`.
|
|
481
|
+
* `requestAccountDeletionConfirmation` takes `{ refreshToken }` and answers
|
|
482
|
+
* `200` with an `AccountDeletionConfirmationGrant`. A refresh token any
|
|
483
|
+
* route refuses for good is `GUEST_SESSION_REJECTED`.
|
|
484
|
+
*/
|
|
485
|
+
export const guestIdentityHttpRoutes = Object.freeze({
|
|
486
|
+
issueGuest: '/identity/v1/guest',
|
|
487
|
+
refresh: '/identity/v1/refresh',
|
|
488
|
+
revoke: '/identity/v1/revoke',
|
|
489
|
+
bindAccount: '/identity/v1/bind-account',
|
|
490
|
+
requestAccountDeletionConfirmation: '/identity/v1/account-deletion-confirmation',
|
|
491
|
+
});
|
|
492
|
+
/** A failed identity request. The message names the route and the code, never a token. */
|
|
493
|
+
export class GuestSessionHttpError extends Error {
|
|
494
|
+
status;
|
|
495
|
+
code;
|
|
496
|
+
constructor(route, status, code) {
|
|
497
|
+
super(`Guest identity request ${route} failed: ${status} ${code}.`);
|
|
498
|
+
this.name = 'GuestSessionHttpError';
|
|
499
|
+
this.status = status;
|
|
500
|
+
this.code = code;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/** How long one identity request may take by default: 15 seconds. */
|
|
504
|
+
export const defaultGuestSessionHttpTimeoutMs = 15_000;
|
|
505
|
+
/**
|
|
506
|
+
* A `GuestSessionBackend` over a guest identity service's HTTP routes
|
|
507
|
+
* (`guestIdentityHttpRoutes`). A `refresh` the service refuses with
|
|
508
|
+
* `GUEST_SESSION_REJECTED` throws `GuestSessionRejectedError` (the
|
|
509
|
+
* coordinator's `GUEST_SESSION_REJECTED`); any other failure throws
|
|
510
|
+
* `GuestSessionHttpError` or the fetch error, which the coordinator reports
|
|
511
|
+
* as `GUEST_SESSION_BACKEND_FAILED`. `baseUrl` must be `https:`, except on a
|
|
512
|
+
* local development host (`localhost`, `*.localhost`, `127.0.0.1`, `[::1]`,
|
|
513
|
+
* `10.0.2.2`). Requests never follow a redirect, so a token is only ever sent
|
|
514
|
+
* to `baseUrl`'s origin.
|
|
515
|
+
*/
|
|
516
|
+
export function createGuestSessionHttpBackend(input) {
|
|
517
|
+
const base = parseGuestIdentityBaseUrl(input.baseUrl);
|
|
518
|
+
const timeoutMs = input.timeoutMs ?? defaultGuestSessionHttpTimeoutMs;
|
|
519
|
+
// setTimeout fires at once past a signed 32-bit delay.
|
|
520
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {
|
|
521
|
+
throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_INPUT');
|
|
522
|
+
}
|
|
523
|
+
const fetchImpl = input.fetch ?? ((request) => globalThis.fetch(request));
|
|
524
|
+
async function post(route, body) {
|
|
525
|
+
const controller = new AbortController();
|
|
526
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
527
|
+
try {
|
|
528
|
+
const response = await fetchImpl(new Request(new URL(route.slice(1), base), {
|
|
529
|
+
method: 'POST',
|
|
530
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
531
|
+
body: JSON.stringify(body),
|
|
532
|
+
redirect: 'manual',
|
|
533
|
+
signal: controller.signal,
|
|
534
|
+
}));
|
|
535
|
+
// A fetch that followed a redirect anyway (some polyfills ignore
|
|
536
|
+
// `manual`) answered from another origin: never trust that answer.
|
|
537
|
+
if (response.redirected) {
|
|
538
|
+
throw new GuestSessionHttpError(route, response.status, 'REDIRECTED');
|
|
539
|
+
}
|
|
540
|
+
let payload;
|
|
541
|
+
try {
|
|
542
|
+
payload = await response.json();
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
payload = undefined;
|
|
546
|
+
}
|
|
547
|
+
if (response.status !== 200) {
|
|
548
|
+
const code = isRecord(payload) && typeof payload.error === 'string' && /^[A-Z_]{1,64}$/u.test(payload.error)
|
|
549
|
+
? payload.error
|
|
550
|
+
: 'UNKNOWN';
|
|
551
|
+
throw new GuestSessionHttpError(route, response.status, code);
|
|
552
|
+
}
|
|
553
|
+
// A 200 that is not a JSON object (a captive portal's page, a body cut
|
|
554
|
+
// off by the timeout) is a failure, so a revoke is never assumed done.
|
|
555
|
+
if (!isRecord(payload)) {
|
|
556
|
+
throw new GuestSessionHttpError(route, response.status, 'INVALID_BODY');
|
|
557
|
+
}
|
|
558
|
+
return payload;
|
|
559
|
+
}
|
|
560
|
+
finally {
|
|
561
|
+
clearTimeout(timer);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return {
|
|
565
|
+
async issueGuest({ installationId }) {
|
|
566
|
+
return await post(guestIdentityHttpRoutes.issueGuest, { installationId });
|
|
567
|
+
},
|
|
568
|
+
async refresh({ refreshToken }) {
|
|
569
|
+
return await withRejection(post(guestIdentityHttpRoutes.refresh, { refreshToken }));
|
|
570
|
+
},
|
|
571
|
+
async revoke({ refreshToken }) {
|
|
572
|
+
await post(guestIdentityHttpRoutes.revoke, { refreshToken });
|
|
573
|
+
},
|
|
574
|
+
async bindAccount({ refreshToken, externalProof, idempotencyKey }) {
|
|
575
|
+
return await withRejection(post(guestIdentityHttpRoutes.bindAccount, {
|
|
576
|
+
refreshToken,
|
|
577
|
+
externalProof,
|
|
578
|
+
idempotencyKey,
|
|
579
|
+
}));
|
|
580
|
+
},
|
|
581
|
+
async requestAccountDeletionConfirmation({ refreshToken }) {
|
|
582
|
+
return await withRejection(post(guestIdentityHttpRoutes.requestAccountDeletionConfirmation, { refreshToken }));
|
|
583
|
+
},
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
/** The service's `401 GUEST_SESSION_REJECTED` as `GuestSessionRejectedError`; other failures unchanged. */
|
|
587
|
+
async function withRejection(request) {
|
|
588
|
+
try {
|
|
589
|
+
return await request;
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
if (error instanceof GuestSessionHttpError && error.status === 401 && error.code === 'GUEST_SESSION_REJECTED') {
|
|
593
|
+
throw new GuestSessionRejectedError();
|
|
594
|
+
}
|
|
595
|
+
throw error;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
function parseGuestIdentityBaseUrl(value) {
|
|
599
|
+
let url;
|
|
600
|
+
try {
|
|
601
|
+
url = new URL(value);
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_INPUT');
|
|
605
|
+
}
|
|
606
|
+
const host = url.hostname.toLowerCase();
|
|
607
|
+
const local = host === 'localhost' || host.endsWith('.localhost') || host === '127.0.0.1'
|
|
608
|
+
|| host === '[::1]' || host === '10.0.2.2';
|
|
609
|
+
if (!(url.protocol === 'https:' || (url.protocol === 'http:' && local))
|
|
610
|
+
|| url.username !== '' || url.password !== '' || url.search !== '' || url.hash !== '') {
|
|
611
|
+
throw new GuestSessionCoordinatorError('GUEST_SESSION_INVALID_INPUT');
|
|
612
|
+
}
|
|
613
|
+
// Routes resolve under the base path, so `https://host/api` serves `/api/identity/v1/...`.
|
|
614
|
+
if (!url.pathname.endsWith('/')) {
|
|
615
|
+
url.pathname = `${url.pathname}/`;
|
|
616
|
+
}
|
|
617
|
+
return url;
|
|
618
|
+
}
|