@mpgd/cli 0.56.0 → 0.58.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 (49) hide show
  1. package/dist/capacitor-shell-starter.d.ts +30 -0
  2. package/dist/capacitor-shell-starter.js +243 -11
  3. package/dist/index.js +170 -11
  4. package/dist/legal-site-output.d.ts +18 -0
  5. package/dist/legal-site-output.js +46 -1
  6. package/dist/native-build-info.json +2 -2
  7. package/dist/native-build-target.js +3935 -1308
  8. package/dist/native-deploy-run.js +4 -0
  9. package/dist/native-shell-store-policy.d.ts +123 -0
  10. package/dist/native-shell-store-policy.js +816 -0
  11. package/dist/notifications-starter.js +26 -3
  12. package/dist/release-state.d.ts +2 -0
  13. package/dist/release-state.js +4 -2
  14. package/dist/testflight-submission.d.ts +9 -0
  15. package/dist/testflight-submission.js +69 -0
  16. package/package.json +16 -16
  17. package/templates/lite-overlay/agent/acceptance.md +1 -1
  18. package/templates/lite-overlay/agent/brief.md +1 -1
  19. package/templates/lite-overlay/src/lite/deleteAccountButton.ts +173 -0
  20. package/templates/lite-overlay/src/lite/restorePurchasesButton.ts +63 -0
  21. package/templates/lite-overlay/src/runtime/createGame.ts +8 -0
  22. package/templates/notifications-overlay/common/docs/NOTIFICATIONS.md +28 -6
  23. package/templates/notifications-overlay/common/notifications/deployment.json +2 -1
  24. package/templates/notifications-overlay/common/notifications/topics.json +3 -0
  25. package/templates/notifications-overlay/common/src/platform/capacitorNotifications.ts +8 -3
  26. package/templates/notifications-overlay/common/src/platform/notificationBackend.ts +111 -2
  27. package/templates/notifications-overlay/common/src/platform/notifications.ts +36 -4
  28. package/templates/phaser-game/README.md +72 -0
  29. package/templates/phaser-game/agent/acceptance.md +4 -1
  30. package/templates/phaser-game/agent/brief.md +14 -4
  31. package/templates/phaser-game/apps/target-devvit/src/server/bridge.ts +37 -0
  32. package/templates/phaser-game/gitignore +11 -0
  33. package/templates/phaser-game/legal/delete-account.html +133 -0
  34. package/templates/phaser-game/legal/privacy.html +111 -12
  35. package/templates/phaser-game/legal/support.html +1 -0
  36. package/templates/phaser-game/legal/terms.html +1 -0
  37. package/templates/phaser-game/mpgd.game.json +3 -0
  38. package/templates/phaser-game/src/env.d.ts +6 -0
  39. package/templates/phaser-game/src/i18n/messages.ts +86 -1
  40. package/templates/phaser-game/src/main.ts +69 -8
  41. package/templates/phaser-game/src/platform/commerceAccountBinding.ts +39 -10
  42. package/templates/phaser-game/src/platform/deleteAccount.ts +334 -0
  43. package/templates/phaser-game/src/platform/gameServices.ts +55 -10
  44. package/templates/phaser-game/src/platform/guestIdentity.ts +301 -0
  45. package/templates/phaser-game/src/platform/restorePurchases.ts +205 -0
  46. package/templates/phaser-game/src/platform/serverSession.ts +8 -0
  47. package/templates/phaser-game/src/platform/sessionGatedServices.ts +217 -0
  48. package/templates/phaser-game/src/scenes/LobbyScene.ts +254 -1
  49. package/templates/phaser-game/src/scenes/PlayScene.ts +16 -6
@@ -3,6 +3,7 @@ import {
3
3
  createCapacitorNotificationServiceProvider,
4
4
  loadCapacitorNotificationSdks,
5
5
  openCapacitorNotificationSettings,
6
+ readCapacitorAdvertisingPushRenderer,
6
7
  } from '@mpgd/adapter-capacitor/notifications';
7
8
 
8
9
  import { starterNotificationTopics } from './notifications';
