@mpgd/cli 0.45.0 → 0.47.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 (26) hide show
  1. package/dist/capacitor-shell-starter.d.ts +57 -1
  2. package/dist/capacitor-shell-starter.js +311 -34
  3. package/dist/index.js +23 -5
  4. package/dist/native-build-info.json +2 -2
  5. package/dist/native-build-target.js +613 -316
  6. package/package.json +17 -15
  7. package/templates/phaser-game/apps/target-ait/src/main.ts +17 -0
  8. package/templates/phaser-game/apps/target-ait/src/runtime-config.types.ts +2 -0
  9. package/templates/phaser-game/apps/target-ait/vite.config.ts +27 -0
  10. package/templates/phaser-game/apps/target-devvit/README.md +8 -1
  11. package/templates/phaser-game/apps/target-devvit/package.json +2 -1
  12. package/templates/phaser-game/apps/target-devvit/src/server/index.ts +35 -1
  13. package/templates/phaser-game/package.json +2 -0
  14. package/templates/phaser-game/src/main.ts +8 -1
  15. package/templates/phaser-game/src/platform/buildGateways/ait.ts +9 -0
  16. package/templates/phaser-game/src/platform/buildGateways/aitSandbox.ts +7 -0
  17. package/templates/phaser-game/src/platform/buildGateways/capacitorAndroid.ts +51 -2
  18. package/templates/phaser-game/src/platform/buildGateways/capacitorIos.ts +51 -2
  19. package/templates/phaser-game/src/platform/buildGateways/microsoftStore.ts +62 -26
  20. package/templates/phaser-game/src/platform/buildGateways/reddit.ts +36 -1
  21. package/templates/phaser-game/src/platform/buildGateways/verse8.ts +40 -2
  22. package/templates/phaser-game/src/platform/commerceAccountBinding.ts +79 -0
  23. package/templates/phaser-game/src/platform/commerceProducts.ts +69 -0
  24. package/templates/phaser-game/src/platform/gameServices.ts +22 -2
  25. package/templates/phaser-game/src/platform/monetizationJournal.ts +286 -0
  26. package/templates/phaser-game/src/platform/verse8Agent8Client.ts +23 -0
