@mpgd/cli 0.52.0 → 0.54.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 (29) hide show
  1. package/dist/capacitor-shell-starter.d.ts +22 -0
  2. package/dist/capacitor-shell-starter.js +395 -18
  3. package/dist/deploy-planning.js +2 -2
  4. package/dist/index.js +122 -34
  5. package/dist/kit-upgrade.js +2 -2
  6. package/dist/native-build-info.json +2 -2
  7. package/dist/native-build-target.js +433 -2
  8. package/dist/notifications-starter.d.ts +75 -0
  9. package/dist/notifications-starter.js +522 -0
  10. package/dist/offline-playtest.js +1 -1
  11. package/dist/tooling-migration.js +1 -1
  12. package/package.json +16 -16
  13. package/templates/notifications-overlay/common/docs/NOTIFICATIONS.md +104 -0
  14. package/templates/notifications-overlay/common/notifications/deployment.json +8 -0
  15. package/templates/notifications-overlay/common/notifications/topics.json +222 -0
  16. package/templates/notifications-overlay/common/src/platform/capacitorNotifications.ts +59 -0
  17. package/templates/notifications-overlay/common/src/platform/notificationBackend.ts +157 -0
  18. package/templates/notifications-overlay/common/src/platform/notificationOpens.ts +60 -0
  19. package/templates/notifications-overlay/common/src/platform/notificationSettings.ts +761 -0
  20. package/templates/notifications-overlay/common/src/platform/notifications.ts +163 -0
  21. package/templates/notifications-overlay/common/tools/validate-notifications.ts +75 -0
  22. package/templates/notifications-overlay/lite/src/lite/notificationSettingsPanel.ts +200 -0
  23. package/templates/notifications-overlay/phaser/src/scenes/NotificationSettingsScene.ts +301 -0
  24. package/templates/phaser-game/apps/target-ait/src/main.ts +15 -0
  25. package/templates/phaser-game/apps/target-ait/src/runtime-config.types.ts +5 -0
  26. package/templates/phaser-game/apps/target-ait/vite.config.ts +22 -1
  27. package/templates/phaser-game/docs/MPGD_KIT_WORKFLOWS.md +14 -0
  28. package/templates/phaser-game/src/platform/gameServices.ts +3 -0
  29. package/templates/phaser-game/src/platform/serverSession.ts +66 -0