@@ -38,9 +39,8 @@ export async function createStarterCapacitorNotifications(
38
39
  ...sdks,
39
40
  // Android channel names the player sees in system settings, in the
40
41
  // 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.
42
+ // mapped to Android; advertising has `mpgd-advertising`, a channel of
43
+ // its own the player can turn off.
44
44
  channels: korean
45
45
  ? {
46
46
  'mpgd-service': { name: '알림' },
@@ -54,6 +54,11 @@ export async function createStarterCapacitorNotifications(
54
54
  },
55
55
  advertisingOptOutTitle: korean ? '수신거부' : 'Unsubscribe',
56
56
  openSettings: openCapacitorNotificationSettings,
57
+ // Android: the shell renders advertising pushes with the opt-out
58
+ // action when its messaging service is the kit's renderer, which
59
+ // `mpgd target init capacitor --providers notifications` declares; the
60
+ // backend sends advertising only to such an installation.
61
+ advertisingRenderer: readCapacitorAdvertisingPushRenderer,
57
62
  }),
58
63
  ];
59
64
  }
@@ -16,6 +16,7 @@ import type {
16
16
  import {
17
17
  createNotificationRuntime,
18
18
  createNotificationRuntimeStorage,
19
+ type NotificationRuntimeBackend,
19
20
  } from '@mpgd/platform/notifications-runtime';
20
21
  import type { TargetConfiguredGateway } from '@mpgd/target-config';
21
22
 
@@ -56,8 +57,8 @@ export interface StarterWorkerNotificationInput {
56
57
  * Worker. A Reddit game's backend is its Devvit server: the opt-in, the
57
58
  * consent records, and the sends live there, and the Worker serves no
58
59
  * `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.
60
+ * header to a backend that cannot use it, so Reddit gets the hosted backend
61
+ * below instead (`createStarterHostedNotificationBackend`).
61
62
  */
62
63
  const targetsWithoutWorkerBackend: ReadonlySet<string> = new Set(['reddit']);
63
64
 
@@ -140,6 +141,114 @@ export function createStarterWorkerNotificationBackend(
140
141
  }
141
142
  }
142
143
 
144
+ /**
145
+ * A backend the target's own server hosts, set by the target's gateway
146
+ * builder for its config target: on Reddit, `createDevvitNotificationsBackend`
147
+ * from `@mpgd/adapter-devvit` over the Devvit server bridge
148
+ * (`buildGateways/reddit.ts`). The server answers for the request's
149
+ * logged-in Reddit user, so the game sets no session of its own. The
150
+ * backend is created on first use, so a game whose target exposes no
151
+ * notifications provider (Reddit's gated beta before approval) sends
152
+ * nothing.
153
+ */
154
+ let hostedBackend:
155
+ | {
156
+ readonly configTarget: string;
157
+ readonly create: () => NotificationRuntimeBackend;
158
+ created?: NotificationRuntimeBackend;
159
+ /** The factory threw once: notifications stay off, and the failure is logged once. */
160
+ failed?: true;
161
+ }
162
+ | undefined;
163
+
164
+ /**
165
+ * Sets the backend `configTarget`'s own server hosts, as a factory called on
166
+ * first use; `undefined` clears it.
167
+ */
168
+ export function setStarterHostedNotificationBackend(
169
+ configTarget: string,
170
+ create: (() => NotificationRuntimeBackend) | undefined,
171
+ ): void {
172
+ hostedBackend = create === undefined ? undefined : { configTarget, create };
173
+ }
174
+
175
+ /**
176
+ * The backend `configTarget`'s own server hosts, created on the first call;
177
+ * `undefined` when none was set for this config target, or when its factory
178
+ * threw (logged once; not called again until a new factory is set).
179
+ */
180
+ export function resolveStarterHostedNotificationBackend(configTarget: string): NotificationRuntimeBackend | undefined {
181
+ if (hostedBackend === undefined || hostedBackend.configTarget !== configTarget) {
182
+ return undefined;
183
+ }
184
+ if (hostedBackend.failed === true) {
185
+ return undefined;
186
+ }
187
+ try {
188
+ hostedBackend.created ??= hostedBackend.create();
189
+ } catch (error) {
190
+ // A backend that cannot be created turns notifications off, never the game.
191
+ hostedBackend.failed = true;
192
+ console.error('[notifications] the hosted notifications backend could not be created.', error);
193
+ return undefined;
194
+ }
195
+ return hostedBackend.created;
196
+ }
197
+
198
+ export interface StarterHostedNotificationInput {
199
+ readonly backend: NotificationRuntimeBackend | undefined;
200
+ /** This target's topics, each with its mapping. */
201
+ readonly topics: readonly NotificationTopicDeclaration[];
202
+ readonly defaultLocale: string;
203
+ /** Logs a message, once per gateway and message. */
204
+ readonly log: (message: string, error?: unknown) => void;
205
+ }
206
+
207
+ /**
208
+ * The notifications runtime over a backend the target's own server hosts
209
+ * (Reddit: the Devvit server's notifications service), when the target
210
+ * exposes a notifications provider and its gateway builder set the backend.
211
+ * Otherwise `undefined`.
212
+ *
213
+ * The runtime keeps its registration state in the platform storage and
214
+ * starts here. It learns the player from the backend, which reads the
215
+ * session the server answers, so the settings screen shows the reminder
216
+ * switches once a logged-in Reddit user is known; a logged-out viewer is
217
+ * asked to log in first. It runs without a registry entry comparison: the
218
+ * provider and the server's service come from the same adapter and
219
+ * deployment, and the entry lives in server code the webview does not load.
220
+ */
221
+ export function createStarterHostedNotificationBackend(
222
+ platform: TargetConfiguredGateway,
223
+ input: StarterHostedNotificationInput,
224
+ ): StarterNotificationBackend | undefined {
225
+ const provider = platform.notificationsV2;
226
+ const { backend } = input;
227
+ if (provider === undefined || backend === undefined) {
228
+ return undefined;
229
+ }
230
+ try {
231
+ const runtime = createNotificationRuntime({
232
+ provider,
233
+ backend,
234
+ catalog: input.topics,
235
+ defaultLocale: input.defaultLocale,
236
+ storage: createNotificationRuntimeStorage({ storage: platform.storage }),
237
+ onError(error) {
238
+ console.error('[notifications]', error);
239
+ },
240
+ });
241
+ runtime.start().catch((error: unknown) => {
242
+ console.error('[notifications] the runtime could not start.', error);
243
+ });
244
+ return { runtime, listConsent: () => backend.listConsent() };
245
+ } catch (error) {
246
+ // A configuration the runtime refuses turns notifications off, never the game.
247
+ input.log('[notifications] the notifications backend could not be created.', error);
248
+ return undefined;
249
+ }
250
+ }
251
+
143
252
  /**
144
253
  * Whether the game-services URL may carry the session's credentials: any
145
254
  * absolute http(s) URL outside production builds, and in production the
@@ -4,9 +4,12 @@ import type { TargetConfiguredGateway } from '@mpgd/target-config';
4
4
 
5
5
  import notificationTopicsJson from '../../notifications/topics.json';
6
6
  import {
7
+ createStarterHostedNotificationBackend,
7
8
  createStarterWorkerNotificationBackend,
9
+ resolveStarterHostedNotificationBackend,
8
10
  type StarterNotificationBackend,
9
11
  } from './notificationBackend';
12
+ import { onStarterAccountDeleted } from './deleteAccount';
10
13
  import { resolveStarterServerSession } from './serverSession';
11
14
  import { routeNotificationOpens, type NotificationOpenRoutes } from './notificationOpens';
12
15
  import {
@@ -23,9 +26,13 @@ import {
23
26
  * The starter topics' purposes follow the notifications RFC (open question 1):
24
27
  * `daily-ready` and `streak-at-risk` are `requested` reminders the player
25
28
  * turns on, `friend-challenge` is `service`, and `events` is the sample
26
- * `advertising` topic, mapped to iOS only: an Android FCM push cannot carry
27
- * the opt-out action the Korean rules require yet. A topic whose copy offers a reward, a benefit, or a
28
- * promotion is advertising: declare it so, never `requested`.
29
+ * `advertising` topic. On Android an FCM notification cannot carry the
30
+ * opt-out action the Korean rules require, so the shell renders advertising
31
+ * pushes itself (`mpgd target init capacitor --providers notifications`
32
+ * declares the kit's renderer, and `notifications/deployment.json` sets
33
+ * `shellRendersAdvertising`); the backend sends one only to an installation
34
+ * whose shell has the renderer. A topic whose copy offers a reward, a
35
+ * benefit, or a promotion is advertising: declare it so, never `requested`.
29
36
  */
30
37
  export const starterNotificationCatalog = notificationTopicsJson as NotificationTopicCatalog;
31
38
 
@@ -56,7 +63,11 @@ export type { StarterNotificationBackend } from './notificationBackend';
56
63
  * reminder, advertising, or night switch, because nothing could record them.
57
64
  *
58
65
  * Reddit gets no Worker backend: its notifications backend is the Devvit
59
- * server, which keeps the opt-in, the records, and the sends. The Worker
66
+ * server, which keeps the opt-in, the records, and the sends. The runtime
67
+ * there runs over the backend `buildGateways/reddit.ts` sets
68
+ * (`createDevvitNotificationsBackend`, through
69
+ * `setStarterHostedNotificationBackend`), which needs no game session: the
70
+ * Devvit server answers for the logged-in Reddit user. The Worker
60
71
  * serves the Android, iOS, and Apps in Toss providers and binds a device to
61
72
  * the session its `Authorization` header names, through its
62
73
  * `GAME_SERVICES_NOTIFICATIONS_AUTH` binding to your identity service
@@ -68,6 +79,18 @@ export type { StarterNotificationBackend } from './notificationBackend';
68
79
  export function createStarterNotificationBackend(
69
80
  platform: TargetConfiguredGateway,
70
81
  ): StarterNotificationBackend | undefined {
82
+ // Only a hosted backend set for this config target; other targets keep the Worker path.
83
+ const hosted = platform.notificationsV2 === undefined
84
+ ? undefined
85
+ : resolveStarterHostedNotificationBackend(platform.configTarget);
86
+ if (hosted !== undefined) {
87
+ return createStarterHostedNotificationBackend(platform, {
88
+ backend: hosted,
89
+ topics: starterNotificationTopics(platform.configTarget),
90
+ defaultLocale: starterNotificationCatalog.defaultLocale,
91
+ log: (message, error) => warnOnce(platform, message, error),
92
+ });
93
+ }
71
94
  return createStarterWorkerNotificationBackend(platform, {
72
95
  baseUrl: import.meta.env.VITE_MPGD_GAME_SERVICES_URL,
73
96
  mode: import.meta.env.MODE,
@@ -111,6 +134,15 @@ function starterNotificationBackend(platform: TargetConfiguredGateway): StarterN
111
134
  backend = createStarterNotificationBackend(platform);
112
135
  if (backend !== undefined) {
113
136
  backends.set(platform, backend);
137
+ const { runtime } = backend;
138
+ // After an account deletion the device stops registering for the
139
+ // deleted player (the server already removed its endpoints), and the
140
+ // next session gets a fresh runtime.
141
+ const stop = onStarterAccountDeleted(async () => {
142
+ stop();
143
+ backends.delete(platform);
144
+ await runtime.unregister();
145
+ });
114
146
  }
115
147
  }
116
148
  return backend;
@@ -507,3 +507,75 @@ credentials: Game Services reports `missing_authoritative_backend` or
507
507
  backends require both a non-production authority mode and an explicit
508
508
  `allowLocalBackend` opt-in, so they cannot manufacture successful production
509
509
  purchase, rewarded-ad, or leaderboard ledger results.
510
+
511
+ ### Guest identity
512
+
513
+ Store purchases on Android and iOS are bound to the player of a server
514
+ session: Google Play's `obfuscatedAccountId` and StoreKit's `appAccountToken`
515
+ are derived from that player's id (`src/platform/commerceAccountBinding.ts`),
516
+ and the backend checks them before it grants. The native shell's local player
517
+ (`android-local-player` / `ios-local-player`) is the same on every install, so
518
+ it binds nothing: Play refuses with `ACCOUNT_BINDING_REQUIRED` and StoreKit
519
+ throws an error naming the missing session.
520
+
521
+ Point the game at the kit's reference guest identity Worker
522
+ (`apps/game-identity-worker` in mpgd-kit, deployed per
523
+ `docs/CLOUDFLARE_WORKER_DEPLOY.md`):
524
+
525
+ ```sh
526
+ VITE_MPGD_IDENTITY_URL=https://identity.example.com
527
+ ```
528
+
529
+ `src/platform/guestIdentity.ts` then signs the player in during bootstrap: a
530
+ server-issued guest on the first launch, kept with a rotating refresh token in
531
+ the device's Keychain or Keystore, and the same guest on later launches. The
532
+ server player becomes the game services' `playerId` and the purchases'
533
+ binding, and `setStarterServerSession` sends its access token to the
534
+ game-services Worker, which resolves it through the identity Worker's
535
+ bindings. A session the identity service ended (logout, a replayed refresh
536
+ token, or 180 days without play by default) is replaced by a new guest; without a linked
537
+ account that player's purchases stay with the old one.
538
+
539
+ Offline at launch, the game starts anyway and keeps trying in the background
540
+ (after 5 s, 15 s, 1 min, then every 5 min, and at once when the device reports
541
+ it is online). A purchase or a Restore Purchases tap signs in first; if that
542
+ still fails, the purchase is refused before any store sheet opens (it
543
+ answers `failed` with the retryable diagnostic `STARTER_SESSION_REQUIRED`,
544
+ which the game action coordinator treats as a plain failure; retry with a
545
+ new idempotency key, as for any failed purchase) and the restore control says it could not
546
+ reach the game server and stays tappable as a retry. The purchase, restore,
547
+ and notification services (`src/platform/sessionGatedServices.ts`) come up
548
+ for the server player as soon as a sign-in succeeds, and run the startup
549
+ purchase recovery then; they are never bound to the platform's local
550
+ placeholder, so no purchase or journal record changes owner. Rewarded ads
551
+ stay off for such a run: an ad watched before the session exists could not
552
+ be claimed. The scenes' `player` and `identitySession` are the boot values
553
+ for the whole run (the platform's player when the boot sign-in failed) and
554
+ the server player from the next launch on: key game-owned save data by
555
+ something that does not change, such as the platform storage itself, not by
556
+ `player.playerId`. A native shell only reaches `https:` origins, so test on a
557
+ device or emulator through an https tunnel to a local Worker. Account linking (Sign in with Apple or Google) is not
558
+ part of the reference Worker yet.
559
+
560
+ ### Restore Purchases
561
+
562
+ The lobby (Phaser) or a corner button (lite) shows **Restore purchases** on
563
+ targets that sell through a store and have a Game Services client. It calls
564
+ `monetizationRecovery.restorePurchases()` (`src/platform/restorePurchases.ts`):
565
+ the store resynchronizes (StoreKit `AppStore.sync()`; Google Play re-reads owned
566
+ items) and every purchase is verified through the backend ledger before anything
567
+ is granted. App Review expects this control while the catalog sells a
568
+ non-consumable such as `REMOVE_ADS`. On the App Store and Google Play a purchase
569
+ is bound to the server player, so without a server session (see Guest identity)
570
+ the button says to connect to the game server instead of restoring; keep it reachable from the game's own
571
+ store or settings screen when you replace the lobby.
572
+
573
+ A reinstall on Android, a new device, a second device, a logout, or (by
574
+ default) 180 days away make a new guest, and the store then returns the
575
+ purchase's non-consumables bound to the guest that bought them. The kit's
576
+ reference Worker grants a verified restore of such a non-consumable to the new
577
+ guest as well (restore sharing; earlier devices keep it, up to five guests per
578
+ purchase by default), and the control then says **Restored to this device**;
579
+ past the limit it says the purchase is already in use on the maximum number of
580
+ devices. Consumables are never shared. See "Reinstalls and new devices" in the
581
+ kit's `docs/CLOUDFLARE_WORKER_DEPLOY.md` before selling non-consumables.
@@ -67,7 +67,10 @@ the snapshot bounds for persistent controls.
67
67
  ## Server session
68
68
 
69
69
  Verify the game sets its real server-backed session with `setStarterServerSession`
70
- before `bootstrap()` (a guest backend through `createGuestServerSession`) and does
70
+ before the game services are created (the starter's `guestIdentity.ts` does it
71
+ with `VITE_MPGD_IDENTITY_URL`, a guest backend through `createGuestServerSession`),
72
+ that a store purchase on Android and iOS carries the server player's binding
73
+ (not `android-local-player` / `ios-local-player`), and that the game does
71
74
  not rely on the development fallback, which only a game created with
72
75
  `--providers notifications` reads. `VITE_MPGD_DEV_NOTIFICATION_SESSION` is local-only evidence:
73
76
  a `pnpm build` never signs in with it (its `mpgd-dev` header needs the Vite dev
@@ -51,10 +51,20 @@ and reconcile existing operations on startup and resume without opening SDK UI.
51
51
 
52
52
  ## Server session
53
53
 
54
- Set the game's server-backed session with `setStarterServerSession`
55
- (`src/platform/serverSession.ts`) before `bootstrap()`; with a guest backend use
56
- `createGuestServerSession(coordinator)`, whose `sessionId` is the stable
57
- session-family id. For local notifications work only (a game created with
54
+ With `VITE_MPGD_IDENTITY_URL` set (the kit's reference guest identity Worker,
55
+ `apps/game-identity-worker`), `src/platform/guestIdentity.ts` signs the player
56
+ in as a server-issued guest during bootstrap, before the game services read
57
+ the session: it keeps the refresh token in the device's Keychain or Keystore,
58
+ calls `setStarterServerSession` with `createGuestServerSession(coordinator)`
59
+ (whose `sessionId` is the stable session-family id), and binds StoreKit and
60
+ Google Play purchases to the server player. A failed boot sign-in is retried
61
+ in the background and before each purchase or restore; the purchase,
62
+ restore, and notification services come up when it succeeds
63
+ (`src/platform/sessionGatedServices.ts`). Without it the native shell's
64
+ local player binds no purchase, and StoreKit and Play refuse with the reason.
65
+ A game with its own identity service sets its session with
66
+ `setStarterServerSession` (`src/platform/serverSession.ts`) before the game
67
+ services are created instead. For local notifications work only (a game created with
58
68
  `--providers notifications`), `VITE_MPGD_DEV_NOTIFICATION_SESSION` on the Vite
59
69
  dev server in the `development` mode signs in with an unauthenticated
60
70
  development session (`src/platform/developmentServerSession.ts`)
@@ -3,7 +3,9 @@ import {
3
3
  bridgeStorageLoadProtocol,
4
4
  createBridgeError,
5
5
  createCommerceV2UnavailableBridgeError,
6
+ createNotificationsBackendUnavailableBridgeError,
6
7
  createNotificationsV2UnavailableBridgeError,
8
+ isBridgeNotificationsBackendMethod,
7
9
  isBridgeNotificationsV2Method,
8
10
  type BridgeRequest,
9
11
  type BridgeResponse,
@@ -68,6 +70,16 @@ export interface DevvitBridgeNotificationsHandler {
68
70
  request(input: BridgeRequest): Promise<BridgeResponse>;
69
71
  }
70
72
 
73
+ /**
74
+ * Serves the nine `notifications.backend.*` bridge methods:
75
+ * `createDevvitNotificationsBackendBridgeHandler` from
76
+ * `@mpgd/adapter-devvit/server` over the Devvit notifications service, for
77
+ * the request's logged-in user.
78
+ */
79
+ export interface DevvitBridgeNotificationsBackendHandler {
80
+ request(input: BridgeRequest): Promise<BridgeResponse>;
81
+ }
82
+
71
83
  export interface DevvitBridgeHandlerDependencies {
72
84
  readonly redis: DevvitBridgeRedisLike;
73
85
  /** Authenticated Reddit user ID from the Devvit request context, never from the payload. */
@@ -91,6 +103,14 @@ export interface DevvitBridgeHandlerDependencies {
91
103
  * `unavailable`.
92
104
  */
93
105
  readonly notifications?: DevvitBridgeNotificationsHandler | undefined;
106
+ /**
107
+ * The notifications runtime's backend: consent records, the player's
108
+ * registration, server reminders, and notices, kept by the Devvit
109
+ * notifications service. Absent, every `notifications.backend.*` method
110
+ * answers `NOTIFICATIONS_CONFIGURATION_REQUIRED`, and the webview's
111
+ * runtime runs without a backend.
112
+ */
113
+ readonly notificationsBackend?: DevvitBridgeNotificationsBackendHandler | undefined;
94
114
  readonly warn?: ((message: string) => void) | undefined;
95
115
  }
96
116
 
@@ -129,6 +149,23 @@ export function createDevvitBridgeHandler(
129
149
  );
130
150
  }
131
151
  }
152
+ // The runtime's backend calls, kept apart from the provider methods above.
153
+ if (isBridgeNotificationsBackendMethod(input.method)) {
154
+ if (dependencies.notificationsBackend === undefined) {
155
+ return createNotificationsBackendUnavailableBridgeError(input, 'Devvit server bridge');
156
+ }
157
+ try {
158
+ return await dependencies.notificationsBackend.request(input);
159
+ } catch (error) {
160
+ warn(`devvit notifications ${input.method} failed: ${errorMessage(error)}`);
161
+ return createBridgeError(
162
+ input.id,
163
+ 'NOTIFICATIONS_TEMPORARILY_UNAVAILABLE',
164
+ `${input.method} failed on the Devvit server.`,
165
+ true,
166
+ );
167
+ }
168
+ }
132
169
  switch (input.method) {
133
170
  case 'runtime.getCapabilities':
134
171
  return ok(input, {
@@ -18,3 +18,14 @@ apps/*/.env
18
18
  .DS_Store
19
19
  *.log
20
20
  *.ait
21
+ # Signing and store credentials stay out of Git (use mise or CI secrets).
22
+ # google-services.json and GoogleService-Info.plist are committed on
23
+ # purpose: Firebase documents them as non-secret, and release builds run from
24
+ # a pinned Git checkout that needs them.
25
+ *.p12
26
+ *.p8
27
+ *.mobileprovision
28
+ *.provisionprofile
29
+ *.keystore
30
+ *.jks
31
+ *.pepk
@@ -0,0 +1,133 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Delete Your __GAME_TITLE__ Account</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light dark;
10
+ font-family:
11
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
12
+ line-height: 1.6;
13
+ }
14
+
15
+ body {
16
+ margin: 0;
17
+ background: Canvas;
18
+ color: CanvasText;
19
+ }
20
+
21
+ main {
22
+ box-sizing: border-box;
23
+ margin: 0 auto;
24
+ max-width: 760px;
25
+ padding: 48px 24px;
26
+ }
27
+
28
+ h1,
29
+ h2 {
30
+ line-height: 1.2;
31
+ }
32
+
33
+ a {
34
+ color: LinkText;
35
+ }
36
+
37
+ nav {
38
+ display: flex;
39
+ flex-wrap: wrap;
40
+ gap: 16px;
41
+ margin: 0 0 32px;
42
+ }
43
+ </style>
44
+ </head>
45
+ <body>
46
+ <main>
47
+ <nav aria-label="Legal pages">
48
+ <a href="/privacy/">Privacy</a>
49
+ <a href="/support/">Support</a>
50
+ <a href="/terms/">Terms</a>
51
+ <a href="/delete-account/" aria-current="page">Delete account</a>
52
+ </nav>
53
+ <!--
54
+ Google Play: enter this page's URL (https://<your legal site>/delete-account/)
55
+ in Play Console > App content > Data safety > "Delete account URL".
56
+ The page must name the app as the store listing does, let players
57
+ request deletion without reinstalling the game, and say what is
58
+ deleted and what is kept and for how long. Starting point, not legal
59
+ advice: replace every REPLACE marker in square brackets (mpgd legal
60
+ build and check warn while any remain).
61
+ -->
62
+ <h1>Delete Your __GAME_TITLE__ Account</h1>
63
+ <p>
64
+ This page explains how to delete your __GAME_TITLE__ account and the
65
+ data linked to it. __GAME_TITLE__ is published by [REPLACE: developer
66
+ name exactly as shown on the store listing].
67
+ </p>
68
+ <h2>In the Game</h2>
69
+ <ol>
70
+ <li>Open __GAME_TITLE__ and go to [REPLACE: menu path, for example the lobby's Delete account button].</li>
71
+ <li>Tap <strong>Delete account</strong>, read what is deleted, and tap <strong>Delete permanently</strong>.</li>
72
+ <li>
73
+ The game deletes your account and signs you out. Anything our server
74
+ cannot finish at once is completed automatically within
75
+ [REPLACE: period, for example 24 hours].
76
+ </li>
77
+ </ol>
78
+ <h2>Without the Game</h2>
79
+ <p>
80
+ If you no longer have the game installed, you do not need to reinstall
81
+ it. Send a deletion request to
82
+ [REPLACE: support email address or request form URL] with the subject
83
+ "Delete my account" and [REPLACE: what lets you find the account,
84
+ for example the player ID shown in the game's settings, or the store
85
+ order number of a purchase made with it]. We delete the account within
86
+ [REPLACE: period, for example 30 days] and confirm by reply.
87
+ [REPLACE: what happens to a guest account that has neither, for
88
+ example "If you cannot give either, we cannot find your account; its
89
+ data is deleted after N months without play."]
90
+ </p>
91
+ <h2>What Is Deleted</h2>
92
+ <ul>
93
+ <li>Your player account and every sign-in session on every device.</li>
94
+ <li>Scores and leaderboard entries.</li>
95
+ <li>[REPLACE: game progress, if our server stores it; otherwise delete this item.]</li>
96
+ <li>Rewarded-ad records.</li>
97
+ <li>Notification tokens, notification settings, and your consent records for advertising messages.</li>
98
+ </ul>
99
+ <h2>What We Keep, and for How Long</h2>
100
+ <ul>
101
+ <li>
102
+ <strong>Purchase records</strong> (the product, the store's order or
103
+ transaction number, and the date) for 5 years from the purchase or its
104
+ last refund, whichever is later, for payment and supply record
105
+ obligations [REPLACE: confirm with counsel which apply to you, for
106
+ example Korea's Act on Consumer Protection in Electronic Commerce].
107
+ They are kept separately and no longer contain your player ID (the
108
+ store's own order number still identifies the purchase), and they
109
+ also stop the same store purchase from being used twice. They are
110
+ deleted after that period.
111
+ </li>
112
+ <li>[REPLACE: anything else the game keeps, such as support messages, with the period.]</li>
113
+ </ul>
114
+ <h2>Purchases</h2>
115
+ <p>
116
+ Deleting your account does not refund purchases. Items you buy once
117
+ (such as removing ads) stay owned by your Google Play or Apple
118
+ account: if you play again, Restore purchases gives them to your new
119
+ game account as a new record, and the deleted account is never
120
+ restored. Consumable items (such as coins) are not restored. To ask
121
+ for a refund, contact Google Play or Apple. A purchase still pending when
122
+ the account is deleted (waiting for a payment or a parent's approval)
123
+ is never delivered: Google Play refunds it automatically, but an App
124
+ Store purchase is not refunded automatically; ask Apple for the
125
+ refund. [REPLACE: subscriptions, if the
126
+ game sells any: cancel them in the store first, because billing
127
+ continues through the store.]
128
+ </p>
129
+ <h2>Contact</h2>
130
+ <p>[REPLACE: support email or form, and the data protection contact if required.]</p>
131
+ </main>
132
+ </body>
133
+ </html>