@@ -0,0 +1,79 @@
1
+ import type { IdentitySession, PlatformGateway } from '@mpgd/platform';
2
+ import type { StorePurchaseAccountBinding } from '@mpgd/platform/commerce';
3
+
4
+ /**
5
+ * Reference account binding of the native store providers. Google Play's
6
+ * obfuscated account id and StoreKit's app account token must be stable,
7
+ * non-identifying, and derived the same way by the game backend
8
+ * (`resolveObfuscatedAccountId` of the Play verifier,
9
+ * `resolveAppAccountToken` of the App Store verifier), which is how the
10
+ * backend ties a store purchase to the player before it grants. This module
11
+ * derives both from a SHA-256 digest of the authenticated player id; a game
12
+ * with its own account scheme replaces these two functions and the backend
13
+ * resolvers together. Nothing here grants anything.
14
+ */
15
+ export const starterAccountBindingDigestPrefix = 'mpgd-commerce-account:';
16
+
17
+ /**
18
+ * The authenticated player id a purchase is bound to. Only an
19
+ * `authenticated` session whose trust is not `local` binds: a guest, a
20
+ * platform-anonymous session (the native shell's default
21
+ * `android-local-player` / `ios-local-player`), or a locally asserted id
22
+ * would give every install the same binding, and the backend binds the
23
+ * purchase to its own authenticated player id anyway.
24
+ */
25
+ export async function resolveStarterCommercePlayerId(
26
+ gateway: Pick<PlatformGateway, 'identity'>,
27
+ ): Promise<string | undefined> {
28
+ let session: IdentitySession | undefined;
29
+ try {
30
+ session = await gateway.identity.getSession?.();
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ if (session?.identityLevel !== 'authenticated' || session.trustLevel === 'local') {
35
+ return undefined;
36
+ }
37
+ const playerId = session.playerId?.trim();
38
+ return playerId === undefined || playerId.length === 0 ? undefined : playerId;
39
+ }
40
+
41
+ /** Google Play `obfuscatedAccountId`: 64 hex characters, within Play's 64-character limit. */
42
+ export async function resolvePlayBillingAccountBinding(
43
+ gateway: Pick<PlatformGateway, 'identity'>,
44
+ ): Promise<StorePurchaseAccountBinding | undefined> {
45
+ const playerId = await resolveStarterCommercePlayerId(gateway);
46
+ if (playerId === undefined) {
47
+ return undefined;
48
+ }
49
+ return { kind: 'obfuscated-account-id', value: toHex(await digestPlayerId(playerId)) };
50
+ }
51
+
52
+ /** StoreKit `appAccountToken`: a non-nil UUID derived from the same digest. */
53
+ export async function resolveStoreKitAppAccountToken(
54
+ gateway: Pick<PlatformGateway, 'identity'>,
55
+ ): Promise<string> {
56
+ const playerId = await resolveStarterCommercePlayerId(gateway);
57
+ if (playerId === undefined) {
58
+ throw new Error('A StoreKit purchase requires an authenticated player session.');
59
+ }
60
+ return toUuid(await digestPlayerId(playerId));
61
+ }
62
+
63
+ async function digestPlayerId(playerId: string): Promise<Uint8Array> {
64
+ const bytes = new TextEncoder().encode(`${starterAccountBindingDigestPrefix}${playerId}`);
65
+ return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
66
+ }
67
+
68
+ function toHex(bytes: Uint8Array): string {
69
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
70
+ }
71
+
72
+ /** RFC 4122 layout (version 5 nibble, variant bits) over the first 16 digest bytes. */
73
+ function toUuid(bytes: Uint8Array): string {
74
+ const uuid = bytes.slice(0, 16);
75
+ uuid[6] = ((uuid[6] ?? 0) & 0x0f) | 0x50;
76
+ uuid[8] = ((uuid[8] ?? 0) & 0x3f) | 0x80;
77
+ const hex = toHex(uuid);
78
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
79
+ }
@@ -0,0 +1,69 @@
1
+ import {
2
+ resolveProductPlatformId,
3
+ type ProductCatalog,
4
+ type ProductCatalogEntry,
5
+ } from '@mpgd/catalog';
6
+ import type { CommerceReconcilerProduct } from '@mpgd/game-services';
7
+ import type { PurchaseKind } from '@mpgd/platform/commerce';
8
+
9
+ /**
10
+ * One catalog product mapped to the store SKU of a target, in the shape the
11
+ * Capacitor store providers take (`StoreCommerceProductMapping`): the catalog
12
+ * `platformProductIds[target]` entry is the single source of every SKU.
13
+ */
14
+ export interface StarterStoreProductMapping {
15
+ readonly logicalId: ProductCatalogEntry['id'];
16
+ readonly storeSku: string;
17
+ readonly kind: PurchaseKind;
18
+ }
19
+
20
+ /** Commerce v2 purchase kind of a catalog product type. */
21
+ export function purchaseKindOf(type: ProductCatalogEntry['type']): PurchaseKind {
22
+ switch (type) {
23
+ case 'consumable':
24
+ return 'consumable';
25
+ case 'non_consumable':
26
+ return 'durable';
27
+ case 'subscription':
28
+ return 'subscription';
29
+ default:
30
+ throw new TypeError(`Unknown catalog product type: ${String(type)}`);
31
+ }
32
+ }
33
+
34
+ /**
35
+ * The products sold on `target`: every catalog entry that names a platform
36
+ * product id for it. A product without one is not sold on that target, so a
37
+ * store provider never offers it.
38
+ */
39
+ export function resolveStarterStoreProducts(
40
+ catalog: ProductCatalog,
41
+ target: string,
42
+ ): readonly StarterStoreProductMapping[] {
43
+ return catalog.products.flatMap((product) => {
44
+ const storeSku = resolveProductPlatformId(product, target);
45
+ return storeSku === undefined
46
+ ? []
47
+ : [{ logicalId: product.id, storeSku, kind: purchaseKindOf(product.type) }];
48
+ });
49
+ }
50
+
51
+ /**
52
+ * The catalog kinds and SKUs the commerce v2 reconciler resolves without a
53
+ * store read (`createGameServicesRuntime({ commerceProducts })`). Every
54
+ * product is listed so a purchase for an unsold product fails on the store
55
+ * answer, not on a missing kind.
56
+ */
57
+ export function resolveStarterCommerceProducts(
58
+ catalog: ProductCatalog,
59
+ target: string,
60
+ ): readonly CommerceReconcilerProduct[] {
61
+ return catalog.products.map((product) => {
62
+ const storeSku = resolveProductPlatformId(product, target);
63
+ return {
64
+ logicalId: product.id,
65
+ kind: purchaseKindOf(product.type),
66
+ ...(storeSku === undefined ? {} : { storeSku }),
67
+ };
68
+ });
69
+ }
@@ -1,4 +1,6 @@
1
1
  import type { AnalyticsSink } from '@mpgd/analytics';
2
+ import type { ProductCatalog } from '@mpgd/catalog';
3
+ import productCatalogJson from '@mpgd/catalog/catalog.json';
2
4
  import {
3
5
  createGameServicesRuntime,
4
6
  resolveGameServicesAuthorityMode,
@@ -11,7 +13,16 @@ import type { PlatformGateway } from '@mpgd/platform';
11
13
  import type { GameActionReconciliationPort } from '@mpgd/game-runtime/actions';
12
14
  import type { PurchasePresentationEvent } from '@mpgd/game-runtime/ads';
13
15
 
14
- /** Supply a game-owned durable encrypted journal and trusted recovery/native facts. */
16
+ import { resolveStarterCommerceProducts } from './commerceProducts';
17
+ import { createStorageMonetizationOperationStore } from './monetizationJournal';
18
+
19
+ const productCatalog = productCatalogJson as ProductCatalog;
20
+
21
+ /**
22
+ * Game-owned monetization ports. `operationStore` replaces the reference
23
+ * journal over platform storage with an encrypted, access-controlled one;
24
+ * `reconciliation` and `purchasePresentation` supply trusted native facts.
25
+ */
15
26
  export interface StarterMonetizationPorts {
16
27
  readonly operationStore: MonetizationOperationStore;
17
28
  readonly reconciliation?: GameActionReconciliationPort;
@@ -21,6 +32,13 @@ export interface StarterMonetizationPorts {
21
32
  export type StarterBackendMode = GameServicesRuntimeMode;
22
33
  export type StarterGameServices = GameServicesRuntime;
23
34
 
35
+ /**
36
+ * Game-services runtime over the commerce v2 reconciler: every purchase is
37
+ * journaled in a durable `MonetizationOperationStore` (the reference journal
38
+ * over the platform storage adapter unless the game supplies its own) and
39
+ * verified through the backend ledger, with the catalog's kinds and SKUs for
40
+ * the gateway target resolved without a store read.
41
+ */
24
42
  export function createStarterGameServices(input: {
25
43
  readonly gateway: PlatformGateway;
26
44
  readonly playerId: string;
@@ -32,7 +50,9 @@ export function createStarterGameServices(input: {
32
50
  return createGameServicesRuntime({
33
51
  gateway: input.gateway,
34
52
  playerId: input.playerId,
35
- ...(input.operationStore === undefined ? {} : { operationStore: input.operationStore }),
53
+ operationStore: input.operationStore
54
+ ?? createStorageMonetizationOperationStore({ storage: input.gateway.storage }),
55
+ commerceProducts: resolveStarterCommerceProducts(productCatalog, input.configTarget),
36
56
  ...(input.analytics === undefined ? {} : { analytics: input.analytics }),
37
57
  ...(input.analyticsSessionId === undefined
38
58
  ? {}
@@ -0,0 +1,286 @@
1
+ import {
2
+ isMonetizationOperationGranted,
3
+ isMonetizationOperationRecoverable,
4
+ type MonetizationOperationRecord,
5
+ type MonetizationOperationStore,
6
+ } from '@mpgd/game-services';
7
+ import type { StorageAdapter } from '@mpgd/platform';
8
+
9
+ /** The storage key the journal document lives under. */
10
+ export const defaultMonetizationJournalKey = 'mpgd:monetization-journal';
11
+ /** Terminal records older than this are dropped on the next write (30 days). */
12
+ export const defaultMonetizationJournalRetentionMs = 30 * 24 * 60 * 60 * 1000;
13
+ /**
14
+ * At most this many terminal records without a ledger grant and without a
15
+ * store transaction are kept (the most recently settled): finished rewarded
16
+ * ads and cancelled or failed attempts the store never answered. Platform
17
+ * storage bounds one value (the Devvit bridge allows 256 KiB), so the
18
+ * document must not grow with every one of them. Open records are never
19
+ * dropped; granted purchases (`granted`, `finishing`, `finished` with a
20
+ * ledger entry) and purchases that still name a store transaction leave by
21
+ * age, or by the byte budget (`defaultMonetizationJournalMaxDocumentBytes`)
22
+ * when the document would not fit, so the revocation pass and
23
+ * `findByTransaction` keep them for the window whenever storage allows.
24
+ */
25
+ export const defaultMonetizationJournalMaxTerminalRecords = 100;
26
+ /**
27
+ * Upper bound of the serialized document (192 KiB, below the Devvit bridge's
28
+ * 256 KiB per value). When the records in the window would exceed it, the
29
+ * oldest terminal records leave first: ungranted ones without a store
30
+ * transaction, then ungranted ones that still name a store transaction, then
31
+ * granted purchases. Open records are never dropped.
32
+ */
33
+ export const defaultMonetizationJournalMaxDocumentBytes = 192 * 1024;
34
+
35
+ export interface CreateStorageMonetizationOperationStoreInput {
36
+ /** The platform storage adapter (`gateway.storage`); the host owns its durability. */
37
+ readonly storage: StorageAdapter;
38
+ /** Defaults to `mpgd:monetization-journal`. */
39
+ readonly key?: string;
40
+ /** Defaults to 30 days; `Infinity` keeps every terminal record within the count bound. */
41
+ readonly retentionMs?: number;
42
+ /** Defaults to 100 ungranted terminal records; `Infinity` keeps them all within the window. */
43
+ readonly maxTerminalRecords?: number;
44
+ /** Defaults to 192 KiB of serialized JSON; see `defaultMonetizationJournalMaxDocumentBytes`. */
45
+ readonly maxDocumentBytes?: number;
46
+ readonly now?: () => number;
47
+ /**
48
+ * Cross-context lock for the read-modify-write of the document; defaults to
49
+ * the Web Locks API (`navigator.locks`) when the host has it, so two tabs or
50
+ * PWA windows over the same storage cannot lose each other's writes. `null`
51
+ * keeps only the in-context queue.
52
+ */
53
+ readonly locks?: JournalLockManager | null;
54
+ }
55
+
56
+ /** The part of the Web Locks API (`LockManager`) the journal uses. */
57
+ export interface JournalLockManager {
58
+ request<T>(name: string, callback: (lock: unknown) => Promise<T>): Promise<T>;
59
+ }
60
+
61
+ interface JournalDocument {
62
+ readonly version: 1;
63
+ readonly records: Readonly<Record<string, MonetizationOperationRecord>>;
64
+ }
65
+
66
+ /**
67
+ * The reference durable `MonetizationOperationStore` of the starter: one JSON
68
+ * document in the platform storage adapter, rewritten whole on every change
69
+ * so a record and its index can never disagree, terminal records bounded by
70
+ * age (and, without a ledger grant, by count; open records are always kept),
71
+ * with every operation
72
+ * serialized through one promise chain so the compare-and-swap of `replace`
73
+ * holds inside the WebView. It is crash recovery, not grant authority: the
74
+ * backend ledger still decides every grant. The platform storage is the
75
+ * host's; a game whose host cannot keep that storage private supplies an
76
+ * encrypted, access-controlled implementation through
77
+ * `bootstrapStarter({ monetization: { operationStore } })` instead.
78
+ *
79
+ * A document that cannot be parsed fails every call instead of being
80
+ * replaced: silently starting over would let a purchase be verified twice.
81
+ */
82
+ export function createStorageMonetizationOperationStore(
83
+ input: CreateStorageMonetizationOperationStoreInput,
84
+ ): MonetizationOperationStore {
85
+ const key = input.key ?? defaultMonetizationJournalKey;
86
+ const retentionMs = input.retentionMs ?? defaultMonetizationJournalRetentionMs;
87
+ const maxTerminalRecords = input.maxTerminalRecords
88
+ ?? defaultMonetizationJournalMaxTerminalRecords;
89
+ const maxDocumentBytes = input.maxDocumentBytes ?? defaultMonetizationJournalMaxDocumentBytes;
90
+ const now = input.now ?? (() => Date.now());
91
+ const locks = input.locks === undefined ? readHostLockManager() : (input.locks ?? undefined);
92
+ const lockName = `mpgd-monetization-journal:${key}`;
93
+ let chain: Promise<unknown> = Promise.resolve();
94
+
95
+ const serialize = <T>(operation: () => Promise<T>): Promise<T> => {
96
+ const run = locks === undefined ? operation : () => locks.request(lockName, () => operation());
97
+ const next = chain.then(run, run);
98
+ chain = next.catch(() => undefined);
99
+ return next;
100
+ };
101
+
102
+ const load = async (): Promise<Map<string, MonetizationOperationRecord>> => {
103
+ const loaded = await input.storage.load({ key });
104
+ if (loaded === null || loaded.value === undefined || loaded.value === null) {
105
+ return new Map();
106
+ }
107
+ const document = loaded.value;
108
+ if (!isJournalDocument(document)) {
109
+ throw new Error(`Monetization journal at ${key} is unreadable; it was not replaced.`);
110
+ }
111
+ return new Map(Object.entries(document.records));
112
+ };
113
+
114
+ const save = async (records: Map<string, MonetizationOperationRecord>): Promise<void> => {
115
+ const cutoff = now() - retentionMs;
116
+ const terminal: [string, number][] = [];
117
+ const kept: Record<string, MonetizationOperationRecord> = {};
118
+ /** Terminal records the byte budget may drop, with their drop tier (lower leaves first). */
119
+ const droppable: [string, number, number][] = [];
120
+ for (const [recordKey, record] of records) {
121
+ const settledAt = terminalSettledAt(record);
122
+ if (settledAt === undefined) {
123
+ kept[recordKey] = record;
124
+ } else if (settledAt < cutoff) {
125
+ continue;
126
+ } else if (isMonetizationOperationGranted(record)) {
127
+ kept[recordKey] = record;
128
+ droppable.push([recordKey, settledAt, 2]);
129
+ } else if (holdsStoreTransaction(record)) {
130
+ // A rejected or failed purchase the store still lists: the reconciler's
131
+ // findByTransaction needs it, so the count bound does not drop it.
132
+ kept[recordKey] = record;
133
+ droppable.push([recordKey, settledAt, 1]);
134
+ } else {
135
+ terminal.push([recordKey, settledAt]);
136
+ }
137
+ }
138
+ // Newest first; the oldest terminal records beyond the bound are dropped.
139
+ terminal.sort((left, right) => right[1] - left[1]);
140
+ for (const [recordKey, settledAt] of terminal.slice(0, Math.max(0, maxTerminalRecords))) {
141
+ const record = records.get(recordKey);
142
+ if (record !== undefined) {
143
+ kept[recordKey] = record;
144
+ droppable.push([recordKey, settledAt, 0]);
145
+ }
146
+ }
147
+ // The document must fit the host's per-value limit, or every later write
148
+ // fails and monetization stops: the oldest record of the lowest tier leaves
149
+ // until it fits. Open records are never dropped.
150
+ droppable.sort((left, right) => left[2] - right[2] || left[1] - right[1]);
151
+ let size = documentBytes(kept);
152
+ for (const [recordKey] of droppable) {
153
+ if (size <= maxDocumentBytes) {
154
+ break;
155
+ }
156
+ delete kept[recordKey];
157
+ size = documentBytes(kept);
158
+ }
159
+ const document: JournalDocument = { version: 1, records: kept };
160
+ await input.storage.save({ key, value: document });
161
+ };
162
+
163
+ return {
164
+ reserve(record) {
165
+ return serialize(async () => {
166
+ const records = await load();
167
+ const existing = records.get(record.key);
168
+ if (existing !== undefined) {
169
+ return { created: false, record: clone(existing) };
170
+ }
171
+ records.set(record.key, clone(record));
172
+ await save(records);
173
+ return { created: true, record: clone(record) };
174
+ });
175
+ },
176
+ read(recordKey) {
177
+ return serialize(async () => {
178
+ const record = (await load()).get(recordKey);
179
+ return record === undefined ? undefined : clone(record);
180
+ });
181
+ },
182
+ replace(expectedRevision, record) {
183
+ return serialize(async () => {
184
+ const records = await load();
185
+ const existing = records.get(record.key);
186
+ if (
187
+ existing === undefined
188
+ || existing.revision !== expectedRevision
189
+ || record.revision !== expectedRevision + 1
190
+ || existing.playerId !== record.playerId
191
+ || existing.target !== record.target
192
+ || existing.deploymentTarget !== record.deploymentTarget
193
+ || existing.kind !== record.kind
194
+ ) {
195
+ throw new Error('Stale or conflicting monetization journal write.');
196
+ }
197
+ records.set(record.key, clone(record));
198
+ await save(records);
199
+ });
200
+ },
201
+ listRecoverable(playerId) {
202
+ return serialize(async () =>
203
+ [...(await load()).values()]
204
+ .filter((record) => record.playerId === playerId
205
+ && isMonetizationOperationRecoverable(record))
206
+ .map(clone));
207
+ },
208
+ listGranted(playerId) {
209
+ return serialize(async () =>
210
+ [...(await load()).values()]
211
+ .filter((record) => record.playerId === playerId
212
+ && isMonetizationOperationGranted(record))
213
+ .map(clone));
214
+ },
215
+ findByTransaction(lookup) {
216
+ return serialize(async () => {
217
+ const found = [...(await load()).values()].find(
218
+ (record) =>
219
+ record.kind === 'commerce-purchase'
220
+ && record.playerId === lookup.playerId
221
+ && record.target === lookup.target
222
+ && record.deploymentTarget === lookup.deploymentTarget
223
+ && record.store === lookup.store
224
+ && record.purchase?.transactionId === lookup.transactionId,
225
+ );
226
+ return found === undefined ? undefined : clone(found);
227
+ });
228
+ },
229
+ };
230
+ }
231
+
232
+ /**
233
+ * When a terminal record settled, or `undefined` for an open record (never
234
+ * dropped). A terminal record without a readable time sorts as the newest:
235
+ * only the count bound drops it.
236
+ */
237
+ function terminalSettledAt(record: MonetizationOperationRecord): number | undefined {
238
+ if (isMonetizationOperationRecoverable(record)) {
239
+ return undefined;
240
+ }
241
+ const settledAt = record.kind === 'commerce-purchase'
242
+ ? record.updatedAt
243
+ : record.platformCompletedAt;
244
+ const time = settledAt === undefined ? Number.NaN : Date.parse(settledAt);
245
+ return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER;
246
+ }
247
+
248
+ /** A commerce purchase record that names a store transaction (the store may still list it). */
249
+ function holdsStoreTransaction(record: MonetizationOperationRecord): boolean {
250
+ return record.kind === 'commerce-purchase' && record.purchase?.transactionId !== undefined;
251
+ }
252
+
253
+ /** UTF-8 size of the document the records serialize to. */
254
+ function documentBytes(records: Readonly<Record<string, MonetizationOperationRecord>>): number {
255
+ return new TextEncoder().encode(JSON.stringify({ version: 1, records })).length;
256
+ }
257
+
258
+ function readHostLockManager(): JournalLockManager | undefined {
259
+ const candidate = (globalThis as { readonly navigator?: { readonly locks?: unknown } })
260
+ .navigator?.locks;
261
+ return typeof candidate === 'object'
262
+ && candidate !== null
263
+ && typeof (candidate as { readonly request?: unknown }).request === 'function'
264
+ ? candidate as JournalLockManager
265
+ : undefined;
266
+ }
267
+
268
+ function isJournalDocument(value: unknown): value is JournalDocument {
269
+ if (typeof value !== 'object' || value === null) {
270
+ return false;
271
+ }
272
+ const document = value as { readonly version?: unknown; readonly records?: unknown };
273
+ return document.version === 1
274
+ && typeof document.records === 'object'
275
+ && document.records !== null
276
+ && !Array.isArray(document.records)
277
+ && Object.values(document.records).every(
278
+ (record) => typeof record === 'object' && record !== null
279
+ && typeof (record as { readonly key?: unknown }).key === 'string'
280
+ && typeof (record as { readonly revision?: unknown }).revision === 'number',
281
+ );
282
+ }
283
+
284
+ function clone(record: MonetizationOperationRecord): MonetizationOperationRecord {
285
+ return structuredClone(record);
286
+ }
@@ -0,0 +1,23 @@
1
+ import type { Verse8RecordedPurchase } from '@mpgd/adapter-verse8';
2
+ import type { Entitlement } from '@mpgd/platform';
3
+
4
+ /**
5
+ * The game-owned Agent8 remote client the Verse8 gateway reads commerce state
6
+ * through: two authenticated server methods exposed for the sender's own
7
+ * account (see docs/VERSE8_COMMERCE.md, "Iframe client boundary"). The
8
+ * starter installs no Agent8 browser SDK, so `resolveStarterVerse8Agent8Client`
9
+ * answers `undefined` until the game provides one; VXShop then stays off and
10
+ * the gateway offers no `commerceV2`, exactly as before. The client only
11
+ * reads: `$onItemPurchased` on the Agent8 server is the sole grant path.
12
+ */
13
+ export interface StarterVerse8Agent8Client {
14
+ /** Server-owned balances and entitlements (`getSnapshot` of the Agent8 commerce service). */
15
+ getCommerceSnapshot(): Promise<{ readonly entitlements: readonly Entitlement[] }>;
16
+ /** The purchases `$onItemPurchased` recorded (`listPurchases` of the Agent8 commerce service). */
17
+ listPurchases(): Promise<readonly Verse8RecordedPurchase[]>;
18
+ }
19
+
20
+ /** Return the game's authenticated Agent8 client here to enable VXShop and commerce v2 on Verse8. */
21
+ export function resolveStarterVerse8Agent8Client(): StarterVerse8Agent8Client | undefined {
22
+ return undefined;
23
+ }