@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
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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
|
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: {
|
|
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) {
|