@@ -0,0 +1,104 @@
1
+ # Notifications in __GAME_TITLE__
2
+
3
+ This game was created with `mpgd game create --providers notifications`. The
4
+ mpgd-kit notifications guide (`docs/NOTIFICATIONS.md` in mpgd-kit) explains
5
+ the whole flow; this page lists what the starter put in this game and what is
6
+ left for you.
7
+
8
+ ## Files
9
+
10
+ | File | What it is |
11
+ | --- | --- |
12
+ | `notifications/topics.json` | The topic catalog: each topic's purpose, channels, routes, copy, and per-target mapping. The one source for every target and the backend. |
13
+ | `notifications/deployment.json` | The backend settings: `advertisingRules` (`kr`), the advertising sender, and the zone. |
14
+ | `src/platform/notifications.ts` | Reads the catalog for a target, and the place to return your notifications runtime. |
15
+ | `src/platform/capacitorNotifications.ts` | The Android and iOS provider (FCM and local reminders), registered by the Capacitor gateways. |
16
+ | `src/platform/notificationSettings.ts` | The settings screen's logic: permission, reminders, advertising and night consent, notices. |
17
+ | `src/platform/notificationOpens.ts` | The game's one listener for notification opens: the settings route, and an `other` route for your topics. |
18
+ | `src/scenes/NotificationSettingsScene.ts` or `src/lite/notificationSettingsPanel.ts` | The settings screen the game renders. |
19
+ | `tools/validate-notifications.ts` | `pnpm validate:notifications`. |
20
+
21
+ ## Before you release
22
+
23
+ 1. Replace `REPLACE-WITH-SENDER-PHONE-OR-ADDRESS` in
24
+ `notifications/deployment.json` with the sender's phone number or
25
+ address. Every advertising push carries it under the Korean rules, and
26
+ `pnpm validate:notifications` fails until you do. The game backend
27
+ refuses the placeholder as well: a Worker given this file as is does not
28
+ start its notifications.
29
+ 2. Keep the sender in `notices.copy` (`[__GAME_TITLE__]` in every variant) the
30
+ same as `advertisingSender.name` in `notifications/deployment.json`: a
31
+ consent notice must name the sender.
32
+ 3. Run `pnpm validate:notifications` after every catalog change.
33
+ 4. Decide each topic's purpose with your own legal review. The starter's
34
+ `daily-ready` and `streak-at-risk` are `requested`: off until the player
35
+ turns them on, and their copy states only the player's own progress. If
36
+ you add a reward, a benefit, or a promotion to them, or send them to
37
+ players who did not turn them on, declare them `advertising` instead.
38
+ 5. Android and iOS: set up Firebase (see the mpgd-kit Firebase guide,
39
+ `docs/FIREBASE_NOTIFICATIONS.md`), then run
40
+ `mpgd target init capacitor --providers notifications` to install the two
41
+ plugins into the game-owned shell. Never install
42
+ `@capacitor/push-notifications` next to them.
43
+ 6. Advertising on Android: the sample `events` topic is mapped to iOS only.
44
+ An Android FCM notification cannot carry the opt-out action that the
45
+ Korean rules require on every advertising push (a settings path in the
46
+ text does not replace it), and the kit does not render Android
47
+ advertising natively yet. Do not add an `android` mapping to an
48
+ advertising topic until it does.
49
+ 7. Apps in Toss: register each functional-message template in the console and
50
+ add its code to the topic's `targets.ait` mapping
51
+ (`{ "templateSetCode": "...", "agreement": "required" }`). Apps in Toss
52
+ cannot deliver advertising topics.
53
+ 8. Reddit: apply for Devvit notifications; keep `integrations.notifications`
54
+ at `approval-required` until Reddit approves the app and its copy.
55
+
56
+ ## The notifications runtime
57
+
58
+ Consent records (reminders, 광고성 정보 수신동의, night consent), endpoint
59
+ registration, server reminders, and consent notices go through the game
60
+ backend. `createStarterNotificationBackend` in
61
+ `src/platform/notifications.ts` builds the notifications runtime
62
+ (`@mpgd/platform/notifications-runtime`) over the game-services Worker's
63
+ notification routes (`@mpgd/game-services/notifications/client`) when the
64
+ game has both:
65
+
66
+ - a game-services URL, `VITE_MPGD_GAME_SERVICES_URL`, the one the commerce
67
+ client uses (a public https URL in production); and
68
+ - a server-backed session, which the game sets with
69
+ `setStarterServerSession` in `src/platform/serverSession.ts` at the top of
70
+ `src/main.ts`, before `bootstrap()`: the signed-in `{ playerId, sessionId }`
71
+ (`undefined` until the player signs in), a sign-in subscription, and the
72
+ session's `Authorization` header. The commerce client then sends the same
73
+ header. `sessionId` must be the stable session-family id: the `sessionId` your
74
+ identity service answers to the Worker's `authenticateNotificationSession`
75
+ for this session's header, the same across token refreshes (see the kit's
76
+ `docs/CLOUDFLARE_WORKER_DEPLOY.md`, "Sessions"). Do not pass a per-token
77
+ or rotating id: a `GuestSessionCoordinator` view's `sessionId` may rotate
78
+ on refresh, so it is not that id.
79
+
80
+ Without either, it returns `undefined` and the settings screen asks only
81
+ for the OS or host permission (and withdraws a Devvit opt-in the game
82
+ rendered). With them, the runtime registers the device for the signed-in
83
+ player, keeps its registration state in the platform storage, and
84
+ `listConsent` reads the player's consent records (the Worker's
85
+ `GET /consent`). The runtime also loads the Worker's registry entry for
86
+ its target (`GET /providers`) and turns notifications into a configuration
87
+ error when the provider disagrees with it; a Worker it cannot reach is
88
+ logged and the runtime runs without that check.
89
+
90
+ Reddit never gets this backend, even with a URL and a session: a Reddit
91
+ game's notifications backend is its Devvit server, which keeps the opt-in,
92
+ the records, and the sends, and the Worker serves no `reddit` target. The
93
+ Worker serves the Android, iOS, and Apps in Toss providers.
94
+
95
+ The settings screen shows each
96
+ switch from the latest record, read every time it opens and after every
97
+ change, so a choice made on another device shows. A tap reads the records
98
+ again and writes only what changes. It keeps nothing on the device, and
99
+ shows no switch when the records cannot be read.
100
+
101
+ Night consent adds to 광고성 정보 수신동의: the screen offers it only while
102
+ advertising consent is granted, and turning advertising consent off also
103
+ withdraws a granted night consent in the same call (two records, two result
104
+ notices).
@@ -0,0 +1,8 @@
1
+ {
2
+ "advertisingRules": "kr",
3
+ "advertisingSender": {
4
+ "name": "__GAME_TITLE__",
5
+ "contact": "REPLACE-WITH-SENDER-PHONE-OR-ADDRESS"
6
+ },
7
+ "timeZone": "Asia/Seoul"
8
+ }
@@ -0,0 +1,222 @@
1
+ {
2
+ "version": "1",
3
+ "defaultLocale": "ko",
4
+ "notices": {
5
+ "topic": "consent-notice",
6
+ "copy": {
7
+ "ko": {
8
+ "consent-result.advertising.granted": {
9
+ "title": "[__GAME_TITLE__] 광고성 정보 수신동의 결과",
10
+ "body": "{{consentDate}}에 하신 광고성 정보 수신동의가 처리됐어요. 알림 설정에서 언제든 철회할 수 있어요."
11
+ },
12
+ "consent-result.advertising.refused": {
13
+ "title": "[__GAME_TITLE__] 광고성 정보 수신거부 결과",
14
+ "body": "{{consentDate}}에 하신 광고성 정보 수신거부가 처리됐어요."
15
+ },
16
+ "consent-result.advertising.withdrawn": {
17
+ "title": "[__GAME_TITLE__] 광고성 정보 수신동의 철회 결과",
18
+ "body": "{{consentDate}}에 하신 광고성 정보 수신동의 철회가 처리됐어요."
19
+ },
20
+ "consent-result.night.granted": {
21
+ "title": "[__GAME_TITLE__] 야간 광고성 정보 수신동의 결과",
22
+ "body": "{{consentDate}}에 하신 야간(21시~다음 날 8시) 광고성 정보 수신동의가 처리됐어요. 알림 설정에서 언제든 철회할 수 있어요."
23
+ },
24
+ "consent-result.night.refused": {
25
+ "title": "[__GAME_TITLE__] 야간 광고성 정보 수신거부 결과",
26
+ "body": "{{consentDate}}에 하신 야간(21시~다음 날 8시) 광고성 정보 수신거부가 처리됐어요."
27
+ },
28
+ "consent-result.night.withdrawn": {
29
+ "title": "[__GAME_TITLE__] 야간 광고성 정보 수신동의 철회 결과",
30
+ "body": "{{consentDate}}에 하신 야간(21시~다음 날 8시) 광고성 정보 수신동의 철회가 처리됐어요."
31
+ },
32
+ "reconfirmation.advertising": {
33
+ "title": "[__GAME_TITLE__] 광고성 정보 수신동의 확인",
34
+ "body": "{{consentDate}}에 광고성 정보 수신에 동의하셨어요. 동의를 유지하려면 따로 하실 일은 없고, 철회하려면 게임의 알림 설정에서 광고성 정보 수신을 끄면 돼요."
35
+ },
36
+ "reconfirmation.night": {
37
+ "title": "[__GAME_TITLE__] 야간 광고성 정보 수신동의 확인",
38
+ "body": "{{consentDate}}에 야간(21시~다음 날 8시) 광고성 정보 수신에 동의하셨어요. 동의를 유지하려면 따로 하실 일은 없고, 철회하려면 게임의 알림 설정에서 야간 수신을 끄면 돼요."
39
+ }
40
+ },
41
+ "en": {
42
+ "consent-result.advertising.granted": {
43
+ "title": "[__GAME_TITLE__] Promotional notifications: consent processed",
44
+ "body": "Your consent to promotional notifications on {{consentDate}} has been processed. You can withdraw it any time in the game's notification settings."
45
+ },
46
+ "consent-result.advertising.refused": {
47
+ "title": "[__GAME_TITLE__] Promotional notifications: refusal processed",
48
+ "body": "Your refusal of promotional notifications on {{consentDate}} has been processed."
49
+ },
50
+ "consent-result.advertising.withdrawn": {
51
+ "title": "[__GAME_TITLE__] Promotional notifications: withdrawal processed",
52
+ "body": "Your withdrawal of consent to promotional notifications on {{consentDate}} has been processed."
53
+ },
54
+ "consent-result.night.granted": {
55
+ "title": "[__GAME_TITLE__] Night-time promotional notifications: consent processed",
56
+ "body": "Your consent to promotional notifications between 21:00 and 08:00 on {{consentDate}} has been processed. You can withdraw it any time in the game's notification settings."
57
+ },
58
+ "consent-result.night.refused": {
59
+ "title": "[__GAME_TITLE__] Night-time promotional notifications: refusal processed",
60
+ "body": "Your refusal of promotional notifications between 21:00 and 08:00 on {{consentDate}} has been processed."
61
+ },
62
+ "consent-result.night.withdrawn": {
63
+ "title": "[__GAME_TITLE__] Night-time promotional notifications: withdrawal processed",
64
+ "body": "Your withdrawal of consent to promotional notifications between 21:00 and 08:00 on {{consentDate}} has been processed."
65
+ },
66
+ "reconfirmation.advertising": {
67
+ "title": "[__GAME_TITLE__] Promotional notifications: your consent",
68
+ "body": "You agreed to promotional notifications on {{consentDate}}. To keep this consent you need do nothing; to withdraw it, turn promotional notifications off in the game's notification settings."
69
+ },
70
+ "reconfirmation.night": {
71
+ "title": "[__GAME_TITLE__] Night-time promotional notifications: your consent",
72
+ "body": "You agreed to promotional notifications between 21:00 and 08:00 on {{consentDate}}. To keep this consent you need do nothing; to withdraw it, turn night-time promotional notifications off in the game's notification settings."
73
+ }
74
+ }
75
+ }
76
+ },
77
+ "topics": [
78
+ {
79
+ "id": "daily-ready",
80
+ "purpose": "requested",
81
+ "channels": [
82
+ "local",
83
+ "remote"
84
+ ],
85
+ "route": "/?entry=continue",
86
+ "copy": {
87
+ "ko": {
88
+ "title": "오늘의 플레이",
89
+ "body": "오늘의 판이 준비됐어요."
90
+ },
91
+ "en": {
92
+ "title": "Today's run",
93
+ "body": "Today's run is ready."
94
+ }
95
+ },
96
+ "frequency": {
97
+ "maxPerDay": 1
98
+ },
99
+ "quietHours": {
100
+ "start": "21:00",
101
+ "end": "08:00"
102
+ },
103
+ "targets": {
104
+ "android": {
105
+ "channelId": "mpgd-requested"
106
+ },
107
+ "ios": {},
108
+ "reddit": {
109
+ "title": "Today's run is ready",
110
+ "body": "Today's run is ready when you are."
111
+ }
112
+ }
113
+ },
114
+ {
115
+ "id": "streak-at-risk",
116
+ "purpose": "requested",
117
+ "channels": [
118
+ "local",
119
+ "remote"
120
+ ],
121
+ "route": "/?entry=continue",
122
+ "copy": {
123
+ "ko": {
124
+ "title": "연속 플레이 기록",
125
+ "body": "오늘 플레이하면 연속 기록이 이어져요."
126
+ },
127
+ "en": {
128
+ "title": "Your streak",
129
+ "body": "Play today to keep your streak going."
130
+ }
131
+ },
132
+ "frequency": {
133
+ "maxPerDay": 1
134
+ },
135
+ "quietHours": {
136
+ "start": "21:00",
137
+ "end": "08:00"
138
+ },
139
+ "targets": {
140
+ "android": {
141
+ "channelId": "mpgd-requested"
142
+ },
143
+ "ios": {},
144
+ "reddit": {
145
+ "title": "Your streak",
146
+ "body": "Play today to keep your streak going."
147
+ }
148
+ }
149
+ },
150
+ {
151
+ "id": "friend-challenge",
152
+ "purpose": "service",
153
+ "channels": [
154
+ "remote"
155
+ ],
156
+ "route": "/?entry=challenge",
157
+ "copy": {
158
+ "ko": {
159
+ "title": "친구의 도전",
160
+ "body": "{{friend}} 님이 도전장을 보냈어요."
161
+ },
162
+ "en": {
163
+ "title": "Friend challenge",
164
+ "body": "{{friend}} sent you a challenge."
165
+ }
166
+ },
167
+ "quietHours": {
168
+ "start": "22:00",
169
+ "end": "08:00"
170
+ },
171
+ "targets": {
172
+ "android": {
173
+ "channelId": "mpgd-service"
174
+ },
175
+ "ios": {},
176
+ "reddit": {
177
+ "title": "Friend challenge",
178
+ "body": "A friend sent you a challenge."
179
+ }
180
+ }
181
+ },
182
+ {
183
+ "id": "events",
184
+ "purpose": "advertising",
185
+ "channels": [
186
+ "remote"
187
+ ],
188
+ "route": "/?entry=events",
189
+ "optOutRoute": "/?entry=notification-settings",
190
+ "copy": {
191
+ "ko": {
192
+ "title": "이번 주 이벤트",
193
+ "body": "새 이벤트가 시작됐어요."
194
+ },
195
+ "en": {
196
+ "title": "This week's event",
197
+ "body": "A new event has started."
198
+ }
199
+ },
200
+ "frequency": {
201
+ "maxPerDay": 1
202
+ },
203
+ "targets": {
204
+ "ios": {}
205
+ }
206
+ },
207
+ {
208
+ "id": "consent-notice",
209
+ "purpose": "service",
210
+ "channels": [
211
+ "remote"
212
+ ],
213
+ "route": "/?entry=notification-settings",
214
+ "targets": {
215
+ "android": {
216
+ "channelId": "mpgd-service"
217
+ },
218
+ "ios": {}
219
+ }
220
+ }
221
+ ]
222
+ }
@@ -0,0 +1,59 @@
1
+ import type { CapacitorServiceProvider } from '@mpgd/adapter-capacitor';
2
+ import {
3
+ createCapacitorNotificationServiceProvider,
4
+ loadCapacitorNotificationSdks,
5
+ openCapacitorNotificationSettings,
6
+ } from '@mpgd/adapter-capacitor/notifications';
7
+
8
+ import { starterNotificationTopics } from './notifications';
9
+
10
+ /**
11
+ * The Android and iOS notifications v2 provider over
12
+ * `@capacitor-firebase/messaging` (remote, FCM for both platforms) and
13
+ * `@capacitor/local-notifications` (local reminders, the permission read,
14
+ * and the advertising opt-out action). `mpgd target init capacitor
15
+ * --providers notifications` installs both into the native shell. A shell
16
+ * without them still runs: the provider fails closed for the missing half,
17
+ * and without both it is `unsupported`.
18
+ *
19
+ * It is created once, early, because the provider registers its native
20
+ * listeners when it is created, which is how a cold-start open reaches it.
21
+ * Never install `@capacitor/push-notifications` next to it: on iOS both
22
+ * claim Capacitor's one push handler.
23
+ */
24
+ export async function createStarterCapacitorNotifications(
25
+ platform: 'android' | 'ios',
26
+ configTarget: string,
27
+ ): Promise<readonly CapacitorServiceProvider[]> {
28
+ const topics = starterNotificationTopics(configTarget);
29
+ if (topics.length === 0) {
30
+ return [];
31
+ }
32
+ const sdks = await loadCapacitorNotificationSdks();
33
+ const korean = (globalThis.navigator?.language ?? '').toLowerCase().startsWith('ko');
34
+ return [
35
+ createCapacitorNotificationServiceProvider({
36
+ platform,
37
+ topics,
38
+ ...sdks,
39
+ // Android channel names the player sees in system settings, in the
40
+ // device language. The provider creates a channel only for the topics
41
+ // mapped to Android; `mpgd-advertising` is named for the day an
42
+ // advertising topic is mapped there, in a channel of its own the
43
+ // player can turn off.
44
+ channels: korean
45
+ ? {
46
+ 'mpgd-service': { name: '알림' },
47
+ 'mpgd-requested': { name: '리마인더' },
48
+ 'mpgd-advertising': { name: '광고성 정보' },
49
+ }
50
+ : {
51
+ 'mpgd-service': { name: 'Notifications' },
52
+ 'mpgd-requested': { name: 'Reminders' },
53
+ 'mpgd-advertising': { name: 'Promotions' },
54
+ },
55
+ advertisingOptOutTitle: korean ? '수신거부' : 'Unsubscribe',
56
+ openSettings: openCapacitorNotificationSettings,
57
+ }),
58
+ ];
59
+ }
@@ -0,0 +1,157 @@
1
+ import {
2
+ isPublicGameServicesUrl,
3
+ resolveGameServicesAuthorityMode,
4
+ type GameServicesHeaderResolver,
5
+ } from '@mpgd/game-services';
6
+ import {
7
+ createGameServicesNotificationsClient,
8
+ type GameServicesNotificationIdentity,
9
+ type GameServicesNotificationsFetch,
10
+ } from '@mpgd/game-services/notifications/client';
11
+ import type {
12
+ NotificationConsentRecord,
13
+ NotificationRuntime,
14
+ NotificationTopicDeclaration,
15
+ } from '@mpgd/platform/notifications';
16
+ import {
17
+ createNotificationRuntime,
18
+ createNotificationRuntimeStorage,
19
+ } from '@mpgd/platform/notifications-runtime';
20
+ import type { TargetConfiguredGateway } from '@mpgd/target-config';
21
+
22
+ /**
23
+ * The game backend's notifications surface the starter uses: the runtime
24
+ * (notifications RFC "Runtime"), which composes the target's provider with
25
+ * the backend that keeps consent records, endpoint registrations, server
26
+ * reminders, and notices; and the signed-in player's consent records (the
27
+ * game-services Worker's `GET /consent`), which the settings screen shows.
28
+ */
29
+ export interface StarterNotificationBackend {
30
+ readonly runtime: NotificationRuntime;
31
+ listConsent(): Promise<readonly NotificationConsentRecord[]>;
32
+ }
33
+
34
+ /** The server-backed session the Worker client needs (see `serverSession.ts`). */
35
+ export interface StarterWorkerSession extends GameServicesNotificationIdentity {
36
+ readonly getHeaders: GameServicesHeaderResolver;
37
+ }
38
+
39
+ export interface StarterWorkerNotificationInput {
40
+ /** `VITE_MPGD_GAME_SERVICES_URL`. */
41
+ readonly baseUrl: string | undefined;
42
+ /** The Vite mode (`import.meta.env.MODE`), which decides the production URL rule. */
43
+ readonly mode: string;
44
+ readonly session: StarterWorkerSession | undefined;
45
+ /** This target's topics, each with its mapping. */
46
+ readonly topics: readonly NotificationTopicDeclaration[];
47
+ readonly defaultLocale: string;
48
+ /** Logs a message, once per gateway and message. */
49
+ readonly log: (message: string, error?: unknown) => void;
50
+ /** Defaults to `globalThis.fetch`. */
51
+ readonly fetch?: GameServicesNotificationsFetch;
52
+ }
53
+
54
+ /**
55
+ * Config targets whose notifications backend is not the game-services
56
+ * Worker. A Reddit game's backend is its Devvit server: the opt-in, the
57
+ * consent records, and the sends live there, and the Worker serves no
58
+ * `reddit` target. A runtime over the Worker would send the game's session
59
+ * header to a backend that cannot use it, so Reddit keeps no backend here,
60
+ * as before R1, until the Devvit server has a runtime client of its own.
61
+ */
62
+ const targetsWithoutWorkerBackend: ReadonlySet<string> = new Set(['reddit']);
63
+
64
+ /**
65
+ * The notifications runtime over the game-services Worker's notification
66
+ * routes (`@mpgd/game-services/notifications/client`), when the target's
67
+ * backend is the Worker, the game has a game-services URL and a
68
+ * server-backed session, and the target exposes a notifications provider.
69
+ * Otherwise `undefined`.
70
+ *
71
+ * The runtime keeps its registration state in the platform storage, starts
72
+ * here, and compares its provider with the Worker's registry entry for this
73
+ * target (`GET /providers`), loaded in the background: a Worker it cannot
74
+ * reach is logged once and the runtime runs without the comparison.
75
+ */
76
+ export function createStarterWorkerNotificationBackend(
77
+ platform: TargetConfiguredGateway,
78
+ input: StarterWorkerNotificationInput,
79
+ ): StarterNotificationBackend | undefined {
80
+ // By platform, and by config target name for a gateway that reports another platform.
81
+ if (platform.target === 'reddit' || targetsWithoutWorkerBackend.has(platform.configTarget)) {
82
+ return undefined;
83
+ }
84
+ const baseUrl = input.baseUrl?.trim();
85
+ const provider = platform.notificationsV2;
86
+ if (baseUrl === undefined || baseUrl === '' || provider === undefined) {
87
+ return undefined;
88
+ }
89
+ const { session } = input;
90
+ if (session === undefined) {
91
+ input.log(
92
+ '[notifications] VITE_MPGD_GAME_SERVICES_URL is set but no server session is: call setStarterServerSession '
93
+ + '(src/platform/serverSession.ts) before the game starts. The settings screen shows no switch until then.',
94
+ );
95
+ return undefined;
96
+ }
97
+ try {
98
+ if (!isAllowedGameServicesUrl(baseUrl, input.mode)) {
99
+ // The session's credentials never travel in clear text or to a private host in production.
100
+ input.log(
101
+ '[notifications] VITE_MPGD_GAME_SERVICES_URL must be an absolute http(s) URL, and a public https URL '
102
+ + 'without credentials in production; notifications stay off.',
103
+ );
104
+ return undefined;
105
+ }
106
+ const client = createGameServicesNotificationsClient({
107
+ baseUrl,
108
+ identity: session,
109
+ getHeaders: () => session.getHeaders(),
110
+ // The catalog target whose mapping applies to this device (android or ios share FCM).
111
+ target: platform.configTarget,
112
+ ...(input.fetch === undefined ? {} : { fetch: input.fetch }),
113
+ });
114
+ const runtime = createNotificationRuntime({
115
+ provider,
116
+ backend: client,
117
+ catalog: input.topics,
118
+ defaultLocale: input.defaultLocale,
119
+ storage: createNotificationRuntimeStorage({ storage: platform.storage }),
120
+ // This target's entry in the Worker's registry, or none when the Worker does not serve it.
121
+ async loadRegistryEntry() {
122
+ const { providers, targets } = await client.getProviders();
123
+ const id = targets[platform.configTarget];
124
+ return id === undefined ? undefined : providers.find((entry) => entry.provider === id);
125
+ },
126
+ onError(error) {
127
+ console.error('[notifications]', error);
128
+ },
129
+ });
130
+ // It subscribes to the provider at once; a backend that cannot answer yet does not fail the game.
131
+ runtime.start().catch((error: unknown) => {
132
+ console.error('[notifications] the runtime could not start.', error);
133
+ });
134
+ return { runtime, listConsent: () => client.listConsent() };
135
+ } catch (error) {
136
+ // A configuration the client refuses turns notifications off, never the game.
137
+ input.log('[notifications] the notifications backend could not be created.', error);
138
+ return undefined;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Whether the game-services URL may carry the session's credentials: any
144
+ * absolute http(s) URL outside production builds, and in production the
145
+ * public https URL the commerce client requires (`isPublicGameServicesUrl`).
146
+ */
147
+ function isAllowedGameServicesUrl(baseUrl: string, mode: string): boolean {
148
+ if (resolveGameServicesAuthorityMode(mode) === 'production') {
149
+ return isPublicGameServicesUrl(baseUrl);
150
+ }
151
+ try {
152
+ const { protocol } = new URL(baseUrl);
153
+ return protocol === 'https:' || protocol === 'http:';
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
@@ -0,0 +1,60 @@
1
+ import { isRootRelativeNotificationRoute } from '@mpgd/catalog';
2
+ import type { NotificationEvent } from '@mpgd/platform/notifications';
3
+
4
+ /** The route the advertising opt-out action and the consent notices open. */
5
+ export const starterNotificationSettingsRoute = '/?entry=notification-settings';
6
+
7
+ export interface NotificationOpenRoutes {
8
+ /** An open whose route is the settings screen. */
9
+ readonly settings: () => void;
10
+ /** Every other open, as the provider reported it, with `route` only when it is safe. */
11
+ readonly other?: (open: Omit<Extract<NotificationEvent, { readonly type: 'opened' }>, 'route'> & {
12
+ readonly route?: string;
13
+ }) => void;
14
+ }
15
+
16
+ /**
17
+ * The game's one listener for notification opens. It is the runtime's (or
18
+ * the provider's) first subscriber, which is the one that receives a
19
+ * cold-start open (RFC "Buffer before the first listener"), so route every
20
+ * topic's opens here rather than in a later subscriber. The starter routes
21
+ * the settings screen: the advertising opt-out action and the consent
22
+ * notices carry `starterNotificationSettingsRoute`, and the RFC requires the
23
+ * opt-out to open that screen directly. `other` receives every other open,
24
+ * with a route only when `isRootRelativeNotificationRoute` accepts it. The
25
+ * data an open carries is untrusted and never grants anything. Returns the
26
+ * unsubscribe.
27
+ */
28
+ export function routeNotificationOpens(
29
+ source: { subscribe(listener: (event: NotificationEvent) => void): () => void },
30
+ routes: NotificationOpenRoutes,
31
+ ): () => void {
32
+ return source.subscribe((event) => {
33
+ if (event.type !== 'opened') {
34
+ return;
35
+ }
36
+ const route = event.route !== undefined && isRootRelativeNotificationRoute(event.route)
37
+ ? event.route
38
+ : undefined;
39
+ if (route !== undefined && isSettingsRoute(route)) {
40
+ routes.settings();
41
+ return;
42
+ }
43
+ const { route: _unsafe, ...rest } = event;
44
+ routes.other?.({ ...rest, ...(route === undefined ? {} : { route }) });
45
+ });
46
+ }
47
+
48
+ const settingsEntry = new URL(starterNotificationSettingsRoute, 'https://game.invalid')
49
+ .searchParams.get('entry');
50
+
51
+ /**
52
+ * Whether a route opens the settings screen: the same `entry` as
53
+ * `starterNotificationSettingsRoute`. Only routes
54
+ * `isRootRelativeNotificationRoute` accepted reach it, which parse.
55
+ */
56
+ function isSettingsRoute(route: string): boolean {
57
+ // A settings route without an `entry` would match every route: fail closed.
58
+ return settingsEntry !== null
59
+ && new URL(route, 'https://game.invalid').searchParams.get('entry') === settingsEntry;
60
+ }