@mpgd/game-services 0.21.0 → 0.22.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.
@@ -0,0 +1,773 @@
1
+ import { commerceProtocolVersion, } from '@mpgd/platform/commerce';
2
+ import { assertCommerceStoreId, } from './types.js';
3
+ const defaultPurchaseCapabilities = Object.freeze({
4
+ consumable: true,
5
+ durable: true,
6
+ subscription: false,
7
+ });
8
+ /**
9
+ * The v1 request mapping (RFC "Open store ids"): a request without `store`
10
+ * names its store through its deployment target. `android` is `google-play`,
11
+ * `ios` is `app-store`, `ait` is `apps-in-toss`, `reddit` is `devvit`, and
12
+ * every other target is its own store id (`microsoft-store`, `verse8`,
13
+ * `oneplay`, or a project store that reuses its target name).
14
+ */
15
+ export function resolveLegacyCommerceStoreId(target) {
16
+ switch (target) {
17
+ case 'android':
18
+ return 'google-play';
19
+ case 'ios':
20
+ return 'app-store';
21
+ case 'ait':
22
+ return 'apps-in-toss';
23
+ case 'reddit':
24
+ return 'devvit';
25
+ default:
26
+ return target;
27
+ }
28
+ }
29
+ /** The store a verify request names: its `store`, or the v1 mapping of its `target`. */
30
+ export function resolveCommerceStoreId(request) {
31
+ return request.store ?? resolveLegacyCommerceStoreId(request.target);
32
+ }
33
+ /**
34
+ * Builds the registry. Construction fails when two entries claim one schema,
35
+ * when a `finish: 'server'` entry has no finalizer, when two entries of one
36
+ * store disagree on `finish`, or when more than one entry of a store accepts
37
+ * a missing envelope.
38
+ */
39
+ export function createCommerceEvidenceRegistry(entries, options = {}) {
40
+ const bySchema = new Map();
41
+ const byStore = new Map();
42
+ const missingEvidenceByStore = new Map();
43
+ const frozenEntries = [];
44
+ for (const entry of entries) {
45
+ assertRegistryEntry(entry);
46
+ if (bySchema.has(entry.schema)) {
47
+ throw new TypeError(`Commerce evidence schema ${entry.schema} is claimed by two registry entries.`);
48
+ }
49
+ const siblings = byStore.get(entry.store) ?? [];
50
+ const sibling = siblings[0];
51
+ if (sibling !== undefined && sibling.finish !== entry.finish) {
52
+ throw new TypeError(`Commerce store ${entry.store} declares finish ${sibling.finish} for ${sibling.schema} `
53
+ + `and ${entry.finish} for ${entry.schema}; one store has one finish owner.`);
54
+ }
55
+ if (sibling !== undefined && !samePurchaseCapabilities(sibling.purchase, entry.purchase)) {
56
+ // What a store sells is one profile; two entries of one store must
57
+ // declare it identically (or both leave the default), never be merged.
58
+ throw new TypeError(`Commerce store ${entry.store} declares different purchase kinds for ${sibling.schema} `
59
+ + `and ${entry.schema}; every entry of a store declares the same purchase profile.`);
60
+ }
61
+ if (entry.acceptsMissingEvidence === true) {
62
+ if (missingEvidenceByStore.has(entry.store)) {
63
+ throw new TypeError(`Commerce store ${entry.store} has two entries that accept a missing evidence envelope.`);
64
+ }
65
+ missingEvidenceByStore.set(entry.store, entry);
66
+ }
67
+ const frozen = Object.freeze({ ...entry });
68
+ bySchema.set(entry.schema, frozen);
69
+ byStore.set(entry.store, [...siblings, frozen]);
70
+ frozenEntries.push(frozen);
71
+ }
72
+ const fallback = options.unregistered;
73
+ const fallbackStores = new Map();
74
+ for (const declared of fallback?.stores ?? []) {
75
+ assertCommerceStoreId(declared.store, 'unregistered.stores[].store');
76
+ if (byStore.has(declared.store)) {
77
+ throw new TypeError(`Commerce store ${declared.store} has registry entries and is also declared for the `
78
+ + 'unregistered fallback; one or the other serves it.');
79
+ }
80
+ if (fallbackStores.has(declared.store)) {
81
+ throw new TypeError(`Commerce store ${declared.store} is declared twice for the fallback.`);
82
+ }
83
+ if (declared.finish === 'server' && fallback?.finalizer === undefined) {
84
+ throw new TypeError(`Commerce store ${declared.store} is declared for the fallback with finish: server and `
85
+ + 'needs unregistered.finalizer; without one its grants would never be acknowledged.');
86
+ }
87
+ fallbackStores.set(declared.store, declared);
88
+ }
89
+ const unclaimedByStore = new Map();
90
+ for (const entry of frozenEntries) {
91
+ if (entry.acceptsUnclaimedSchemas === true) {
92
+ if (unclaimedByStore.has(entry.store)) {
93
+ throw new TypeError(`Commerce store ${entry.store} has two entries that accept unclaimed schemas.`);
94
+ }
95
+ unclaimedByStore.set(entry.store, entry);
96
+ }
97
+ }
98
+ const servesUndeclaredStores = fallback !== undefined
99
+ && (fallback.servesUndeclaredStores ?? fallback.stores === undefined);
100
+ const stores = new Set([...byStore.keys(), ...fallbackStores.keys()]);
101
+ const allowedTargets = (store) => {
102
+ const storeEntries = byStore.get(store);
103
+ if (storeEntries !== undefined) {
104
+ if (storeEntries.some((entry) => entry.targets === undefined)) {
105
+ return undefined;
106
+ }
107
+ return new Set(storeEntries.flatMap((entry) => entry.targets ?? []));
108
+ }
109
+ const declared = fallbackStores.get(store);
110
+ return declared?.targets === undefined ? undefined : new Set(declared.targets);
111
+ };
112
+ const resolveStore = (request) => resolveCommerceStoreId(request);
113
+ const targetAllowed = (allowed, target) => allowed === undefined || allowed.includes(target);
114
+ const routeToEntry = (store, target, entry) => {
115
+ if (entry.store !== store) {
116
+ return { status: 'rejected', store, reason: 'EVIDENCE_SCHEMA_STORE_MISMATCH' };
117
+ }
118
+ if (!targetAllowed(entry.targets, target)) {
119
+ return { status: 'rejected', store, reason: 'EVIDENCE_SCHEMA_TARGET_MISMATCH' };
120
+ }
121
+ return { status: 'routed', store, entry };
122
+ };
123
+ const route = (request) => {
124
+ const store = resolveStore(request);
125
+ const schema = request.evidence?.schema;
126
+ const claimed = schema === undefined ? undefined : bySchema.get(schema);
127
+ if (claimed !== undefined) {
128
+ return routeToEntry(store, request.target, claimed);
129
+ }
130
+ if (byStore.has(store)) {
131
+ // A store with entries is never served by the fallback: an unclaimed
132
+ // schema or a missing envelope reaches only the entry that opted in.
133
+ const owner = schema === undefined
134
+ ? missingEvidenceByStore.get(store)
135
+ : unclaimedByStore.get(store);
136
+ return owner === undefined
137
+ ? { status: 'rejected', store, reason: 'EVIDENCE_SCHEMA_UNSUPPORTED' }
138
+ : routeToEntry(store, request.target, owner);
139
+ }
140
+ const declared = fallbackStores.get(store);
141
+ if (declared !== undefined) {
142
+ return targetAllowed(declared.targets, request.target)
143
+ ? { status: 'unregistered', store }
144
+ : { status: 'rejected', store, reason: 'EVIDENCE_SCHEMA_TARGET_MISMATCH' };
145
+ }
146
+ if (!servesUndeclaredStores) {
147
+ return { status: 'rejected', store, reason: 'EVIDENCE_SCHEMA_UNSUPPORTED' };
148
+ }
149
+ if (dodgesServedStore(request)) {
150
+ return { status: 'rejected', store, reason: 'EVIDENCE_SCHEMA_STORE_MISMATCH' };
151
+ }
152
+ return { status: 'unregistered', store };
153
+ };
154
+ /**
155
+ * An undeclared `store` label on a target whose v1 store is served by an
156
+ * entry or a declared fallback store (`{ target: 'android', store: 'x' }`)
157
+ * would route the target's purchase around that entry; it is refused.
158
+ */
159
+ const dodgesServedStore = (request) => {
160
+ const legacy = resolveLegacyCommerceStoreId(request.target);
161
+ return request.store !== undefined && request.store !== legacy && stores.has(legacy);
162
+ };
163
+ /**
164
+ * The entry that owns a grant being finalized: the schema and store the
165
+ * grant's ledger payload recorded come first (a retry may carry another
166
+ * envelope), then the request's schema, then the single finalizing entry
167
+ * of the grant's store (a grant written before the payload carried
168
+ * `evidenceSchema`).
169
+ */
170
+ const routeFinalization = (input) => {
171
+ const payloadStore = input.evidencePayload?.store;
172
+ const store = typeof payloadStore === 'string' && payloadStore.length > 0
173
+ ? payloadStore
174
+ : resolveStore(input.request);
175
+ const payloadSchema = input.evidencePayload?.evidenceSchema;
176
+ const schema = typeof payloadSchema === 'string' && payloadSchema.length > 0
177
+ ? payloadSchema
178
+ : input.request.evidence?.schema;
179
+ if (schema !== undefined) {
180
+ const entry = bySchema.get(schema);
181
+ if (entry !== undefined) {
182
+ return entry.store === store ? { kind: 'entry', entry } : { kind: 'mismatch', entry };
183
+ }
184
+ const unclaimed = unclaimedByStore.get(store);
185
+ if (unclaimed !== undefined) {
186
+ return { kind: 'entry', entry: unclaimed };
187
+ }
188
+ }
189
+ const finalizing = (byStore.get(store) ?? []).filter((entry) => entry.finalizer !== undefined);
190
+ const single = finalizing[0];
191
+ if (finalizing.length === 1 && single !== undefined) {
192
+ return { kind: 'entry', entry: single };
193
+ }
194
+ if (!byStore.has(store) && (fallbackStores.has(store) || servesUndeclaredStores)) {
195
+ return { kind: 'fallback' };
196
+ }
197
+ return { kind: 'none' };
198
+ };
199
+ const finalizer = {
200
+ supportsPurchaseGrant(input) {
201
+ const owner = routeFinalization(input);
202
+ switch (owner.kind) {
203
+ case 'entry':
204
+ return owner.entry.finalizer !== undefined
205
+ && (owner.entry.finalizer.supportsPurchaseGrant?.(input) ?? true);
206
+ case 'fallback':
207
+ return fallback?.finalizer !== undefined
208
+ && (fallback.finalizer.supportsPurchaseGrant?.(input) ?? true);
209
+ case 'mismatch':
210
+ // The owning entry has a finalizer that must not run for another
211
+ // store's grant: the grant stays pending rather than reported done.
212
+ return owner.entry.finalizer !== undefined;
213
+ case 'none':
214
+ return false;
215
+ }
216
+ },
217
+ async finalizePurchaseGrant(input) {
218
+ const owner = routeFinalization(input);
219
+ const selected = owner.kind === 'entry'
220
+ ? owner.entry.finalizer
221
+ : owner.kind === 'fallback'
222
+ ? fallback?.finalizer
223
+ : undefined;
224
+ if (selected === undefined) {
225
+ return {
226
+ status: 'pending',
227
+ alreadyCompleted: false,
228
+ reason: owner.kind === 'mismatch'
229
+ ? 'PURCHASE_FINALIZER_STORE_MISMATCH'
230
+ : 'PURCHASE_FINALIZER_UNREGISTERED',
231
+ };
232
+ }
233
+ return selected.finalizePurchaseGrant(input);
234
+ },
235
+ };
236
+ const purchaseProfileOf = (declared) => ({ ...(declared ?? defaultPurchaseCapabilities) });
237
+ const describeStore = (store, storeEntries) => {
238
+ const first = storeEntries[0];
239
+ if (first === undefined) {
240
+ throw new Error(`Commerce store ${store} has no registry entries.`);
241
+ }
242
+ return {
243
+ store,
244
+ schemas: storeEntries.map((entry) => entry.schema),
245
+ finish: first.finish,
246
+ purchase: purchaseProfileOf(first.purchase),
247
+ };
248
+ };
249
+ const getCapabilities = () => {
250
+ const published = [];
251
+ for (const [store, storeEntries] of byStore) {
252
+ published.push(describeStore(store, storeEntries));
253
+ }
254
+ for (const declared of fallbackStores.values()) {
255
+ published.push({
256
+ store: declared.store,
257
+ schemas: [],
258
+ finish: declared.finish,
259
+ purchase: purchaseProfileOf(declared.purchase),
260
+ });
261
+ }
262
+ return { commerceProtocolVersion, stores: published };
263
+ };
264
+ const serves = (request) => {
265
+ const store = resolveStore(request);
266
+ if (stores.has(store)) {
267
+ return true;
268
+ }
269
+ return servesUndeclaredStores && !dodgesServedStore(request);
270
+ };
271
+ const registry = {
272
+ entries: Object.freeze(frozenEntries),
273
+ stores,
274
+ serves,
275
+ servesUndeclaredStores,
276
+ allowedTargets,
277
+ resolveStore,
278
+ route,
279
+ async verifyPurchase(input) {
280
+ const routed = route(input.request);
281
+ if (routed.status === 'rejected') {
282
+ return { status: 'rejected', reason: routed.reason };
283
+ }
284
+ if (routed.status === 'unregistered') {
285
+ if (fallback === undefined) {
286
+ return { status: 'rejected', reason: 'EVIDENCE_SCHEMA_UNSUPPORTED' };
287
+ }
288
+ return fallback.verifier.verifyPurchase(input);
289
+ }
290
+ return routed.entry.verifier.verifyPurchase(input);
291
+ },
292
+ finalizer,
293
+ getCapabilities,
294
+ };
295
+ return Object.freeze(registry);
296
+ }
297
+ function samePurchaseCapabilities(left, right) {
298
+ const a = left ?? defaultPurchaseCapabilities;
299
+ const b = right ?? defaultPurchaseCapabilities;
300
+ return a.consumable === b.consumable
301
+ && a.durable === b.durable
302
+ && a.subscription === b.subscription;
303
+ }
304
+ function assertRegistryEntry(entry) {
305
+ if (typeof entry.schema !== 'string'
306
+ || entry.schema.trim() === ''
307
+ || entry.schema.length > 512
308
+ || entry.schema.trim() !== entry.schema) {
309
+ throw new TypeError('A commerce evidence registry entry needs a bounded schema identifier.');
310
+ }
311
+ try {
312
+ assertCommerceStoreId(entry.store, 'store');
313
+ }
314
+ catch (error) {
315
+ throw new TypeError(`Commerce evidence registry entry ${entry.schema}: ${error instanceof Error ? error.message : String(error)}`);
316
+ }
317
+ if (typeof entry.verifier?.verifyPurchase !== 'function') {
318
+ throw new TypeError(`Commerce evidence registry entry ${entry.schema} needs a verifier with verifyPurchase.`);
319
+ }
320
+ if (entry.finish !== 'client'
321
+ && entry.finish !== 'server'
322
+ && entry.finish !== 'host'
323
+ && entry.finish !== 'none') {
324
+ throw new TypeError(`Commerce evidence registry entry ${entry.schema} declares an unknown finish owner.`);
325
+ }
326
+ if (entry.finalizer !== undefined && typeof entry.finalizer.finalizePurchaseGrant !== 'function') {
327
+ throw new TypeError(`Commerce evidence registry entry ${entry.schema} has a finalizer without finalizePurchaseGrant.`);
328
+ }
329
+ if (entry.finish === 'server' && entry.finalizer === undefined) {
330
+ throw new TypeError(`Commerce evidence registry entry ${entry.schema} (store ${entry.store}) declares `
331
+ + 'finish: server and needs a finalizer; a server-finish store without one would leave '
332
+ + 'every grant unacknowledged.');
333
+ }
334
+ if (entry.creditScope !== undefined
335
+ && entry.creditScope !== 'title'
336
+ && entry.creditScope !== 'cross-title') {
337
+ throw new TypeError(`Commerce evidence registry entry ${entry.schema} declares an unknown credit scope.`);
338
+ }
339
+ if (entry.acceptsMissingEvidence !== undefined && typeof entry.acceptsMissingEvidence !== 'boolean') {
340
+ throw new TypeError(`Commerce evidence registry entry ${entry.schema}: acceptsMissingEvidence must be a boolean.`);
341
+ }
342
+ if (entry.acceptsUnclaimedSchemas !== undefined && typeof entry.acceptsUnclaimedSchemas !== 'boolean') {
343
+ throw new TypeError(`Commerce evidence registry entry ${entry.schema}: acceptsUnclaimedSchemas must be a boolean.`);
344
+ }
345
+ if (entry.targets !== undefined
346
+ && (!Array.isArray(entry.targets) || entry.targets.length === 0
347
+ || entry.targets.some((target) => typeof target !== 'string' || target.length === 0))) {
348
+ throw new TypeError(`Commerce evidence registry entry ${entry.schema}: targets must list ledger target names.`);
349
+ }
350
+ if (entry.purchase !== undefined) {
351
+ const { consumable, durable, subscription } = entry.purchase;
352
+ if (typeof consumable !== 'boolean'
353
+ || typeof durable !== 'boolean'
354
+ || typeof subscription !== 'boolean') {
355
+ throw new TypeError(`Commerce evidence registry entry ${entry.schema}: purchase flags must be booleans.`);
356
+ }
357
+ }
358
+ }
359
+ // Reference entries. Each wraps a verifier (and finalizer) the kit already
360
+ // ships with the RFC "Capabilities" profile row of its store.
361
+ /** The ledger target each reference store's verify requests name (RFC "Open store ids"). */
362
+ export const referenceCommerceLedgerTargets = Object.freeze({
363
+ 'google-play': Object.freeze(['android']),
364
+ 'app-store': Object.freeze(['ios']),
365
+ 'microsoft-store': Object.freeze(['microsoft-store']),
366
+ 'apps-in-toss': Object.freeze(['ait']),
367
+ devvit: Object.freeze(['reddit']),
368
+ verse8: Object.freeze(['verse8']),
369
+ });
370
+ /** The RFC profile purchase flags of the reference stores. */
371
+ export const referenceCommercePurchaseCapabilities = Object.freeze({
372
+ 'google-play': Object.freeze({ consumable: true, durable: true, subscription: false }),
373
+ 'app-store': Object.freeze({ consumable: true, durable: true, subscription: false }),
374
+ 'microsoft-store': Object.freeze({ consumable: true, durable: false, subscription: false }),
375
+ 'apps-in-toss': Object.freeze({ consumable: true, durable: true, subscription: false }),
376
+ devvit: Object.freeze({ consumable: true, durable: true, subscription: false }),
377
+ verse8: Object.freeze({ consumable: true, durable: true, subscription: false }),
378
+ });
379
+ export const googlePlayCommerceStoreId = 'google-play';
380
+ export const appStoreCommerceStoreId = 'app-store';
381
+ export const microsoftStoreCommerceStoreId = 'microsoft-store';
382
+ export const appsInTossCommerceStoreId = 'apps-in-toss';
383
+ export const devvitCommerceStoreId = 'devvit';
384
+ export const verse8CommerceStoreId = 'verse8';
385
+ export const googlePlayCommerceEvidenceSchema = 'google-play.product-purchase.v2';
386
+ export const appStoreCommerceEvidenceSchema = 'app-store.signed-transaction.v1';
387
+ export const microsoftStoreCommerceEvidenceSchema = 'mpgd.microsoft-store.digital-goods.v1';
388
+ export const appsInTossCommerceEvidenceSchema = 'apps-in-toss.iap.callback.v1';
389
+ export const devvitPaymentOrderEvidenceSchema = 'devvit.payment-order.v1';
390
+ export const devvitOrderEvidenceSchema = 'devvit.order.v1';
391
+ export const verse8Agent8EventEvidenceSchema = 'verse8.agent8-event.v1';
392
+ /**
393
+ * `google-play.product-purchase.v2`: the Play boundary verifies the purchase
394
+ * token and, after the ledger grant, acknowledges or consumes it
395
+ * (`finish: 'server'`). Pass `createGooglePlayProductPurchaseBoundary(...)`,
396
+ * or a verifier binding plus a separate finalizer.
397
+ */
398
+ export function createGooglePlayCommerceEvidenceEntry(input) {
399
+ const finish = input.finish ?? 'server';
400
+ return {
401
+ schema: googlePlayCommerceEvidenceSchema,
402
+ store: googlePlayCommerceStoreId,
403
+ verifier: input.verifier,
404
+ ...(finish === 'server' ? { finalizer: input.finalizer } : {}),
405
+ finish,
406
+ creditScope: 'title',
407
+ ...optionalRouting(input),
408
+ targets: input.targets ?? referenceCommerceLedgerTargets['google-play'],
409
+ purchase: referenceCommercePurchaseCapabilities['google-play'],
410
+ };
411
+ }
412
+ function optionalRouting(input) {
413
+ return {
414
+ ...(input.acceptsMissingEvidence === undefined
415
+ ? {}
416
+ : { acceptsMissingEvidence: input.acceptsMissingEvidence }),
417
+ ...(input.acceptsUnclaimedSchemas === undefined
418
+ ? {}
419
+ : { acceptsUnclaimedSchemas: input.acceptsUnclaimedSchemas }),
420
+ };
421
+ }
422
+ /**
423
+ * `app-store.signed-transaction.v1`: the App Store verifier checks the
424
+ * StoreKit JWS offline and decides on the Server API status; a request
425
+ * without the envelope keeps the `Get Transaction Info` lookup, so the entry
426
+ * accepts a missing envelope. The client finishes after the ledger grant.
427
+ */
428
+ export function createAppStoreCommerceEvidenceEntry(input) {
429
+ return {
430
+ schema: appStoreCommerceEvidenceSchema,
431
+ store: appStoreCommerceStoreId,
432
+ verifier: input.verifier,
433
+ finish: 'client',
434
+ creditScope: 'title',
435
+ ...optionalRouting(input),
436
+ acceptsMissingEvidence: input.acceptsMissingEvidence ?? true,
437
+ targets: input.targets ?? referenceCommerceLedgerTargets['app-store'],
438
+ purchase: referenceCommercePurchaseCapabilities['app-store'],
439
+ };
440
+ }
441
+ /**
442
+ * `mpgd.microsoft-store.digital-goods.v1`: the Collections verifier before
443
+ * the grant and the consume finalizer after it (`finish: 'server'`), always
444
+ * configured together.
445
+ */
446
+ export function createMicrosoftStoreCommerceEvidenceEntry(input) {
447
+ return {
448
+ schema: microsoftStoreCommerceEvidenceSchema,
449
+ store: microsoftStoreCommerceStoreId,
450
+ verifier: input.verifier,
451
+ finalizer: input.finalizer,
452
+ finish: 'server',
453
+ ...optionalRouting(input),
454
+ targets: input.targets ?? referenceCommerceLedgerTargets['microsoft-store'],
455
+ purchase: referenceCommercePurchaseCapabilities['microsoft-store'],
456
+ };
457
+ }
458
+ /**
459
+ * `apps-in-toss.iap.callback.v1`: the production evidence verifier binds the
460
+ * order through the partner API; the host finishes (`completeProductGrant`)
461
+ * after the ledger grant.
462
+ */
463
+ export function createAppsInTossCommerceEvidenceEntry(input) {
464
+ return {
465
+ schema: appsInTossCommerceEvidenceSchema,
466
+ store: appsInTossCommerceStoreId,
467
+ verifier: input.verifier,
468
+ finish: 'host',
469
+ ...optionalRouting(input),
470
+ targets: input.targets ?? referenceCommerceLedgerTargets['apps-in-toss'],
471
+ purchase: referenceCommercePurchaseCapabilities['apps-in-toss'],
472
+ };
473
+ }
474
+ /**
475
+ * The `devvit.payment-order.v1` verifier: the envelope
476
+ * `normalizeDevvitFulfillmentOrder` produced names the order, and the grant
477
+ * is decided by the deployment's payments authority (`PAID` or `DELIVERED`
478
+ * for the requesting player and SKU). Replay identity is the order id, so a
479
+ * second fulfillment of one order meets the first grant.
480
+ */
481
+ export function createDevvitPaymentOrderEvidenceVerifier(input) {
482
+ const now = input.now ?? (() => new Date().toISOString());
483
+ return {
484
+ async verifyPurchase({ request, platformProductId, signal }) {
485
+ const envelope = request.evidence;
486
+ if (envelope?.schema !== devvitPaymentOrderEvidenceSchema) {
487
+ return { status: 'rejected', reason: 'EVIDENCE_SCHEMA_UNSUPPORTED' };
488
+ }
489
+ const orderId = readBoundedIdentifier(envelope.payload.orderId);
490
+ if (orderId === undefined || orderId !== request.platformTransactionId) {
491
+ return { status: 'rejected', reason: 'DEVVIT_ORDER_ID_MISMATCH' };
492
+ }
493
+ if (envelope.payload.status !== 'PAID') {
494
+ return { status: 'rejected', reason: 'DEVVIT_ORDER_NOT_PAID' };
495
+ }
496
+ let result;
497
+ try {
498
+ result = await input.authority.getOrder({
499
+ orderId,
500
+ playerId: request.playerId,
501
+ platformSku: platformProductId,
502
+ signal,
503
+ });
504
+ }
505
+ catch (error) {
506
+ if (signal.aborted) {
507
+ throw error;
508
+ }
509
+ return { status: 'pending', reason: 'DEVVIT_ORDER_AUTHORITY_UNAVAILABLE' };
510
+ }
511
+ if (result.decision === 'pending') {
512
+ return { status: 'pending', ...(result.reason === undefined ? {} : { reason: result.reason }) };
513
+ }
514
+ if (result.decision === 'rejected') {
515
+ return { status: 'rejected', reason: result.reason };
516
+ }
517
+ const order = result.order;
518
+ if (order.orderId !== orderId) {
519
+ return { status: 'rejected', reason: 'DEVVIT_ORDER_ID_MISMATCH' };
520
+ }
521
+ if (order.playerId !== request.playerId) {
522
+ return { status: 'rejected', reason: 'DEVVIT_ORDER_PLAYER_MISMATCH' };
523
+ }
524
+ if (order.sku !== platformProductId) {
525
+ return { status: 'rejected', reason: 'DEVVIT_ORDER_SKU_MISMATCH' };
526
+ }
527
+ if (order.status === 'REVERTED') {
528
+ return { status: 'rejected', reason: 'DEVVIT_ORDER_REVERTED' };
529
+ }
530
+ if (order.status !== 'PAID' && order.status !== 'DELIVERED') {
531
+ return { status: 'pending', reason: 'DEVVIT_ORDER_NOT_PAID' };
532
+ }
533
+ return {
534
+ status: 'verified',
535
+ verificationId: `devvit:payment-order:${orderId}`,
536
+ verifiedAt: now(),
537
+ platformEvidenceId: orderId,
538
+ payload: {
539
+ devvitOrderId: orderId,
540
+ devvitOrderStatus: order.status,
541
+ ...(order.occurredAt === undefined ? {} : { devvitOrderOccurredAt: order.occurredAt }),
542
+ },
543
+ };
544
+ },
545
+ };
546
+ }
547
+ /** `devvit.payment-order.v1`: the fulfillment handler's own record, bound through the payments authority. */
548
+ export function createDevvitPaymentOrderCommerceEvidenceEntry(input) {
549
+ return {
550
+ schema: devvitPaymentOrderEvidenceSchema,
551
+ store: devvitCommerceStoreId,
552
+ verifier: createDevvitPaymentOrderEvidenceVerifier(input),
553
+ finish: 'none',
554
+ targets: input.targets ?? referenceCommerceLedgerTargets.devvit,
555
+ purchase: referenceCommercePurchaseCapabilities.devvit,
556
+ };
557
+ }
558
+ /**
559
+ * A server-push lookup verifier (RFC "Evidence registry"): it never grants
560
+ * from the client envelope. It answers `settled` with the ledger entry the
561
+ * host's fulfillment path already wrote for the same store and transaction
562
+ * id, when that entry belongs to the requesting player and product and is
563
+ * not reversed; `pending` while no such entry exists; and `rejected` when the
564
+ * envelope names another transaction or product than the request, or the
565
+ * entry belongs to someone else.
566
+ */
567
+ export function createCommerceLedgerLookupVerifier(input) {
568
+ return {
569
+ async verifyPurchase({ request, platformProductId }) {
570
+ const envelope = request.evidence;
571
+ if (envelope?.schema !== input.schema) {
572
+ return { status: 'rejected', reason: 'EVIDENCE_SCHEMA_UNSUPPORTED' };
573
+ }
574
+ const transactionId = input.readTransactionId(envelope.payload);
575
+ if (transactionId === undefined) {
576
+ return { status: 'rejected', reason: 'EVIDENCE_PAYLOAD_INVALID' };
577
+ }
578
+ if (transactionId !== request.platformTransactionId) {
579
+ return { status: 'rejected', reason: 'PLATFORM_TRANSACTION_ID_MISMATCH' };
580
+ }
581
+ const storeSku = input.readStoreSku?.(envelope.payload);
582
+ if (storeSku !== undefined && storeSku !== platformProductId) {
583
+ return { status: 'rejected', reason: 'PLATFORM_PRODUCT_MISMATCH' };
584
+ }
585
+ const grant = await findPurchaseGrantByPlatformEvidence(input.ledger, {
586
+ target: input.ledgerTarget ?? request.target,
587
+ platformEvidenceId: transactionId,
588
+ });
589
+ if (grant === undefined) {
590
+ return { status: 'pending', reason: 'LEDGER_GRANT_PENDING' };
591
+ }
592
+ if (grant.playerId !== request.playerId) {
593
+ return { status: 'rejected', reason: 'LEDGER_GRANT_PLAYER_MISMATCH' };
594
+ }
595
+ if (grant.grantId !== request.productId) {
596
+ return { status: 'rejected', reason: 'LEDGER_GRANT_PRODUCT_MISMATCH' };
597
+ }
598
+ const grantStore = grant.payload.store;
599
+ if (typeof grantStore === 'string' && grantStore.length > 0 && grantStore !== input.store) {
600
+ return { status: 'rejected', reason: 'EVIDENCE_SCHEMA_STORE_MISMATCH' };
601
+ }
602
+ if (input.ledger.findEntitlementRevocations !== undefined) {
603
+ const revocations = await input.ledger.findEntitlementRevocations({
604
+ ledgerEntryIds: [grant.ledgerEntryId],
605
+ });
606
+ if (revocations.length > 0) {
607
+ return { status: 'rejected', reason: 'LEDGER_GRANT_REVERSED' };
608
+ }
609
+ }
610
+ return {
611
+ status: 'settled',
612
+ ledgerEntryId: grant.ledgerEntryId,
613
+ ...(input.ledgerTarget === undefined ? {} : { ledgerTarget: input.ledgerTarget }),
614
+ };
615
+ },
616
+ };
617
+ }
618
+ /** The purchase grant written under a ledger target and store transaction id, through the store's index or a list scan. */
619
+ export async function findPurchaseGrantByPlatformEvidence(ledger, identity) {
620
+ if (ledger.findEntitlementTransactionByPlatformEvidence !== undefined) {
621
+ return ledger.findEntitlementTransactionByPlatformEvidence({
622
+ source: 'purchase',
623
+ target: identity.target,
624
+ platformEvidenceId: identity.platformEvidenceId,
625
+ });
626
+ }
627
+ const transactions = await ledger.listEntitlementTransactions();
628
+ return transactions.find((transaction) => transaction.source === 'purchase'
629
+ && transaction.payload.target === identity.target
630
+ && transaction.payload.platformTransactionId === identity.platformEvidenceId);
631
+ }
632
+ /**
633
+ * `devvit.order.v1`: the client's order envelope (a checkout result or the
634
+ * paid-order list) looked up against the ledger the fulfillment handler
635
+ * wrote under target `reddit` (`createPlatformOrderLedgerGrant`).
636
+ */
637
+ export function createDevvitOrderLookupCommerceEvidenceEntry(input) {
638
+ return {
639
+ schema: devvitOrderEvidenceSchema,
640
+ store: devvitCommerceStoreId,
641
+ verifier: createCommerceLedgerLookupVerifier({
642
+ schema: devvitOrderEvidenceSchema,
643
+ store: devvitCommerceStoreId,
644
+ ledger: input.ledger,
645
+ readTransactionId: (payload) => readBoundedIdentifier(payload.orderId),
646
+ readStoreSku: (payload) => readBoundedIdentifier(payload.sku),
647
+ ...(input.ledgerTarget === undefined ? {} : { ledgerTarget: input.ledgerTarget }),
648
+ }),
649
+ finish: 'none',
650
+ targets: input.targets ?? referenceCommerceLedgerTargets.devvit,
651
+ purchase: referenceCommercePurchaseCapabilities.devvit,
652
+ };
653
+ }
654
+ /**
655
+ * `verse8.agent8-event.v1`: the client's Agent8 purchase envelope looked up
656
+ * against the ledger the `$onItemPurchased` event wrote under target `verse8`
657
+ * (`createServerPushLedgerGrant`; the Agent8 commerce service writes it when
658
+ * given a ledger).
659
+ */
660
+ export function createVerse8Agent8EventLookupCommerceEvidenceEntry(input) {
661
+ return {
662
+ schema: verse8Agent8EventEvidenceSchema,
663
+ store: verse8CommerceStoreId,
664
+ verifier: createCommerceLedgerLookupVerifier({
665
+ schema: verse8Agent8EventEvidenceSchema,
666
+ store: verse8CommerceStoreId,
667
+ ledger: input.ledger,
668
+ readTransactionId: (payload) => readBoundedIdentifier(payload.purchaseId),
669
+ readStoreSku: (payload) => readBoundedIdentifier(payload.productId),
670
+ ...(input.ledgerTarget === undefined ? {} : { ledgerTarget: input.ledgerTarget }),
671
+ }),
672
+ finish: 'none',
673
+ targets: input.targets ?? referenceCommerceLedgerTargets.verse8,
674
+ purchase: referenceCommercePurchaseCapabilities.verse8,
675
+ };
676
+ }
677
+ /**
678
+ * The ledger grant a host's fulfillment path writes for a server-push
679
+ * purchase, in the shape the lookup verifiers read: `payload.target`,
680
+ * `payload.store`, and `payload.platformTransactionId` identify it, and the
681
+ * platform-evidence unique index keeps a second write of the same transaction
682
+ * out. Write it with `GameServicesStore.recordEntitlementGrant`.
683
+ */
684
+ export function createServerPushLedgerGrant(input) {
685
+ assertCommerceStoreId(input.store, 'store');
686
+ const transactionId = readBoundedIdentifier(input.transactionId);
687
+ if (transactionId === undefined) {
688
+ throw new TypeError('A server-push ledger grant needs a bounded transaction id.');
689
+ }
690
+ const evidenceVerificationId = input.evidenceVerificationId
691
+ ?? `${input.store}:${input.evidence.schema}:${transactionId}`;
692
+ const quantity = input.quantity ?? 1;
693
+ if (!Number.isSafeInteger(quantity) || quantity <= 0) {
694
+ throw new TypeError('A server-push ledger grant quantity must be a positive integer.');
695
+ }
696
+ const grant = input.product.grant;
697
+ const scaledGrant = quantity === 1 || grant.type === 'entitlement'
698
+ ? grant
699
+ : { ...grant, amount: grant.amount * quantity };
700
+ return {
701
+ playerId: input.playerId,
702
+ grantId: input.product.id,
703
+ source: 'purchase',
704
+ idempotencyKey: input.idempotencyKey
705
+ ?? `store:${encodeURIComponent(input.store)}:${encodeURIComponent(transactionId)}`,
706
+ grantedAt: input.grantedAt,
707
+ grant: scaledGrant,
708
+ evidenceVerificationId,
709
+ payload: {
710
+ target: input.target,
711
+ store: input.store,
712
+ ...(input.deploymentTarget === undefined ? {} : { deploymentTarget: input.deploymentTarget }),
713
+ productId: input.product.id,
714
+ productType: input.product.type,
715
+ platformProductId: input.platformProductId,
716
+ platformTransactionId: transactionId,
717
+ quantity,
718
+ purchasedAt: input.purchasedAt,
719
+ evidenceSchema: input.evidence.schema,
720
+ evidenceVerificationId,
721
+ evidenceVerifiedAt: input.grantedAt,
722
+ },
723
+ };
724
+ }
725
+ /**
726
+ * Compares a provider's capability block with the backend's published
727
+ * capabilities (RFC "Provider capabilities endpoint"): a store the backend
728
+ * does not accept, a finish owner that disagrees, or another protocol major
729
+ * version sets the provider's availability to `configuration-required`
730
+ * before any purchase UI opens. The reconciler applies it at start.
731
+ */
732
+ export function compareCommerceServiceCapabilities(input) {
733
+ const { store } = input;
734
+ if (majorVersionOf(input.service.commerceProtocolVersion) !== majorVersionOf(commerceProtocolVersion)) {
735
+ return {
736
+ status: 'configuration-required',
737
+ store,
738
+ reason: 'PROTOCOL_VERSION_MISMATCH',
739
+ message: `The backend speaks commerce protocol ${input.service.commerceProtocolVersion}; this client speaks ${commerceProtocolVersion}.`,
740
+ };
741
+ }
742
+ const accepted = input.service.stores.find((candidate) => candidate.store === store);
743
+ if (accepted === undefined) {
744
+ return {
745
+ status: 'configuration-required',
746
+ store,
747
+ reason: 'STORE_NOT_ACCEPTED',
748
+ message: `The backend accepts no evidence for store ${store}.`,
749
+ };
750
+ }
751
+ if (accepted.finish !== input.provider.lifecycle.finish) {
752
+ return {
753
+ status: 'configuration-required',
754
+ store,
755
+ reason: 'FINISH_OWNER_MISMATCH',
756
+ message: `The backend expects ${accepted.finish} to finish ${store} purchases; the provider declares ${input.provider.lifecycle.finish}.`,
757
+ };
758
+ }
759
+ return { status: 'aligned', store };
760
+ }
761
+ function majorVersionOf(version) {
762
+ return version.split('.')[0] ?? version;
763
+ }
764
+ function readBoundedIdentifier(value) {
765
+ if (typeof value !== 'string'
766
+ || value.length === 0
767
+ || value.length > 256
768
+ || value.trim() !== value
769
+ || /[\p{Cc}\p{Cf}]/u.test(value)) {
770
+ return undefined;
771
+ }
772
+ return value;
773
+ }