@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
package/dist/index.d.ts CHANGED
@@ -1,7 +1,9 @@
1
+ export * from './account-deletion.js';
1
2
  export * from './admob-ssv.js';
2
3
  export * from './ad-reward-verifier-registry.js';
3
4
  export * from './app-store-verifier.js';
4
5
  export * from './app-store-recovery.js';
6
+ export * from './app-store-server-notifications.js';
5
7
  export * from './apps-in-toss-evidence-verification.js';
6
8
  export * from './apps-in-toss-partner-api.js';
7
9
  export * from './client.js';
@@ -17,6 +19,7 @@ export * from './evidence-verification.js';
17
19
  export * from './guest-session.js';
18
20
  export * from './google-play-purchase.js';
19
21
  export * from './google-play-publisher-client.js';
22
+ export * from './google-play-store-server.js';
20
23
  export * from './microsoft-store-identity.js';
21
24
  export * from './microsoft-store-purchase.js';
22
25
  export * from './monetization-operation-store.js';
@@ -24,6 +27,7 @@ export * from './monetization-recovery.js';
24
27
  export * from './notification-delivery.js';
25
28
  export * from './platform-order.js';
26
29
  export * from './progress-link.js';
30
+ export * from './purchase-sharing.js';
27
31
  export * from './runtime.js';
28
32
  export * from './server.js';
29
33
  export * from './types.js';
package/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
+ export * from './account-deletion.js';
1
2
  export * from './admob-ssv.js';
2
3
  export * from './ad-reward-verifier-registry.js';
3
4
  export * from './app-store-verifier.js';
4
5
  export * from './app-store-recovery.js';
6
+ export * from './app-store-server-notifications.js';
5
7
  export * from './apps-in-toss-evidence-verification.js';
6
8
  export * from './apps-in-toss-partner-api.js';
7
9
  export * from './client.js';
@@ -17,6 +19,7 @@ export * from './evidence-verification.js';
17
19
  export * from './guest-session.js';
18
20
  export * from './google-play-purchase.js';
19
21
  export * from './google-play-publisher-client.js';
22
+ export * from './google-play-store-server.js';
20
23
  export * from './microsoft-store-identity.js';
21
24
  export * from './microsoft-store-purchase.js';
22
25
  export * from './monetization-operation-store.js';
@@ -24,6 +27,7 @@ export * from './monetization-recovery.js';
24
27
  export * from './notification-delivery.js';
25
28
  export * from './platform-order.js';
26
29
  export * from './progress-link.js';
30
+ export * from './purchase-sharing.js';
27
31
  export * from './runtime.js';
28
32
  export * from './server.js';
29
33
  export * from './types.js';
@@ -22,6 +22,13 @@ export interface NotificationDeviceAddress {
22
22
  readonly kind: 'fcm-token' | 'fcm-installation';
23
23
  readonly value: string;
24
24
  readonly environment?: 'production' | 'sandbox';
25
+ /**
26
+ * The installation's shell renders advertising pushes itself, with the
27
+ * opt-out action (an Android shell with the kit's renderer). The backend
28
+ * keeps it only for a target whose pushes need it, and the FCM provider
29
+ * then sends advertising as a data message the shell renders.
30
+ */
31
+ readonly rendersAdvertising?: boolean;
25
32
  }
26
33
  /** Who one delivery goes to: a device endpoint, or a resolved platform identity. */
