@mpgd/game-services 0.28.1 → 0.29.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 +5 -0
  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
package/README.md CHANGED
@@ -236,9 +236,11 @@ with native Node imports, without a bundler or TypeScript runtime loader.
236
236
  ## Guest session and account binding foundation
237
237
 
238
238
  `createGuestSessionCoordinator` from `@mpgd/game-services/guest-session`
239
- accepts a game-owned `GuestSessionBackend` and a dedicated
240
- `SecureCredentialStore`. The kit does **not** issue tokens or infer server
241
- authentication from `installationId` or a local `playerId`. The backend must
239
+ accepts a `GuestSessionBackend` and a dedicated `SecureCredentialStore`.
240
+ The client never issues tokens or infers server authentication from
241
+ `installationId` or a local `playerId`; the reference guest identity Worker
242
+ in the kit repository (`apps/game-identity-worker`) is one backend that does,
243
+ over `createGuestSessionHttpBackend`. The backend must
242
244
  authenticate opaque refresh tokens, verify external account proofs, atomically
243
245
  deduplicate account binding by idempotency key, return `conflict` for an account
244
246
  owned by another server user, and revoke sessions durably. It must define
@@ -277,6 +279,19 @@ family (not a refresh), and `getHeaders()` refreshes an expired access token
277
279
  before answering, and one that expires within a minute in the background
278
280
  (once per token, at most every ten seconds).
279
281
 
282
+ `createGuestSessionHttpBackend({ baseUrl })` is a `GuestSessionBackend` over
283
+ the HTTP routes in `guestIdentityHttpRoutes`, which the reference identity
284
+ Worker (`apps/game-identity-worker`) serves. It takes an `https:` base URL (or
285
+ `http:` on a local development host), never follows a redirect, and times a
286
+ request out after 15 seconds; a `200` that is not a JSON object is a failure,
287
+ so a logout is never assumed revoked. A refresh token the service refuses for
288
+ good on a refresh or a binding (revoked, replayed, expired, or unknown) throws
289
+ `GuestSessionRejectedError`, which the coordinator reports as
290
+ `GUEST_SESSION_REJECTED`; it then stops using that session (`current()` is
291
+ `undefined` and later calls fail with `GUEST_SESSION_NOT_READY` without a
292
+ request). Log out (it removes the dead token) and start a new coordinator for
293
+ a new guest. Any other failure stays `GUEST_SESSION_BACKEND_FAILED`.
294
+
280
295
  Binding an external account never switches to a different server user on a
281
296
  client callback. It does not merge purchases, currency, or progress. Use the
