@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.
Files changed (44) hide show
  1. package/README.md +18 -3
  2. package/dist/account-deletion.d.ts +306 -0
  3. package/dist/account-deletion.js +491 -0
  4. package/dist/app-store-recovery.d.ts +6 -0
  5. package/dist/app-store-recovery.js +26 -16
  6. package/dist/app-store-server-notifications.d.ts +158 -0
  7. package/dist/app-store-server-notifications.js +270 -0
  8. package/dist/app-store-server.d.ts +8 -0
  9. package/dist/app-store-server.js +135 -37
  10. package/dist/app-store-verifier.d.ts +34 -0
  11. package/dist/app-store-verifier.js +161 -31
  12. package/dist/commerce-ledger.d.ts +55 -0
  13. package/dist/commerce-ledger.js +198 -0
  14. package/dist/commerce-reconciler.js +47 -7
  15. package/dist/evidence-verification.d.ts +19 -0
  16. package/dist/google-play-publisher-client.d.ts +2 -1
  17. package/dist/google-play-publisher-client.js +80 -0
  18. package/dist/google-play-purchase.d.ts +7 -0
  19. package/dist/google-play-purchase.js +32 -7
  20. package/dist/google-play-store-server.d.ts +160 -0
  21. package/dist/google-play-store-server.js +297 -0
  22. package/dist/guest-session.d.ts +112 -2
  23. package/dist/guest-session.js +196 -1
  24. package/dist/index.d.ts +4 -0
  25. package/dist/index.js +4 -0
  26. package/dist/notifications/delivery-provider.d.ts +7 -0
  27. package/dist/notifications/fcm.d.ts +30 -3
  28. package/dist/notifications/fcm.js +72 -13
  29. package/dist/notifications/service.js +33 -5
  30. package/dist/notifications/store-conformance.js +7 -1
  31. package/dist/notifications/store.d.ts +9 -0
  32. package/dist/notifications/store.js +41 -0
  33. package/dist/operation-results.d.ts +7 -0
  34. package/dist/purchase-sharing.d.ts +41 -0
  35. package/dist/purchase-sharing.js +37 -0
  36. package/dist/runtime.d.ts +6 -0
  37. package/dist/runtime.js +13 -0
  38. package/dist/server.d.ts +60 -1
  39. package/dist/server.js +536 -42
  40. package/dist/types.d.ts +30 -0
  41. package/dist/types.js +22 -0
  42. package/dist/verified-leaderboard.d.ts +13 -0
  43. package/dist/verified-leaderboard.js +31 -0
  44. 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
+ }
@@ -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
- 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' | 'GUEST_SESSION_CREDENTIAL_LOAD_FAILED' | 'GUEST_SESSION_CREDENTIAL_SAVE_FAILED' | 'GUEST_SESSION_LOGOUT_UNCERTAIN';
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;
@@ -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
+ }