27
34
  export type NotificationRemoteRecipient = {
@@ -14,9 +14,11 @@ import type { NotificationProviderRegistration } from './registry.js';
14
14
  * Android posts to the mapping's `channelId`, else to `mpgd-service`,
15
15
  * `mpgd-requested`, or `mpgd-advertising` by purpose. An advertising send
16
16
  * names the iOS category `mpgd-advertising`, whose opt-out action the app
17
- * registers; an Android FCM notification message cannot carry actions, and
18
- * the Capacitor shell does not render advertising natively yet. An expiry
19
- * becomes `android.ttl` and the `apns-expiration` header. A replay of the
17
+ * registers. An Android FCM notification message cannot carry actions, so an
18
+ * advertising send to an endpoint whose registration says its shell renders
19
+ * advertising (`rendersAdvertising`) is a data message the shell renders
20
+ * with the action (`fcmShellRenderedDataKeys`). An expiry becomes
21
+ * `android.ttl` and the `apns-expiration` header. A replay of the
20
22
  * same send key sets `android.collapse_key` and `apns-collapse-id` so a
21
23
  * crash after HTTP 200, before the ledger write, does not show a second
22
24
  * notification.
@@ -35,6 +37,31 @@ export declare const fcmReservedDataKeys: readonly ["topic", "route"];
35
37
  /** The iOS notification category of advertising, which carries the opt-out action. */
36
38
  export declare const fcmAdvertisingCategory = "mpgd-advertising";
37
39
  /**
40
+ * The data keys of an advertising push the Android shell renders itself
41
+ * (RFC "Delivery providers"). An Android FCM notification message cannot
42
+ * carry actions, so such a push has no `notification` block: the app's
43
+ * messaging service receives it in the background too and posts it with the
44
+ * opt-out action. `render` is `fcmShellRenderedAdvertising`; `title` and
45
+ * `body` are the gate's rendered text, `channel` the Android channel,
46
+ * `actionTitle` and `optOutRoute` the opt-out action's title and route (the
47
+ * topic's declared opt-out route) when the content carries the action, and
48
+ * `expiresAt` the expiry in epoch milliseconds, after which the
49
+ * shell does not show it. The send's `topic` and `route` stay in their own
50
+ * keys, as on every push. A send's routing data may not use the `mpgd.`
51
+ * prefix.
52
+ */
53
+ export declare const fcmShellRenderedDataKeys: Readonly<{
54
+ readonly render: 'mpgd.render';
55
+ readonly title: 'mpgd.title';
56
+ readonly body: 'mpgd.body';
57
+ readonly channel: 'mpgd.channel';
58
+ readonly actionTitle: 'mpgd.actionTitle';
59
+ readonly optOutRoute: 'mpgd.optOutRoute';
60
+ readonly expiresAt: 'mpgd.expiresAt';
61
+ }>;
62
+ /** The `mpgd.render` value of an advertising push the shell renders. */
63
+ export declare const fcmShellRenderedAdvertising = "advertising";
64
+ /**
38
65
  * `android.collapse_key` and `apns-collapse-id` are at most 64 bytes. The
39
66
  * id is the SHA-256 of the send key, which is 64 hex characters.
40
67
  */
@@ -1,4 +1,4 @@
1
- import { referenceNotificationProviders } from '@mpgd/platform/notifications';
1
+ import { notificationAdvertisingOptOutActionId, referenceNotificationProviders, } from '@mpgd/platform/notifications';
2
2
  import { fcmInstallationIdRefusal, } from './delivery-provider.js';
3
3
  /**
4
4
  * The FCM remote delivery provider (notifications v2 RFC, "Delivery
@@ -14,9 +14,11 @@ import { fcmInstallationIdRefusal, } from './delivery-provider.js';
14
14
  * Android posts to the mapping's `channelId`, else to `mpgd-service`,
15
15
  * `mpgd-requested`, or `mpgd-advertising` by purpose. An advertising send
16
16
  * names the iOS category `mpgd-advertising`, whose opt-out action the app
17
- * registers; an Android FCM notification message cannot carry actions, and
18
- * the Capacitor shell does not render advertising natively yet. An expiry
19
- * becomes `android.ttl` and the `apns-expiration` header. A replay of the
17
+ * registers. An Android FCM notification message cannot carry actions, so an
18
+ * advertising send to an endpoint whose registration says its shell renders
19
+ * advertising (`rendersAdvertising`) is a data message the shell renders
20
+ * with the action (`fcmShellRenderedDataKeys`). An expiry becomes
21
+ * `android.ttl` and the `apns-expiration` header. A replay of the
20
22
  * same send key sets `android.collapse_key` and `apns-collapse-id` so a
21
23
  * crash after HTTP 200, before the ledger write, does not show a second
22
24
  * notification.
@@ -34,6 +36,31 @@ export const fcmAddressKinds = Object.freeze(['fcm-token']);
34
36
  export const fcmReservedDataKeys = Object.freeze(['topic', 'route']);
35
37
  /** The iOS notification category of advertising, which carries the opt-out action. */
36
38
  export const fcmAdvertisingCategory = 'mpgd-advertising';
39
+ /**
40
+ * The data keys of an advertising push the Android shell renders itself
41
+ * (RFC "Delivery providers"). An Android FCM notification message cannot
42
+ * carry actions, so such a push has no `notification` block: the app's
43
+ * messaging service receives it in the background too and posts it with the
44
+ * opt-out action. `render` is `fcmShellRenderedAdvertising`; `title` and
45
+ * `body` are the gate's rendered text, `channel` the Android channel,
46
+ * `actionTitle` and `optOutRoute` the opt-out action's title and route (the
47
+ * topic's declared opt-out route) when the content carries the action, and
48
+ * `expiresAt` the expiry in epoch milliseconds, after which the
49
+ * shell does not show it. The send's `topic` and `route` stay in their own
50
+ * keys, as on every push. A send's routing data may not use the `mpgd.`
51
+ * prefix.
52
+ */
53
+ export const fcmShellRenderedDataKeys = Object.freeze({
54
+ render: 'mpgd.render',
55
+ title: 'mpgd.title',
56
+ body: 'mpgd.body',
57
+ channel: 'mpgd.channel',
58
+ actionTitle: 'mpgd.actionTitle',
59
+ optOutRoute: 'mpgd.optOutRoute',
60
+ expiresAt: 'mpgd.expiresAt',
61
+ });
62
+ /** The `mpgd.render` value of an advertising push the shell renders. */
63
+ export const fcmShellRenderedAdvertising = 'advertising';
37
64
  /**
38
65
  * FCM's 4,096-byte limit, less room for what FCM and APNs add on the way
39
66
  * (the `aps` wrapper and FCM's own keys), so a message this check passes is
@@ -319,9 +346,38 @@ export function buildFcmMessage(request) {
319
346
  // instead of showing beside it. Collapse is per token, so two devices of
320
347
  // one send do not hide each other.
321
348
  const collapseId = fcmCollapseId(request.sendKey);
349
+ const ttl = expiresAt === undefined ? {} : { ttl: `${Math.floor((expiresAt - at) / 1000)}s` };
350
+ if (request.topic.purpose === 'advertising' && recipient.address.rendersAdvertising === true) {
351
+ // A data message without a `notification` block reaches the shell's
352
+ // messaging service in the background too, which renders it with the
353
+ // opt-out action. Normal priority, FCM's default for data and
354
+ // notification messages alike: FCM keeps high priority for urgent,
355
+ // time-sensitive messages, and advertising is not one. The shell's
356
+ // renderer checks the expiry again, since arrival decides.
357
+ const optOut = content.actions
358
+ ?.find((action) => action.id === notificationAdvertisingOptOutActionId);
359
+ const rendered = Object.create(null);
360
+ Object.assign(rendered, data, {
361
+ [fcmShellRenderedDataKeys.render]: fcmShellRenderedAdvertising,
362
+ [fcmShellRenderedDataKeys.title]: content.title,
363
+ [fcmShellRenderedDataKeys.body]: content.body,
364
+ [fcmShellRenderedDataKeys.channel]: channelId,
365
+ ...(optOut === undefined
366
+ ? {}
367
+ : {
368
+ [fcmShellRenderedDataKeys.actionTitle]: optOut.title,
369
+ [fcmShellRenderedDataKeys.optOutRoute]: optOut.route,
370
+ }),
371
+ ...(expiresAt === undefined ? {} : { [fcmShellRenderedDataKeys.expiresAt]: String(expiresAt) }),
372
+ });
373
+ return sizedMessage(recipient.address.value, {
374
+ data: { ...rendered },
375
+ android: { collapse_key: collapseId, ...ttl },
376
+ });
377
+ }
322
378
  const android = {
323
379
  collapse_key: collapseId,
324
- ...(expiresAt === undefined ? {} : { ttl: `${Math.floor((expiresAt - at) / 1000)}s` }),
380
+ ...ttl,
325
381
  notification: { channel_id: channelId },
326
382
  };
327
383
  const category = request.topic.purpose === 'advertising' ? fcmAdvertisingCategory : undefined;
@@ -332,12 +388,15 @@ export function buildFcmMessage(request) {
332
388
  },
333
389
  ...(category === undefined ? {} : { payload: { aps: { category } } }),
334
390
  };
335
- const payload = {
391
+ return sizedMessage(recipient.address.value, {
336
392
  notification,
337
393
  data: { ...data },
338
394
  android,
339
395
  apns,
340
- };
396
+ });
397
+ }
398
+ /** The message for `token`, unless its payload is over the size this provider sends. */
399
+ function sizedMessage(token, payload) {
341
400
  // Everything but the token counts toward the size FCM and APNs carry.
342
401
  const payloadBytes = new TextEncoder().encode(JSON.stringify(payload)).length;
343
402
  if (payloadBytes > maxPayloadBytes) {
@@ -347,10 +406,7 @@ export function buildFcmMessage(request) {
347
406
  code: 'INVALID_MESSAGE',
348
407
  };
349
408
  }
350
- return {
351
- status: 'ready',
352
- message: { token: recipient.address.value, ...payload },
353
- };
409
+ return { status: 'ready', message: { token, ...payload } };
354
410
  }
355
411
  /**
356
412
  * Maps an FCM error answer to the provider answer kinds (RFC "Delivery
@@ -518,7 +574,7 @@ function isFirebaseProjectId(id) {
518
574
  && id.length <= 100
519
575
  && new RegExp(`^(?:${label}(?:\\.${label})*:)?${label}$`, 'u').test(id);
520
576
  }
521
- /** FCM's reserved data keys (`from`, `message_type`, and any `google` or `gcm` prefix), `topic` and `route`, and keys a plain object cannot hold. */
577
+ /** FCM's reserved data keys (`from`, `message_type`, and any `google` or `gcm` prefix), `topic` and `route`, the shell renderer's `mpgd.` keys, and keys a plain object cannot hold. */
522
578
  function isReservedDataKey(key) {
523
579
  const lower = key.toLowerCase();
524
580
  return key === ''
@@ -532,7 +588,10 @@ function isReservedDataKey(key) {
532
588
  || key === 'aps'
533
589
  || key === 'fcm_options'
534
590
  || lower.startsWith('google')
535
- || lower.startsWith('gcm');
591
+ || lower.startsWith('gcm')
592
+ // The shell renderer's keys (`fcmShellRenderedDataKeys`) and the keys it
593
+ // adds to an open.
594
+ || lower.startsWith('mpgd.');
536
595
  }
537
596
  /** The `details` of an FCM error answer. */
538
597
  function errorDetailsOf(body) {
@@ -1,4 +1,4 @@
1
- import { assertNotificationConsentRecord, assertNotificationDeployment, assertNotificationEndpointRegistration, assertRecordNotificationNoticeInput, createNotificationNoticeIdempotencyKey, createNotificationReminderIdempotencyKey, evaluateNotificationDelivery, evaluateNotificationReminderConsent, fillNotificationTemplate, isNotificationTimestamp, isSameNotificationNotice, isValidNotificationTimeZone, latestNotificationConsent, listNotificationNotices, normalizeNotificationTimestamp, NotificationError, notificationKeyIssue, notificationNoticeCopyIssues, notificationNoticeSendFrom, notificationNoticeTopic, notificationProviderCarriesNotices, notificationReservedKeyPrefix, notificationSendFingerprint, notificationTemplateDataIssue, toSeoulCalendarDate, } from '@mpgd/platform/notifications';
1
+ import { assertNotificationConsentRecord, assertNotificationDeployment, assertNotificationEndpointRegistration, assertRecordNotificationNoticeInput, createNotificationNoticeIdempotencyKey, createNotificationReminderIdempotencyKey, evaluateNotificationDelivery, evaluateNotificationReminderConsent, fillNotificationTemplate, isNotificationTimestamp, isSameNotificationNotice, isValidNotificationTimeZone, latestNotificationConsent, listNotificationNotices, normalizeNotificationTimestamp, NotificationError, notificationKeyIssue, notificationNoticeCopyIssues, notificationNoticeSendFrom, notificationNoticeTopic, notificationProviderCarriesNotices, notificationReservedKeyPrefix, notificationSendFingerprint, notificationShellRenderedAdvertisingTargets, notificationTemplateDataIssue, toSeoulCalendarDate, } from '@mpgd/platform/notifications';
2
2
  import { fcmInstallationIdRefusal, notificationExpiredProviderCode, } from './delivery-provider.js';
3
3
  import { notificationHistoryRetentionDays, } from './registry.js';
4
4
  import { bestNotificationOutcome, formatNotificationInstant, isNotificationRowAbandoned, isNotificationRowDue, notificationAmbiguousReplayHours, notificationSendOutcome, } from './store.js';
@@ -179,12 +179,15 @@ export function createNotificationService(input) {
179
179
  }
180
180
  };
181
181
  // Topics.
182
+ /**
183
+ * The catalog targets from which a recipient's topic is read: the target
184
+ * its registration named while the provider still serves it, else every
185
+ * target the provider serves, in order.
186
+ */
187
+ const candidateTargetsOf = (provider, target) => target !== undefined && provider.targets.includes(target) ? [target] : provider.targets;
182
188
  /** The topic as declared for the recipient's target, else for the provider's first target that declares it. */
183
189
  const topicFor = (provider, target, topicId, origin) => {
184
- const targets = target !== undefined && provider.targets.includes(target)
185
- ? [target]
186
- : provider.targets;
187
- for (const candidate of targets) {
190
+ for (const candidate of candidateTargetsOf(provider, target)) {
188
191
  const topic = catalog.topicsFor(candidate).find((declared) => declared.id === topicId);
189
192
  if (topic !== undefined) {
190
193
  // A notice says only its variant of notices.copy.
@@ -206,6 +209,24 @@ export function createNotificationService(input) {
206
209
  : [];
207
210
  const noticeListed = async (player, notice, at) => (await noticesOf(player, at)).some((listed) => isSameNotificationNotice(listed, notice));
208
211
  // Recipients (gate step 2).
212
+ /**
213
+ * Whether an endpoint's topic may be read from a target whose pushes need
214
+ * the shell to render advertising (Android), as `topicFor` picks it: the
215
+ * named target while the provider serves it, else any target it serves.
216
+ */
217
+ const mayNeedShellRendering = (provider, target) => candidateTargetsOf(provider, target)
218
+ .some((candidate) => notificationShellRenderedAdvertisingTargets.includes(candidate));
219
+ /**
220
+ * Whether a device endpoint shows the actions a push carries: where its
221
+ * topic may come from a target that needs the shell to render advertising
222
+ * (Android), only when its latest registration said the shell does;
223
+ * elsewhere (iOS's category) always. Gate step 1 refuses advertising
224
+ * under `kr` to an endpoint without `true`.
225
+ */
226
+ const endpointActionsOf = (provider, endpoint) => ({
227
+ endpointShowsActions: !mayNeedShellRendering(provider, endpoint.target)
228
+ || endpoint.address.rendersAdvertising === true,
229
+ });
209
230
  const usable = (endpoint, at) => endpoint.status === 'valid'
210
231
  && at - Date.parse(endpoint.lastRegisteredAt) < endpointStaleMs
211
232
  && (endpoint.sessionExpiresAt === undefined || Date.parse(endpoint.sessionExpiresAt) > at);
@@ -409,6 +430,7 @@ export function createNotificationService(input) {
409
430
  consent,
410
431
  ...(serverPermission === undefined ? {} : { serverPermission }),
411
432
  ...(recipient.endpoint === undefined ? {} : { endpointPermission: recipient.endpoint.permission }),
433
+ ...(recipient.endpoint === undefined ? {} : endpointActionsOf(recipient.provider, recipient.endpoint)),
412
434
  history,
413
435
  at: formatNotificationInstant(at),
414
436
  ...(recipient.timeZone === undefined ? {} : { timeZone: recipient.timeZone }),
@@ -1980,6 +2002,11 @@ export function createNotificationService(input) {
1980
2002
  if (request.target !== undefined && !provider.targets.includes(request.target)) {
1981
2003
  throw invalidInput(`Provider ${provider.entry.provider} does not serve target ${String(request.target)}.`);
1982
2004
  }
2005
+ // Only a target whose pushes need the shell to render advertising keeps
2006
+ // the claim, so the FCM provider never sends another device (iOS) a
2007
+ // data message it would not show.
2008
+ const rendersAdvertising = address.rendersAdvertising === true
2009
+ && mayNeedShellRendering(provider, request.target);
1983
2010
  await store.putEndpoint({
1984
2011
  provider: provider.entry.provider,
1985
2012
  installationId: current.installationId,
@@ -1988,6 +2015,7 @@ export function createNotificationService(input) {
1988
2015
  kind: address.kind,
1989
2016
  value: address.value,
1990
2017
  ...(address.environment === undefined ? {} : { environment: address.environment }),
2018
+ ...(rendersAdvertising ? { rendersAdvertising: true } : {}),
1991
2019
  },
1992
2020
  permission: address.permission,
1993
2021
  ...(registration.locale === undefined ? {} : { locale: registration.locale }),
@@ -65,9 +65,15 @@ export async function runNotificationStoreConformance(createStore) {
65
65
  timeZone: 'Asia/Seoul',
66
66
  target: 'android',
67
67
  sessionExpiresAt: at(86_400_000),
68
- address: { kind: 'fcm-token', value: 'tok-1', environment: 'production' },
68
+ address: {
69
+ kind: 'fcm-token',
70
+ value: 'tok-1',
71
+ environment: 'production',
72
+ rendersAdvertising: true,
73
+ },
69
74
  }));
70
75
  equal(stored.status, 'valid', 'endpoints: a new endpoint is valid');
76
+ deepEqual((await store.getEndpoint({ provider: 'fcm', installationId: 'i-1' }))?.address, { kind: 'fcm-token', value: 'tok-1', environment: 'production', rendersAdvertising: true }, 'endpoints: the address round-trips, with the shell-rendered advertising claim');
71
77
  deepEqual(await store.getEndpoint({ provider: 'fcm', installationId: 'i-1' }), stored, 'endpoints: read back as written');
72
78
  // The same address on another installation moves there.
73
79
  await store.putEndpoint(endpoint('i-2', 'tok-1'));
@@ -1,3 +1,4 @@
1
+ import { type PlayerDataErasureInput, type PlayerDataErasureResult } from '../account-deletion.js';
1
2
  import { type NotificationConsentRecord, type NotificationDeferralReason, type NotificationDeliveryOutcome, type NotificationNoticeIdentity, type NotificationNoticeVariant, type NotificationPermissionState, type NotificationTemplateData } from '@mpgd/platform/notifications';
2
3
  import type { NotificationDeviceAddress } from './delivery-provider.js';
3
4
  /**
@@ -417,6 +418,14 @@ export interface InMemoryNotificationStoreState {
417
418
  export interface InMemoryNotificationStore extends NotificationStore {
418
419
  /** A deep copy of every row, read synchronously. */
419
420
  inspect(): InMemoryNotificationStoreState;
421
+ /**
422
+ * Account deletion (a `PlayerDataEraser`): removes every row of the player
423
+ * (consent records, endpoints, platform registrations, notices given,
424
+ * sends, delivery history, and schedule entries). Nothing is kept: no
425
+ * notification data is under a legal retention period. The Worker's D1
426
+ * deployment erases these tables in its own batch.
427
+ */
428
+ erasePlayerData(input: PlayerDataErasureInput): Promise<PlayerDataErasureResult>;
420
429
  }
421
430
  /**
422
431
  * A process-local store for tests, conformance runs, and local development.
@@ -1,3 +1,4 @@
1
+ import { assertPlayerDataErasureInput, } from '../account-deletion.js';
1
2
  import { normalizeNotificationTimestamp, } from '@mpgd/platform/notifications';
2
3
  export function notificationRowOutcome(row) {
3
4
  switch (row.state.status) {
@@ -163,6 +164,46 @@ export function createInMemoryNotificationStore() {
163
164
  notice.dueBy,
164
165
  ]);
165
166
  return {
167
+ async erasePlayerData(input) {
168
+ const player = assertPlayerDataErasureInput(input).playerId;
169
+ let removed = 0;
170
+ const keep = (rows, belongs) => {
171
+ let kept = 0;
172
+ for (const row of rows) {
173
+ if (belongs(row)) {
174
+ removed += 1;
175
+ }
176
+ else {
177
+ rows[kept] = row;
178
+ kept += 1;
179
+ }
180
+ }
181
+ rows.length = kept;
182
+ };
183
+ keep(consent, (row) => row.player === player);
184
+ keep(endpoints, (endpoint) => endpoint.player === player);
185
+ keep(noticesGiven, (notice) => notice.player === player);
186
+ keep(history, (entry) => entry.player === player);
187
+ for (const [key, registration] of [...platformRegistrations]) {
188
+ if (registration.player === player) {
189
+ platformRegistrations.delete(key);
190
+ removed += 1;
191
+ }
192
+ }
193
+ for (const [key, held] of [...sends]) {
194
+ if (held.send.request.player === player) {
195
+ sends.delete(key);
196
+ removed += 1;
197
+ }
198
+ }
199
+ for (const [key, held] of [...schedule]) {
200
+ if (held.entry.player === player) {
201
+ schedule.delete(key);
202
+ removed += 1;
203
+ }
204
+ }
205
+ return { deleted: removed, retained: 0 };
206
+ },
166
207
  inspect() {
167
208
  return copy({
168
209
  consent: consent.map((row) => ({ ...row.record, player: row.player })),
@@ -7,6 +7,13 @@ export interface VerifyPurchaseResponse {
7
7
  readonly alreadyProcessed: boolean;
8
8
  readonly reason?: string;
9
9
  readonly finalization?: PurchaseGrantFinalization;
10
+ /**
11
+ * This player's grant comes from a verified restore of a purchase the
12
+ * store binds to another player (a reinstall or another device): the game
13
+ * can say the purchase was restored to this device. Present only on a
14
+ * verified response.
15
+ */
16
+ readonly restoredAcrossPlayers?: true;
10
17
  }
11
18
  export type PurchaseGrantFinalizationAction = 'acknowledge' | 'consume' | 'finish' | 'complete';
12
19
  export interface PurchaseGrantFinalization {
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Restore sharing (RFC "Restore sharing"): a guest lives in the device's
3
+ * secure store, so a reinstall, a new device, or a second device on the same
4
+ * store account is a new guest while the store keeps returning the
5
+ * purchase's non-consumable with the buyer's account binding (Google Play
6
+ * `obfuscatedAccountId`, StoreKit `appAccountToken`). Under the `share`
7
+ * policy the backend accepts the store's own verification of a restored
8
+ * non-consumable as the proof of ownership and grants the restoring player
9
+ * its own entitlement, linked to the purchase. Earlier holders keep theirs,
10
+ * up to `maxHolders` players per purchase; a refund or revocation of the
11
+ * purchase revokes every holder. Consumables and subscriptions are never
12
+ * shared.
13
+ */
14
+ /** How the backend answers a restored non-consumable the store binds to another player. */
15
+ export interface PurchaseRestoreSharingPolicy {
16
+ /**
17
+ * `share` grants the restoring player its own entitlement; `refuse`
18
+ * answers the store's binding mismatch (`GOOGLE_PLAY_ACCOUNT_MISMATCH`,
19
+ * `APP_STORE_ACCOUNT_TOKEN_MISMATCH`) as before.
20
+ */
21
+ readonly mode: 'share' | 'refuse';
22
+ /**
23
+ * At most this many players hold one purchase at the same time (the first
24
+ * grant included; a deleted player's retained record and reversed grants
25
+ * do not count). A restore past it answers `pending` with
26
+ * `PURCHASE_HOLDER_LIMIT_REACHED`; no holder is evicted. Defaults to 5.
27
+ */
28
+ readonly maxHolders?: number;
29
+ }
30
+ export declare const defaultPurchaseRestoreMaxHolders = 5;
31
+ /** The policy `createGameServicesBackend` uses when none is passed: restores across players are refused. */
32
+ export declare const refusePurchaseRestoreSharing: PurchaseRestoreSharingPolicy;
33
+ /** The reference stack's policy: share, up to the default number of holders. */
34
+ export declare const sharePurchaseRestores: PurchaseRestoreSharingPolicy;
35
+ /** The largest `maxHolders` a policy accepts; a purchase's holders are read one by one. */
36
+ export declare const maxPurchaseRestoreHolders = 100;
37
+ export interface ResolvedPurchaseRestoreSharingPolicy {
38
+ readonly mode: 'share' | 'refuse';
39
+ readonly maxHolders: number;
40
+ }
41
+ export declare function resolvePurchaseRestoreSharingPolicy(input: PurchaseRestoreSharingPolicy | undefined): ResolvedPurchaseRestoreSharingPolicy;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Restore sharing (RFC "Restore sharing"): a guest lives in the device's
3
+ * secure store, so a reinstall, a new device, or a second device on the same
4
+ * store account is a new guest while the store keeps returning the
5
+ * purchase's non-consumable with the buyer's account binding (Google Play
6
+ * `obfuscatedAccountId`, StoreKit `appAccountToken`). Under the `share`
7
+ * policy the backend accepts the store's own verification of a restored
8
+ * non-consumable as the proof of ownership and grants the restoring player
9
+ * its own entitlement, linked to the purchase. Earlier holders keep theirs,
10
+ * up to `maxHolders` players per purchase; a refund or revocation of the
11
+ * purchase revokes every holder. Consumables and subscriptions are never
12
+ * shared.
13
+ */
14
+ export const defaultPurchaseRestoreMaxHolders = 5;
15
+ /** The policy `createGameServicesBackend` uses when none is passed: restores across players are refused. */
16
+ export const refusePurchaseRestoreSharing = Object.freeze({
17
+ mode: 'refuse',
18
+ });
19
+ /** The reference stack's policy: share, up to the default number of holders. */
20
+ export const sharePurchaseRestores = Object.freeze({
21
+ mode: 'share',
22
+ });
23
+ /** The largest `maxHolders` a policy accepts; a purchase's holders are read one by one. */
24
+ export const maxPurchaseRestoreHolders = 100;
25
+ export function resolvePurchaseRestoreSharingPolicy(input) {
26
+ const policy = input ?? refusePurchaseRestoreSharing;
27
+ if (policy.mode !== 'share' && policy.mode !== 'refuse') {
28
+ throw new Error('restoreSharing.mode must be share or refuse.');
29
+ }
30
+ const maxHolders = policy.maxHolders ?? defaultPurchaseRestoreMaxHolders;
31
+ if (!Number.isSafeInteger(maxHolders)
32
+ || maxHolders < 1
33
+ || maxHolders > maxPurchaseRestoreHolders) {
34
+ throw new Error(`restoreSharing.maxHolders must be an integer from 1 to ${String(maxPurchaseRestoreHolders)}.`);
35
+ }
36
+ return { mode: policy.mode, maxHolders };
37
+ }
package/dist/runtime.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type AccountDeletionClient } from './account-deletion.js';
1
2
  import type { AnalyticsSink } from '@mpgd/analytics';
2
3
  import type { PlatformGateway, PlatformTarget } from '@mpgd/platform';
3
4
  import { type GameServicesBackendApi, type GameServicesBackendTransport, type GameServicesClient, type GameServicesHeaderResolver } from './client.js';
@@ -15,6 +16,11 @@ export interface GameServicesRuntime {
15
16
  readonly target?: GameServicesLedgerTarget;
16
17
  readonly client?: GameServicesClient;
17
18
  readonly monetizationRecovery?: RecoverableMonetizationClient;
19
+ /**
20
+ * The signed-in player's account deletion (`POST /game-services/account/delete`)
21
+ * with the runtime's headers; present with an authoritative `baseUrl`.
22
+ */
23
+ readonly accountDeletion?: AccountDeletionClient;
18
24
  }
19
25
  interface CreateGameServicesRuntimeCommonInput {
20
26
  readonly gateway: PlatformGateway;
package/dist/runtime.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createAccountDeletionClient } from './account-deletion.js';
1
2
  import { createGameServicesClient, createGameServicesFetchBackendTransport, createGameServicesHttpBackendApi, createGameServicesOrpcBackendApi, createGameServicesOrpcClient, resolveGameServicesRequestHeaders, } from './client.js';
2
3
  import { createRecoverableMonetizationClient, } from './monetization-recovery.js';
3
4
  export function createGameServicesRuntime(input) {
@@ -93,6 +94,18 @@ export function createGameServicesRuntime(input) {
93
94
  target,
94
95
  client: routedClient,
95
96
  ...(monetizationRecovery === undefined ? {} : { monetizationRecovery }),
97
+ ...(baseUrl === undefined
98
+ ? {}
99
+ : {
100
+ accountDeletion: createAccountDeletionClient({
101
+ // The deletion route is plain HTTP next to the oRPC handler: an
102
+ // oRPC URL names the handler's `/rpc` prefix, which the route
103
+ // does not sit under.
104
+ baseUrl: input.transport === 'orpc' ? baseUrl.replace(/\/rpc\/?$/u, '') : baseUrl,
105
+ ...(input.headers === undefined ? {} : { headers: input.headers }),
106
+ ...(input.getHeaders === undefined ? {} : { getHeaders: input.getHeaders }),
107
+ }),
108
+ }),
96
109
  };
97
110
  }
98
111
  function withRuntimeHeaders(transport, input) {