282
297
  existing `progress-link` nonce/idempotency service separately for a
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Account deletion (Google Play "account deletion requirements", App Store
3
+ * Review Guideline 5.1.1(v)): the server erases what game services keep for
4
+ * a player, keeps only the purchase records the law requires (detached from
5
+ * the player), and has the identity service end the player's sessions.
6
+ *
7
+ * Retention decision (see docs/GAME_SERVICES_BACKEND.md#account-deletion):
8
+ * - Purchase ledger rows (`source: 'purchase'`), the revocations that reverse
9
+ * them and their notes are kept for five years, the period the Korean
10
+ * E-Commerce Act's enforcement decree (article 6(1)) sets for payment and
11
+ * supply records. They are rewritten so nothing links them to the player:
12
+ * a random ledger id and idempotency key, the player id replaced by
13
+ * `deleted:<deletionId>`, and a `playerId` payload key removed. What
14
+ * identifies the store transaction (store, product, platform transaction
15
+ * and order ids, the token digest, the store's own account binding) stays,
16
+ * so a dispute can be answered from the store receipt and the same store
17
+ * purchase can never be granted again.
18
+ * - Everything else (rewarded-ad and admin grants, scores, verified
19
+ * leaderboard entries, notification endpoints, consent records, notices,
20
+ * reminders, sends and their history) is deleted.
21
+ */
22
+ import { type GameServicesHeaderResolver } from './request-headers.js';
23
+ import type { EntitlementLedgerPayload } from './types.js';
24
+ /** The public route a signed-in player deletes their own account through. */
25
+ export declare const accountDeletionEndpoint = "/game-services/account/delete";
26
+ /**
27
+ * The request header carrying the deletion confirmation: a short-lived,
28
+ * single-use token the identity service issues to the holder of the
29
+ * session's refresh token after the in-app second step. A bare access
30
+ * token (15 minutes, sent with every request) is not enough to destroy the
31
+ * account.
32
+ */
33
+ export declare const accountDeletionConfirmationHeader = "x-mpgd-account-deletion-confirmation";
34
+ /**
35
+ * The longest confirmation the server accepts (characters); the identity
36
+ * service issues shorter ones.
37
+ */
38
+ export declare const accountDeletionConfirmationMaxLength = 4096;
39
+ /**
40
+ * The identity service's check of a deletion confirmation: bound to the
41
+ * player, unexpired, and not used before; a confirmed token is consumed.
42
+ */
43
+ export interface AccountDeletionConfirmationVerifier {
44
+ verifyAccountDeletionConfirmation(input: {
45
+ readonly playerId: string;
46
+ readonly confirmation: string;
47
+ }): Promise<{
48
+ readonly confirmed: boolean;
49
+ }>;
50
+ }
51
+ /** How long the detached purchase records are kept (Korean E-Commerce Act decree, art. 6(1)). */
52
+ export declare const retainedPurchaseRecordYears = 5;
53
+ /** The prefix of the player id that replaces a deleted player's on the records kept. */
54
+ export declare const retainedPlayerIdPrefix = "deleted:";
55
+ export interface PlayerDataErasureInput {
56
+ /** The player whose data is erased. */
57
+ readonly playerId: string;
58
+ /** Replaces the player id on the purchase records kept (`deleted:<deletionId>`). */
59
+ readonly retainedPlayerId: string;
60
+ }
61
+ export interface PlayerDataErasureResult {
62
+ /** Rows deleted. */
63
+ readonly deleted: number;
64
+ /** Purchase ledger rows kept, detached from the player. */
65
+ readonly retained: number;
66
+ }
67
+ /** One store's part of an account deletion; idempotent, so it can run twice. */
68
+ export interface PlayerDataEraser {
69
+ erasePlayerData(input: PlayerDataErasureInput): Promise<PlayerDataErasureResult>;
70
+ }
71
+ /**
72
+ * The identity service's part (implemented by the identity Worker): revoke
73
+ * every session family of the player and delete the guest record, so no
74
+ * session authenticates as the player again. Idempotent: an unknown player
75
+ * succeeds. The game-services session bindings must stop accepting the
76
+ * player's access tokens at once too (not when they expire): the erasure
77
+ * runs right after, and only a request authenticated before the call can
78
+ * still write. It should delete the record last (or atomically): a failure
79
+ * after the sessions are revoked answers the player a retryable error, but
80
+ * the retry can no longer authenticate.
81
+ */
82
+ export interface AccountDeletionIdentity {
83
+ deletePlayer(input: {
84
+ readonly playerId: string;
85
+ }): Promise<unknown>;
86
+ }
87
+ /** The audit entry of one deletion; it carries no player id or other personal data. */
88
+ export interface AccountDeletionAuditEntry {
89
+ readonly deletionId: string;
90
+ readonly requestedAt: string;
91
+ readonly completedAt: string;
92
+ /** `not-configured` only where no identity service exists (local development). */
93
+ readonly identity: 'deleted' | 'not-configured';
94
+ /**
95
+ * `false` when a store's erasure still failed after the
96
+ * identity was deleted: the erasure stays pending (with the player id)
97
+ * until `finishPendingAccountErasures` completes it and records a
98
+ * `complete: true` entry under the same `deletionId`.
99
+ */
100
+ readonly complete: boolean;
101
+ /** Rows this request deleted; a retry after a failure counts only its own. */
102
+ readonly deleted: number;
103
+ readonly retained: number;
104
+ }
105
+ export interface AccountDeletionReceipt {
106
+ readonly status: 'deleted';
107
+ readonly deletionId: string;
108
+ readonly completedAt: string;
109
+ /**
110
+ * Purchase ledger rows (grants, the revocations reversing them, and their
111
+ * notes) this request kept, detached from the player, for the retention
112
+ * period. A retry after a partial failure counts only what it kept itself.
113
+ */
114
+ readonly retainedPurchaseRecords: number;
115
+ }
116
+ export type AccountDeletionErrorCode =
117
+ /** No signed-in session, or a session that no longer authenticates. */
118
+ 'UNAUTHORIZED'
119
+ /** The request named another player. */
120
+ | 'PLAYER_ID_MISMATCH'
121
+ /**
122
+ * No deletion confirmation, or one the identity service refused (expired,
123
+ * used, another player's). Get a new one and try again.
124
+ */
125
+ | 'ACCOUNT_DELETION_CONFIRMATION_REQUIRED'
126
+ /** This server has no authentication or identity service for deletion. */
127
+ | 'ACCOUNT_DELETION_NOT_CONFIGURED'
128
+ /**
129
+ * The server failed before the identity was deleted (or answered 5xx, 408, 425, or 429);
130
+ * every step is idempotent, so retrying is safe.
131
+ */
132
+ | 'ACCOUNT_DELETION_FAILED'
133
+ /** The server refused the request as malformed (another 4xx); retrying does not help. */
134
+ | 'ACCOUNT_DELETION_REJECTED'
135
+ /** The request did not reach the server or its answer was not read. */
136
+ | 'ACCOUNT_DELETION_UNREACHABLE';
137
+ export declare class AccountDeletionError extends Error {
138
+ readonly code: AccountDeletionErrorCode;
139
+ readonly retryable: boolean;
140
+ /**
141
+ * Whether the request may have reached the server. For
142
+ * `ACCOUNT_DELETION_UNREACHABLE` it was sent and its answer was lost or
143
+ * unreadable, so the deletion may have completed; for `UNAUTHORIZED` the
144
+ * server refused the session (`false`: no session on this device).
145
+ */
146
+ readonly requestSent: boolean;
147
+ constructor(code: AccountDeletionErrorCode, options?: {
148
+ readonly cause?: unknown;
149
+ readonly requestSent?: boolean;
150
+ });
151
+ }
152
+ export interface AccountDeletionService {
153
+ /**
154
+ * Records the erasure as pending, deletes the identity (no session
155
+ * authenticates as the player any more, so nothing new is written), marks
156
+ * the pending erasure `identity-deleted`, then erases every store. A
157
+ * failure before the identity is deleted rejects with a retryable
158
+ * `ACCOUNT_DELETION_FAILED` and has erased nothing, so a misconfigured
159
+ * identity service never leaves a live account stripped of its data. Once
160
+ * the identity is deleted the player cannot retry, so each store is tried
161
+ * three times with a short backoff; a store that still fails leaves the
162
+ * erasure pending for `finishPendingAccountErasures` (the Worker's cron)
163
+ * and is reported in the audit entry (`complete: false`), never to the
164
+ * player.
165
+ */
166
+ deletePlayerAccount(input: {
167
+ readonly playerId: string;
168
+ }): Promise<AccountDeletionReceipt>;
169
+ }
170
+ /**
171
+ * An erasure kept across the identity deletion. It holds the player id, so
172
+ * it lives only until the erasure completes.
173
+ */
174
+ export interface PendingAccountErasure extends PlayerDataErasureInput {
175
+ readonly deletionId: string;
176
+ readonly recordedAt: string;
177
+ /**
178
+ * `erasing` until the identity service confirmed the deletion. The sweep
179
+ * erases only `identity-deleted` erasures: an `erasing` one belongs to a
180
+ * request that failed or died before that confirmation, whose player most
181
+ * likely still exists and retries, so the sweep discards it once it is
182
+ * older than `staleErasingPendingMs` instead of erasing a live player.
183
+ * Residual gap: an identity call that committed but whose answer was lost
184
+ * on all three attempts (or a process that died before the mark) leaves
185
+ * an `erasing` entry, and
186
+ * the player's data then stays under the deleted id. Closing it needs the
187
+ * identity service to answer whether a player exists, which the binding
188
+ * does not offer yet.
189
+ */
190
+ readonly phase: 'erasing' | 'identity-deleted';
191
+ /** Sweep attempts so far. */
192
+ readonly attempts: number;
193
+ /** When the sweep last tried it; the store lists least recently tried first. */
194
+ readonly attemptedAt?: string;
195
+ }
196
+ export interface PendingAccountErasureStore {
197
+ /** Inserts or replaces the erasure named by `deletionId`. */
198
+ put(erasure: PendingAccountErasure): Promise<void>;
199
+ clear(input: {
200
+ readonly deletionId: string;
201
+ }): Promise<void>;
202
+ /**
203
+ * At most `limit` erasures: `identity-deleted` ones first, then by least
204
+ * recently tried (`attemptedAt`, else `recordedAt`). With
205
+ * `identityDeletedBefore`, an `identity-deleted` erasure recorded after it
206
+ * is left out (it is not due for its final pass yet), so young entries
207
+ * never take the slots of due ones.
208
+ */
209
+ list(input: {
210
+ readonly limit: number;
211
+ readonly identityDeletedBefore?: string;
212
+ }): Promise<readonly PendingAccountErasure[]>;
213
+ }
214
+ /** An `erasing` erasure older than this (one hour) is discarded by the sweep. */
215
+ export declare const staleErasingPendingMs: number;
216
+ /**
217
+ * The sweep erases an `identity-deleted` erasure once more, and only then
218
+ * clears it, at its first run at least this long (15 minutes) after the
219
+ * request: longer than any request that authenticated before the identity
220
+ * was deleted can still run. Until then the pending entry (with the player
221
+ * id) is kept, so a deployment must run the sweep (the Worker's cron).
222
+ */
223
+ export declare const finalErasureDelayMs: number;
224
+ export declare function createInMemoryPendingAccountErasureStore(): PendingAccountErasureStore;
225
+ export declare function createAccountDeletionService(input: {
226
+ readonly erasers: readonly PlayerDataEraser[];
227
+ /**
228
+ * The identity service. `null` only for local development without one:
229
+ * the player's sessions then survive the deletion.
230
+ */
231
+ readonly identity: AccountDeletionIdentity | null;
232
+ /** Keeps an erasure across the identity deletion; see `finishPendingAccountErasures`. */
233
+ readonly pending: PendingAccountErasureStore;
234
+ /** Records the audit entry. A failure is logged and does not fail the deletion. */
235
+ readonly audit?: (entry: AccountDeletionAuditEntry) => Promise<void>;
236
+ readonly now?: () => string;
237
+ readonly createDeletionId?: () => string;
238
+ /** Waits between retry attempts; defaults to a timer. */
239
+ readonly sleep?: (ms: number) => Promise<void>;
240
+ }): AccountDeletionService;
241
+ export interface PendingAccountErasureSweep {
242
+ /** `identity-deleted` erasures that every store completed and that were cleared. */
243
+ readonly finished: number;
244
+ /** Erasures a store failed (kept for the next sweep), and stale entries that could not be discarded. */
245
+ readonly failed: number;
246
+ /** Stale `erasing` erasures discarded without erasing. */
247
+ readonly discarded: number;
248
+ }
249
+ /**
250
+ * Finishes erasures a deletion left pending: an `identity-deleted` one is
251
+ * erased again `finalErasureDelayMs` after the request (every deletion
252
+ * gets this final pass, which removes what a request authenticated before
253
+ * the identity was deleted wrote later) and cleared once every store
254
+ * completed it, with an audit entry that marks the deletion complete; an
255
+ * `erasing` one older than
256
+ * `staleErasingPendingMs` is discarded without erasing. Run it from a
257
+ * scheduled job. A failing erasure is tried again later, least recently
258
+ * tried first, so one that keeps failing does not block the others.
259
+ */
260
+ export declare function finishPendingAccountErasures(input: {
261
+ readonly erasers: readonly PlayerDataEraser[];
262
+ readonly pending: PendingAccountErasureStore;
263
+ readonly audit?: (entry: AccountDeletionAuditEntry) => Promise<void>;
264
+ readonly now?: () => string;
265
+ readonly limit?: number;
266
+ }): Promise<PendingAccountErasureSweep>;
267
+ /** The player id stored on a deleted player's kept purchase records. */
268
+ export declare function isRetainedPlayerId(playerId: string): boolean;
269
+ /** Refuses an erasure input whose ids could re-erase another deletion's kept records. */
270
+ export declare function assertPlayerDataErasureInput(input: PlayerDataErasureInput): PlayerDataErasureInput;
271
+ /** A fresh id for a kept ledger row; it carries nothing of the player's ids. */
272
+ export declare function createRetainedLedgerEntryId(): string;
273
+ /** The kept row's payload: the store transaction stays, the player id goes. */
274
+ export declare function detachRetainedLedgerPayload(payload: EntitlementLedgerPayload): EntitlementLedgerPayload;
275
+ export interface AccountDeletionFetchResponse {
276
+ readonly status: number;
277
+ text(): Promise<string>;
278
+ }
279
+ export type AccountDeletionFetch = (url: string, init: {
280
+ readonly method: 'POST';
281
+ readonly headers: Record<string, string>;
282
+ readonly body: string;
283
+ readonly signal?: AbortSignal;
284
+ }) => Promise<AccountDeletionFetchResponse>;
285
+ /** How long `deleteAccount()` waits for the session and the server by default. */
286
+ export declare const defaultAccountDeletionTimeoutMs = 30000;
287
+ export interface AccountDeletionClient {
288
+ /**
289
+ * Deletes the signed-in player's account on the server. Resolves with the
290
+ * receipt; rejects with an `AccountDeletionError`. After an
291
+ * `ACCOUNT_DELETION_UNREACHABLE` failure the deletion may have completed:
292
+ * a retry that answers `UNAUTHORIZED` means the session no longer exists.
293
+ */
294
+ deleteAccount(input?: {
295
+ /** The identity service's deletion confirmation (`accountDeletionConfirmationHeader`). */
296
+ readonly confirmation?: string;
297
+ }): Promise<AccountDeletionReceipt>;
298
+ }
299
+ export declare function createAccountDeletionClient(input: {
300
+ readonly baseUrl: string;
301
+ readonly headers?: Readonly<Record<string, string>>;
302
+ readonly getHeaders?: GameServicesHeaderResolver;
303
+ readonly fetch?: AccountDeletionFetch;
304
+ /** Deadline for resolving the session headers and the server's answer; 30 s by default. */
305
+ readonly timeoutMs?: number;
306
+ }): AccountDeletionClient;