@mpgd/cli 0.55.0 → 0.57.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.
@@ -61,9 +61,36 @@ export declare const notificationsCapacitorSupportFiles: readonly string[];
61
61
  /**
62
62
  * Files of the base game template the notifications modules import, which a
63
63
  * game created by an earlier CLI release may not have: written from the
64
- * template when missing, kept when the game has one.
64
+ * template when missing, kept when the game has one. A file listed in
65
+ * `notificationsCapacitorTemplateSupportImporters` is written only together
66
+ * with its importer.
65
67
  */
66
- export declare const notificationsCapacitorTemplateSupportFiles: readonly string[];
68
+ export declare const notificationsCapacitorTemplateSupportFiles: readonly ["src/platform/serverSession.ts", "src/platform/developmentServerSession.ts"];
69
+ /**
70
+ * Template support files only one other support file imports: the importer,
71
+ * and the warning for a game that keeps its own importer without importing
72
+ * the file (an earlier `serverSession.ts`). Such a game would never import the
73
+ * file, and its `@mpgd/game-services` may predate what the file imports, so
74
+ * the file is not written for it. An importer that imports it gets it back.
75
+ */
76
+ export declare const notificationsCapacitorTemplateSupportImporters: Readonly<Partial<Record<(typeof notificationsCapacitorTemplateSupportFiles)[number], {
77
+ readonly importer: (typeof notificationsCapacitorTemplateSupportFiles)[number];
78
+ readonly warning: string;
79
+ /**
80
+ * A kit export the file imports, which the game's installed package
81
+ * must have before the file is written (an older package lacks it).
82
+ */
83
+ readonly requires?: {
84
+ readonly packageName: string;
85
+ readonly exportName: string;
86
+ };
87
+ }>>>;
88
+ /**
89
+ * Whether `source` names the module `./<name>` (with or without `.js` or
90
+ * `.ts`) as a string outside comments: any static, side-effect, or dynamic
91
+ * import or re-export of it. A commented-out import does not count.
92
+ */
93
+ export declare function sourceReferencesRelativeModule(source: string, name: string): boolean;
67
94
  /** Whether a Capacitor gateway already registers a notifications provider. */
68
95
  export declare function capacitorGatewayRegistersNotifications(source: string): boolean;
69
96
  /**
@@ -64,6 +64,15 @@ const commonEdits = {
64
64
  'src/platform/buildGateways/capacitorAndroid.ts': [gatewayImport, gatewayProviders('android')],
65
65
  'src/platform/buildGateways/capacitorIos.ts': [gatewayImport, gatewayProviders('ios')],
66
66
  'src/platform/buildGateways/reddit.ts': [
67
+ {
68
+ anchor: ' createDevvitCommerceProducts,\n createDevvitPlatformGateway,\n',
69
+ replacement: [
70
+ ' createDevvitCommerceProducts,',
71
+ ' createDevvitNotificationsBackend,',
72
+ ' createDevvitPlatformGateway,',
73
+ '',
74
+ ].join('\n'),
75
+ },
67
76
  {
68
77
  anchor: "import { createDevvitWebPaymentsClient } from '@mpgd/adapter-devvit/web';\n",
69
78
  replacement: [
@@ -77,6 +86,7 @@ const commonEdits = {
77
86
  {
78
87
  anchor: "import type { RuntimeConfig } from '../runtimeDetector';\n",
79
88
  replacement: [
89
+ "import { setStarterHostedNotificationBackend } from '../notificationBackend';",
80
90
  "import { starterNotificationTopics } from '../notifications';",
81
91
  "import type { RuntimeConfig } from '../runtimeDetector';",
82
92
  '',
@@ -102,7 +112,14 @@ const commonEdits = {
102
112
  ' runtime: RuntimeConfig,',
103
113
  "): Pick<Parameters<typeof createDevvitPlatformGateway>[0], 'notifications'> {",
104
114
  ' const topics = starterNotificationTopics(runtime.configTarget).map((topic) => topic.id);',
105
- ' return topics.length === 0 ? {} : { notifications: { topics } };',
115
+ ' if (topics.length === 0) {',
116
+ ' return {};',
117
+ ' }',
118
+ " // The runtime's backend is the Devvit server (consent records, reminders, notices for the",
119
+ ' // logged-in Reddit user), over the same server bridge as the gateway; created on first use.',
120
+ ' setStarterHostedNotificationBackend(runtime.configTarget, () =>',
121
+ ' createDevvitNotificationsBackend({ appVersion: runtime.appVersion, buildId: runtime.buildId }));',
122
+ ' return { notifications: { topics } };',
106
123
  '}',
107
124
  '',
108
125
  'function createCommerceOptions(): DevvitGatewayCommerceOptions | undefined {',
@@ -139,6 +156,7 @@ const commonEdits = {
139
156
  ' createDevvitCommerceBridgeProducts,',
140
157
  ' createDevvitCommerceV2BridgeHandler,',
141
158
  ' createDevvitNotificationService,',
159
+ ' createDevvitNotificationsBackendBridgeHandler,',
142
160
  ' createDevvitNotificationsV2BridgeHandler,',
143
161
  "} from '@mpgd/adapter-devvit/server';",
144
162
  '',
@@ -182,7 +200,7 @@ const commonEdits = {
182
200
  ' */',
183
201
  'const notificationTopicCatalog = notificationTopicsJson as NotificationTopicCatalog;',
184
202
  'const redditNotificationTopics = readRedditNotificationTopics();',
185
- "const notificationService: Parameters<typeof handleNotificationTask>[0]['notifications'] =",
203
+ "const notificationService: ReturnType<typeof createDevvitNotificationService>['service'] | undefined =",
186
204
  ' redditNotificationTopics.length === 0 ? undefined : createNotificationService();',
187
205
  '// The opt-in is served only with the service that sends; a refused catalog turns both off.',
188
206
  'const notificationsBridge = notificationService === undefined',
@@ -192,6 +210,10 @@ const commonEdits = {
192
210
  ' currentUserId: currentPlayerId,',
193
211
  ' topics: redditNotificationTopics,',
194
212
  ' });',
213
+ "// The webview runtime's backend: consent records, reminders, and notices for the request's user.",
214
+ 'const notificationsBackend = notificationService === undefined',
215
+ ' ? undefined',
216
+ ' : createDevvitNotificationsBackendBridgeHandler({ service: notificationService, context: () => context });',
195
217
  'const handleBridgeRequest = createDevvitBridgeHandler({',
196
218
  ' redis,',
197
219
  ' currentPlayerId,',
@@ -199,6 +221,7 @@ const commonEdits = {
199
221
  ' storageKeyNamespace: gameName,',
200
222
  ' ...(commerce === undefined ? {} : { commerce }),',
201
223
  ' ...(notificationsBridge === undefined ? {} : { notifications: notificationsBridge }),',
224
+ ' ...(notificationsBackend === undefined ? {} : { notificationsBackend }),',
202
225
  '});',
203
226
  '',
204
227
  ].join('\n'),
@@ -230,7 +253,7 @@ const commonEdits = {
230
253
  ' * deployment the service refuses turns notifications off, never the game',
231
254
  ' * server.',
232
255
  ' */',
233
- "function createNotificationService(): Parameters<typeof handleNotificationTask>[0]['notifications'] {",
256
+ "function createNotificationService(): ReturnType<typeof createDevvitNotificationService>['service'] | undefined {",
234
257
  ' try {',
235
258
  ' return createDevvitNotificationService({',
236
259
  ' redis,',
@@ -479,11 +502,47 @@ export const notificationsCapacitorSupportFiles = Object.freeze([
479
502
  /**
480
503
  * Files of the base game template the notifications modules import, which a
481
504
  * game created by an earlier CLI release may not have: written from the
482
- * template when missing, kept when the game has one.
505
+ * template when missing, kept when the game has one. A file listed in
506
+ * `notificationsCapacitorTemplateSupportImporters` is written only together
507
+ * with its importer.
483
508
  */
484
509
  export const notificationsCapacitorTemplateSupportFiles = Object.freeze([
485
510
  'src/platform/serverSession.ts',
511
+ 'src/platform/developmentServerSession.ts',
486
512
  ]);
513
+ /**
514
+ * Template support files only one other support file imports: the importer,
515
+ * and the warning for a game that keeps its own importer without importing
516
+ * the file (an earlier `serverSession.ts`). Such a game would never import the
517
+ * file, and its `@mpgd/game-services` may predate what the file imports, so
518
+ * the file is not written for it. An importer that imports it gets it back.
519
+ */
520
+ export const notificationsCapacitorTemplateSupportImporters = Object.freeze({
521
+ 'src/platform/developmentServerSession.ts': {
522
+ importer: 'src/platform/serverSession.ts',
523
+ requires: { packageName: '@mpgd/game-services', exportName: 'createDevelopmentServerSession' },
524
+ warning: 'src/platform/serverSession.ts predates development notification sessions '
525
+ + '(VITE_MPGD_DEV_NOTIFICATION_SESSION); to try notifications locally, copy serverSession.ts and '
526
+ + 'developmentServerSession.ts from the starter template.',
527
+ },
528
+ });
529
+ /**
530
+ * Whether `source` names the module `./<name>` (with or without `.js` or
531
+ * `.ts`) as a string outside comments: any static, side-effect, or dynamic
532
+ * import or re-export of it. A commented-out import does not count.
533
+ */
534
+ export function sourceReferencesRelativeModule(source, name) {
535
+ const specifiers = new Set([`./${name}`, `./${name}.js`, `./${name}.ts`]);
536
+ let found = false;
537
+ // One pass over strings and comments, as in capacitorGatewayRegistersNotifications.
538
+ source.replace(/'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|`(?:\\.|[^`\\])*`|\/\/[^\n]*|\/\*[\s\S]*?\*\//gu, (token) => {
539
+ if (!token.startsWith('/') && specifiers.has(token.slice(1, -1))) {
540
+ found = true;
541
+ }
542
+ return token;
543
+ });
544
+ return found;
545
+ }
487
546
  /** Whether a Capacitor gateway already registers a notifications provider. */
488
547
  export function capacitorGatewayRegistersNotifications(source) {
489
548
  // One pass over strings and comments: neither registers anything, and a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/cli",
3
- "version": "0.55.0",
3
+ "version": "0.57.0",
4
4
  "description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -70,10 +70,10 @@
70
70
  "parse5": "7.3.0",
71
71
  "postcss": "8.5.28",
72
72
  "postcss-value-parser": "4.2.0",
73
- "@mpgd/catalog": "0.10.7",
74
- "@mpgd/target-config": "0.23.7",
75
- "@mpgd/platform": "0.27.2",
76
- "@mpgd/phaser-assets": "0.9.1"
73
+ "@mpgd/catalog": "0.10.8",
74
+ "@mpgd/platform": "0.28.0",
75
+ "@mpgd/phaser-assets": "0.9.1",
76
+ "@mpgd/target-config": "0.23.8"
77
77
  },
78
78
  "devDependencies": {
79
79
  "@types/node": "^24.0.0",
@@ -81,19 +81,19 @@
81
81
  "@types/yauzl": "3.4.0",
82
82
  "ttsc": "0.32.1",
83
83
  "typescript": "7.0.2",
84
- "@mpgd/adapter-ait": "0.20.3",
85
- "@mpgd/adapter-browser": "0.11.7",
86
- "@mpgd/adapter-capacitor": "0.12.3",
87
- "@mpgd/adapter-devvit": "0.13.5",
88
- "@mpgd/adapter-verse8": "0.7.7",
89
- "@mpgd/analytics": "0.4.16",
90
- "@mpgd/capacitor-play-billing": "0.3.7",
91
- "@mpgd/bridge": "0.15.0",
92
- "@mpgd/capacitor-storekit": "0.3.7",
93
- "@mpgd/game-runtime": "0.8.0",
94
- "@mpgd/game-services": "0.27.2",
95
- "@mpgd/i18n": "0.6.21",
96
- "@mpgd/lite-shell": "0.1.0"
84
+ "@mpgd/adapter-ait": "0.20.5",
85
+ "@mpgd/adapter-browser": "0.11.8",
86
+ "@mpgd/adapter-capacitor": "0.12.5",
87
+ "@mpgd/adapter-devvit": "0.14.0",
88
+ "@mpgd/adapter-verse8": "0.7.9",
89
+ "@mpgd/capacitor-play-billing": "0.3.9",
90
+ "@mpgd/game-runtime": "0.8.2",
91
+ "@mpgd/bridge": "0.16.0",
92
+ "@mpgd/capacitor-storekit": "0.3.9",
93
+ "@mpgd/analytics": "0.5.1",
94
+ "@mpgd/game-services": "0.28.1",
95
+ "@mpgd/lite-shell": "0.1.0",
96
+ "@mpgd/i18n": "0.6.22"
97
97
  },
98
98
  "main": "./dist/index.js",
99
99
  "types": "./dist/index.d.ts",
@@ -11,6 +11,7 @@ Preserve the generated JSON and Markdown in `artifacts/acceptance` or the target
11
11
  - Verify renderer teardown removes its own listeners and never updates a replacement view from an old promise.
12
12
  - Purchases and rewarded-ad callbacks never grant locally; verify authoritative ledger grants and durable reconciliation before release.
13
13
  - Keep unknown native outcomes and late recovery observers; closing one lease must not release another owner's presentation or audio.
14
+ - Verify the game sets its real server session with `setStarterServerSession` and does not rely on the development fallback (read only with `--providers notifications`): `VITE_MPGD_DEV_NOTIFICATION_SESSION` is local-only (its `mpgd-dev` header needs the Vite dev server, so a `pnpm build` never signs in with it), and deployed acceptance uses the identity service's sessions.
14
15
 
15
16
  ## Reddit Devvit
16
17
 
@@ -11,6 +11,7 @@ Read `AGENTS.md`, the agent manifest, and the `use-mpgd-kit` router before editi
11
11
  - Keep recovery above renderer lifetimes; reconcile existing operations on startup and resume without opening SDK UI.
12
12
  - Rewarded v2 needs an application-owned durable encrypted journal. An in-memory store is not recovery evidence.
13
13
  - Reserve safe-area geometry once and preserve feed scrolling and browser touch gestures.
14
+ - Set the server session with `setStarterServerSession` in `src/main.ts` before `bootstrap()` (a guest backend through `createGuestServerSession`). In a game created with `--providers notifications`, `VITE_MPGD_DEV_NOTIFICATION_SESSION` on the Vite dev server in the `development` mode is a local-only development session for notifications (`src/platform/developmentServerSession.ts`); it never reaches a build, commerce, or a deployed Worker.
14
15
 
15
16
  Run `pnpm dev`, `pnpm check`, `pnpm build`, and `pnpm accept`.
16
17
  Devvit inline and expanded entries both use the lite renderer. Keep optional engines behind explicit dynamic import boundaries and verify the inline budget.
@@ -74,10 +74,22 @@ game has both:
74
74
  identity service answers to the Worker's `authenticateNotificationSession`
75
75
  for this session's header, the same across token refreshes (see the kit's
76
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.
77
+ or rotating id. With a guest-session backend, pass
78
+ `createGuestServerSession(coordinator)` from `@mpgd/game-services`: it
79
+ answers the coordinator's `sessionFamilyId` (not a view's `sessionId`,
80
+ which may rotate on refresh), and refreshes the access token as needed
81
+ (an expired one before the request, one about to expire in the
82
+ background).
79
83
 
80
- Without either, it returns `undefined` and the settings screen asks only
84
+ ```ts
85
+ // src/main.ts, before bootstrap()
86
+ const guest = createGuestSessionCoordinator({ backend, credentials, installationId });
87
+ setStarterServerSession(createGuestServerSession(guest));
88
+ void guest.start();
89
+ ```
90
+
91
+ Without either (on the Vite dev server, a development session counts as a
92
+ session; see "Try it locally"), it returns `undefined` and the settings screen asks only
81
93
  for the OS or host permission (and withdraws a Devvit opt-in the game
82
94
  rendered). With them, the runtime registers the device for the signed-in
83
95
  player, keeps its registration state in the platform storage, and
@@ -92,6 +104,22 @@ game's notifications backend is its Devvit server, which keeps the opt-in,
92
104
  the records, and the sends, and the Worker serves no `reddit` target. The
93
105
  Worker serves the Android, iOS, and Apps in Toss providers.
94
106
 
107
+ On Reddit the runtime runs over the Devvit server instead:
108
+ `src/platform/buildGateways/reddit.ts` sets
109
+ `createDevvitNotificationsBackend` as the hosted backend
110
+ (`setStarterHostedNotificationBackend`), and the generated Devvit server
111
+ serves it with `createDevvitNotificationsBackendBridgeHandler`. No session
112
+ is needed: the server answers for the logged-in Reddit user, and a
113
+ logged-out player is asked to log in first. Once Reddit approves the app
114
+ and `integrations.notifications` is `available`, the settings screen shows
115
+ the reminder switches there; advertising and night consent stay off,
116
+ because Devvit delivers `service` and `requested` topics only. A reminder
117
+ the game schedules on Reddit (`runtime.reminders.set`) needs a post or
118
+ comment to open: pass `link` (the post id, `context.postId` from
119
+ `@devvit/web/client`), or give the topic's `reddit` mapping a default
120
+ `link`; the `mpgd-notifications-dispatch` task sends it through Devvit when
121
+ it is due.
122
+
95
123
  The settings screen shows each
96
124
  switch from the latest record, read every time it opens and after every
97
125
  change, so a choice made on another device shows. A tap reads the records
@@ -102,3 +130,63 @@ Night consent adds to 광고성 정보 수신동의: the screen offers it only w
102
130
  advertising consent is granted, and turning advertising consent off also
103
131
  withdraws a granted night consent in the same call (two records, two result
104
132
  notices).
133
+
134
+ ## Try it locally
135
+
136
+ You can see the settings switches record consent before you have an
137
+ identity service, against the kit's game-services Worker on your machine:
138
+
139
+ 1. Fill in `advertisingSender` in `notifications/deployment.json` (the
140
+ Worker refuses a placeholder). In mpgd-kit, give the Worker this game's
141
+ catalog and deployment and the development sessions flag in
142
+ `apps/game-services-worker/.dev.vars` (local variables, never
143
+ committed), then run it with the memory store, the default in its
144
+ `wrangler.toml`:
145
+
146
+ ```sh
147
+ cd <mpgd-kit>
148
+ node - <game>/notifications/topics.json <game>/notifications/deployment.json \
149
+ > apps/game-services-worker/.dev.vars <<'EOF'
150
+ const fs = require('node:fs');
151
+ // One line of JSON in single quotes, with any ' written as \u0027.
152
+ const value = (file) =>
153
+ `'${JSON.stringify(JSON.parse(fs.readFileSync(file, 'utf8'))).replaceAll("'", '\\u0027')}'`;
154
+ console.log('MPGD_ALLOW_INSECURE_DEVELOPMENT_NOTIFICATION_SESSIONS=true');
155
+ console.log(`MPGD_NOTIFICATIONS_CATALOG=${value(process.argv[2])}`);
156
+ console.log(`MPGD_NOTIFICATIONS_DEPLOYMENT=${value(process.argv[3])}`);
157
+ EOF
158
+ pnpm --dir apps/game-services-worker dev --host 127.0.0.1 --port 8787
159
+ ```
160
+
161
+ The Worker logs once that sessions are not authenticated. To send real
162
+ pushes to a phone or an emulator, follow mpgd-kit's live test runbook
163
+ (`docs/NOTIFICATIONS_LIVE_TEST.md`) instead of adding the key here: it
164
+ runs this same Worker, with `pnpm notifications:live env` writing the
165
+ variables and the Firebase service-account key to a file outside the
166
+ checkout that you link as `.dev.vars`.
167
+ 2. Start this game's dev server with the Worker URL and a development
168
+ session, `<playerId>` or `<playerId>.<installationId>` (letters, digits,
169
+ `_`, `-`):
170
+
171
+ ```sh
172
+ VITE_MPGD_GAME_SERVICES_URL=http://127.0.0.1:8787 \
173
+ VITE_MPGD_DEV_NOTIFICATION_SESSION=player-1 \
174
+ pnpm dev
175
+ ```
176
+
177
+ `src/platform/developmentServerSession.ts` then signs the game in as that
178
+ player with `Authorization: Bearer mpgd-dev.<playerId>.<sessionId>.<installationId>`,
179
+ which the Worker accepts only with the flag. Anyone can write that header,
180
+ so it authenticates nothing: the session exists only on the Vite dev server
181
+ (`import.meta.env.DEV`) in the `development` mode, and the Worker refuses
182
+ the flag with its D1 store, ignores it once `GAME_SERVICES_NOTIFICATIONS_AUTH`
183
+ is bound, and takes development sessions only on a local host (`localhost`,
184
+ `127.0.0.1`, `[::1]`, `*.localhost`, or `10.0.2.2`, which the Android
185
+ emulator uses for your machine). That check reads the request's host, so
186
+ it protects a deployed Worker, not your network: keep the local Worker on
187
+ loopback, its default. Calling `setStarterServerSession`, even with `undefined`, turns it
188
+ off; the first call after the game used it warns, since the notifications
189
+ backend keeps the session it first read.
190
+ The development session is for the notifications backend only: the
191
+ commerce client keeps the session the game set with `setStarterServerSession`
192
+ (or none), so its requests never carry a development header.
@@ -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
 
@@ -90,7 +91,8 @@ export function createStarterWorkerNotificationBackend(
90
91
  if (session === undefined) {
91
92
  input.log(
92
93
  '[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
+ + '(src/platform/serverSession.ts) before the game starts, or on the dev server set '
95
+ + 'VITE_MPGD_DEV_NOTIFICATION_SESSION (docs/NOTIFICATIONS.md). The settings screen shows no switch until then.',
94
96
  );
95
97
  return undefined;
96
98
  }
@@ -139,6 +141,114 @@ export function createStarterWorkerNotificationBackend(
139
141
  }
140
142
  }
141
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
+
142
252
  /**
143
253
  * Whether the game-services URL may carry the session's credentials: any
144
254
  * absolute http(s) URL outside production builds, and in production the
@@ -4,7 +4,9 @@ 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';
10
12
  import { resolveStarterServerSession } from './serverSession';
@@ -56,7 +58,11 @@ export type { StarterNotificationBackend } from './notificationBackend';
56
58
  * reminder, advertising, or night switch, because nothing could record them.
57
59
  *
58
60
  * 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
61
+ * server, which keeps the opt-in, the records, and the sends. The runtime
62
+ * there runs over the backend `buildGateways/reddit.ts` sets
63
+ * (`createDevvitNotificationsBackend`, through
64
+ * `setStarterHostedNotificationBackend`), which needs no game session: the
65
+ * Devvit server answers for the logged-in Reddit user. The Worker
60
66
  * serves the Android, iOS, and Apps in Toss providers and binds a device to
61
67
  * the session its `Authorization` header names, through its
62
68
  * `GAME_SERVICES_NOTIFICATIONS_AUTH` binding to your identity service
@@ -68,6 +74,18 @@ export type { StarterNotificationBackend } from './notificationBackend';
68
74
  export function createStarterNotificationBackend(
69
75
  platform: TargetConfiguredGateway,
70
76
  ): StarterNotificationBackend | undefined {
77
+ // Only a hosted backend set for this config target; other targets keep the Worker path.
78
+ const hosted = platform.notificationsV2 === undefined
79
+ ? undefined
80
+ : resolveStarterHostedNotificationBackend(platform.configTarget);
81
+ if (hosted !== undefined) {
82
+ return createStarterHostedNotificationBackend(platform, {
83
+ backend: hosted,
84
+ topics: starterNotificationTopics(platform.configTarget),
85
+ defaultLocale: starterNotificationCatalog.defaultLocale,
86
+ log: (message, error) => warnOnce(platform, message, error),
87
+ });
88
+ }
71
89
  return createStarterWorkerNotificationBackend(platform, {
72
90
  baseUrl: import.meta.env.VITE_MPGD_GAME_SERVICES_URL,
73
91
  mode: import.meta.env.MODE,
@@ -64,6 +64,16 @@ Verify safe-area geometry is reserved exactly once: CSS-padded containers must
64
64
  not also subtract `safeArea.contentBounds`, while full-bleed game surfaces use
65
65
  the snapshot bounds for persistent controls.
66
66
 
67
+ ## Server session
68
+
69
+ Verify the game sets its real server-backed session with `setStarterServerSession`
70
+ before `bootstrap()` (a guest backend through `createGuestServerSession`) and does
71
+ not rely on the development fallback, which only a game created with
72
+ `--providers notifications` reads. `VITE_MPGD_DEV_NOTIFICATION_SESSION` is local-only evidence:
73
+ a `pnpm build` never signs in with it (its `mpgd-dev` header needs the Vite dev
74
+ server), and acceptance against a deployed Worker uses the identity service's
75
+ sessions.
76
+
67
77
  ## Shared native presentation
68
78
 
69
79
  - Purchase, rewarded, and interstitial requests use the same game-owned scope.
@@ -49,6 +49,18 @@ the game-owned action coordinator and authoritative services, with an
49
49
  application-owned durable encrypted operation store. Keep recovery above scenes
50
50
  and reconcile existing operations on startup and resume without opening SDK UI.
51
51
 
52
+ ## Server session
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
58
+ `--providers notifications`), `VITE_MPGD_DEV_NOTIFICATION_SESSION` on the Vite
59
+ dev server in the `development` mode signs in with an unauthenticated
60
+ development session (`src/platform/developmentServerSession.ts`)
61
+ against a local Worker run with `MPGD_ALLOW_INSECURE_DEVELOPMENT_NOTIFICATION_SESSIONS`;
62
+ it never reaches a build, the commerce client, or a deployed Worker.
63
+
52
64
  ## ONE play server-owned monetization
53
65
 
54
66
  Specify issued placement/product IDs, the authenticated reward and checkout intent routes, durable SSV/intent/ledger stores, an encrypted client operation journal, and reconciliation plus atomic cancellation fencing. Keep monetization disabled until these ports are wired. Purchase/ad callbacks are evidence; backend grants precede consume/acknowledge. Separate confirmed checkout closure from financial completion. Plan real-app acceptance with ONEconsole credentials and hosting headers.
@@ -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, {