@mpgd/adapter-devvit 0.10.1 → 0.12.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.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # @mpgd/adapter-devvit
2
+
3
+ Reddit Devvit Web platform adapter for mpgd games.
4
+
5
+ | Entry point | Purpose |
6
+ | --- | --- |
7
+ | `@mpgd/adapter-devvit` | `createDevvitPlatformGateway`, the oRPC bridge and sandbox bridge, the commerce provider and catalog helpers |
8
+ | `@mpgd/adapter-devvit/payments` | `normalizeDevvitCommerceProducts` and the checkout types |
9
+ | `@mpgd/adapter-devvit/server` | `createDevvitCommerceV2BridgeHandler`, payment-order normalizers, durable post operations, Redis stores |
10
+ | `@mpgd/adapter-devvit/web` | Webview helpers, including `createDevvitWebPaymentsClient()` over `purchase()` from `@devvit/web/client` |
11
+ | `@mpgd/adapter-devvit/surface`, `/view-mode` | Inline and expanded view-mode helpers |
12
+
13
+ ## Commerce
14
+
15
+ The gateway has at most one commerce surface, `gateway.commerce`, a commerce v2 `CommerceProvider`
16
+ (`createDevvitCommerceProvider`). `createDevvitPlatformGateway({ commerce: { products, client } })`
17
+ installs it and reports its block as `getCapabilities().commerce`; without the option the gateway
18
+ sells nothing, has no `commerce`, and reports `createUnsupportedCommerceCapabilities()`. Reddit
19
+ settles a purchase on the server, so only the backend ledger the fulfillment handler writes grants
20
+ anything. Games purchase through `@mpgd/game-services`; see the
21
+ [Devvit commerce guide](../../docs/DEVVIT_COMMERCE.md).
22
+
23
+ ## Migrating from v1
24
+
25
+ | Removed | Replacement |
26
+ | --- | --- |
27
+ | `createDevvitCommerceAdapter`, `withDevvitCommerceAdapter`, `CreateDevvitCommerceAdapterInput` | `createDevvitPlatformGateway({ commerce })` or `createDevvitCommerceProvider` |
28
+ | v1 `gateway.commerce` (`getProducts`, `purchase`, `restore`, `getEntitlements`) | `gateway.commerce`, the provider (absent when nothing is sold) |
29
+ | `gateway.commerceV2` | `gateway.commerce` |
30
+ | `DevvitPaymentsClient.getEntitlements()` | The backend ledger (`listActiveEntitlementTransactions` on the server); the client is only `purchase(sku)` |
31
+ | `capabilities.nativeIap`, `capabilities.subscriptionIap` | `capabilities.commerce` |
32
+ | Bridge methods `commerce.getProducts`, `commerce.purchase`, `commerce.restore`, `commerce.getEntitlements` | `UNSUPPORTED_METHOD`; the six `commerce.v2.*` methods |
@@ -0,0 +1,48 @@
1
+ import type { ProductInfo, ProductType } from '@mpgd/platform';
2
+ import { type DevvitCommerceProduct } from './payments.js';
3
+ /** The catalog target whose `platformProductIds` entry is the Devvit SKU. */
4
+ export declare const defaultDevvitCatalogTarget = "reddit";
5
+ /**
6
+ * The product catalog fields this module reads (`ProductCatalog` from
7
+ * `@mpgd/catalog` fits). Structural so the adapter adds no catalog dependency.
8
+ */
9
+ export interface DevvitCatalogProductLike {
10
+ readonly id: string;
11
+ readonly type: ProductType;
12
+ readonly platformProductIds: Readonly<Partial<Record<string, string>>>;
13
+ }
14
+ export interface DevvitCatalogLike {
15
+ readonly products: readonly DevvitCatalogProductLike[];
16
+ }
17
+ /** A logical product paired with its Devvit SKU, as the server bridge handler takes it. */
18
+ export interface DevvitCatalogBridgeProduct {
19
+ readonly productId: string;
20
+ readonly sku: string;
21
+ }
22
+ export interface CreateDevvitCommerceProductsOptions {
23
+ /** Defaults to `reddit`. */
24
+ readonly target?: string;
25
+ /**
26
+ * Static product info for the webview provider's `fetchProducts`. Devvit
27
+ * prices live products in Reddit Gold on the server (`payments.getProducts`),
28
+ * so the default lists the logical id as title with a Reddit Gold price
29
+ * placeholder; a game that wants localized copy supplies its own.
30
+ */
31
+ readonly describe?: (product: DevvitCatalogProductLike, sku: string) => Pick<ProductInfo, 'title' | 'description' | 'price'>;
32
+ }
33
+ /**
34
+ * The `{ productId, sku }` pairs `createDevvitCommerceV2BridgeHandler` takes,
35
+ * read from the catalog's `platformProductIds[target]` entries. The catalog is
36
+ * the single source of the Devvit SKUs and its rules apply, as
37
+ * `resolveProductPlatformId` reads them: the SKU is trimmed, and a product
38
+ * without an entry, or with a blank one, is not sold on Reddit. A subscription
39
+ * is skipped because Devvit sells one-time goods only. Duplicate product ids
40
+ * and SKUs are rejected.
41
+ */
42
+ export declare function createDevvitCommerceBridgeProducts(catalog: DevvitCatalogLike, target?: string): readonly DevvitCatalogBridgeProduct[];
43
+ /**
44
+ * The webview products for `createDevvitPlatformGateway({ commerce: { products } })`,
45
+ * derived from the same catalog entries as `createDevvitCommerceBridgeProducts`,
46
+ * so the webview and the server agree on every SKU.
47
+ */
48
+ export declare function createDevvitCommerceProducts(catalog: DevvitCatalogLike, options?: CreateDevvitCommerceProductsOptions): readonly DevvitCommerceProduct[];
@@ -0,0 +1,78 @@
1
+ import { normalizeDevvitCommerceProducts } from './payments.js';
2
+ /** The catalog target whose `platformProductIds` entry is the Devvit SKU. */
3
+ export const defaultDevvitCatalogTarget = 'reddit';
4
+ /**
5
+ * The `{ productId, sku }` pairs `createDevvitCommerceV2BridgeHandler` takes,
6
+ * read from the catalog's `platformProductIds[target]` entries. The catalog is
7
+ * the single source of the Devvit SKUs and its rules apply, as
8
+ * `resolveProductPlatformId` reads them: the SKU is trimmed, and a product
9
+ * without an entry, or with a blank one, is not sold on Reddit. A subscription
10
+ * is skipped because Devvit sells one-time goods only. Duplicate product ids
11
+ * and SKUs are rejected.
12
+ */
13
+ export function createDevvitCommerceBridgeProducts(catalog, target = defaultDevvitCatalogTarget) {
14
+ const skus = new Set();
15
+ const productIds = new Set();
16
+ const products = [];
17
+ for (const product of catalog.products) {
18
+ const sku = readSku(product, target);
19
+ if (sku === undefined || product.type === 'subscription') {
20
+ continue;
21
+ }
22
+ if (productIds.has(product.id)) {
23
+ throw new TypeError(`Duplicate Devvit logical product ID: ${product.id}`);
24
+ }
25
+ if (skus.has(sku)) {
26
+ throw new TypeError(`Duplicate Devvit product SKU: ${sku}`);
27
+ }
28
+ productIds.add(product.id);
29
+ skus.add(sku);
30
+ products.push(Object.freeze({ productId: product.id, sku }));
31
+ }
32
+ return Object.freeze(products);
33
+ }
34
+ /**
35
+ * The webview products for `createDevvitPlatformGateway({ commerce: { products } })`,
36
+ * derived from the same catalog entries as `createDevvitCommerceBridgeProducts`,
37
+ * so the webview and the server agree on every SKU.
38
+ */
39
+ export function createDevvitCommerceProducts(catalog, options = {}) {
40
+ const target = options.target ?? defaultDevvitCatalogTarget;
41
+ const describe = options.describe ?? describeDevvitProduct;
42
+ const bridgeProducts = createDevvitCommerceBridgeProducts(catalog, target);
43
+ const byId = new Map(catalog.products.map((product) => [product.id, product]));
44
+ return normalizeDevvitCommerceProducts(bridgeProducts.map((bridgeProduct) => {
45
+ const product = byId.get(bridgeProduct.productId);
46
+ if (product === undefined) {
47
+ throw new TypeError(`Devvit product ${bridgeProduct.productId} is missing from the catalog.`);
48
+ }
49
+ return {
50
+ info: {
51
+ id: product.id,
52
+ type: product.type,
53
+ ...describe(product, bridgeProduct.sku),
54
+ },
55
+ sku: bridgeProduct.sku,
56
+ };
57
+ }));
58
+ }
59
+ function describeDevvitProduct(product) {
60
+ return {
61
+ title: product.id,
62
+ description: '',
63
+ price: { formatted: 'Reddit Gold', currencyCode: 'GOLD' },
64
+ };
65
+ }
66
+ /** The catalog's reading of a platform id: own key only, trimmed, blank as unsold. */
67
+ function readSku(product, target) {
68
+ const identifiers = product.platformProductIds;
69
+ if (!Object.prototype.hasOwnProperty.call(identifiers, target)) {
70
+ return undefined;
71
+ }
72
+ const value = identifiers[target];
73
+ if (typeof value !== 'string') {
74
+ return undefined;
75
+ }
76
+ const sku = value.trim();
77
+ return sku.length === 0 ? undefined : sku;
78
+ }
@@ -40,7 +40,7 @@ export type DevvitOrdersChangeListener = (change?: DevvitOrdersChange) => void;
40
40
  export interface CreateDevvitCommerceProviderInput {
41
41
  readonly products: readonly DevvitCommerceProduct[];
42
42
  /** Opens the Reddit checkout (`purchase()` from `@devvit/payments/client`). */
43
- readonly client: Pick<DevvitPaymentsClient, 'purchase'>;
43
+ readonly client: DevvitPaymentsClient;
44
44
  readonly orders: DevvitOrderSource;
45
45
  /** Defaults to `devvit`; conformance vectors name their fixture store. */
46
46
  readonly store?: StoreId;
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ import { type CreateDevvitCommerceProviderInput, type DevvitOrderSource } from '
5
5
  export * from './commerce-contract.js';
6
6
  export * from './commerce-provider.js';
7
7
  export * from './payments.js';
8
+ export * from './catalog-products.js';
8
9
  export declare const defaultDevvitRpcEndpoint = "/api/mpgd/rpc";
9
10
  /** Bridge error of `createDevvitOrpcBridge` when the oRPC request itself failed. */
10
11
  export declare const devvitBridgeNetworkErrorCode: 'DEVVIT_BRIDGE_NETWORK_ERROR';
@@ -35,15 +36,17 @@ export interface DevvitPlatformGatewayOptions {
35
36
  readonly fallbackBridge?: DevvitBridge;
36
37
  readonly rpcEndpoint?: BridgeRpcEndpoint;
37
38
  /**
38
- * Installs the commerce v2 provider (`createDevvitCommerceProvider`) as
39
- * `commerceV2`, with the `commerce` capability block and `nativeIap` and
40
- * `subscriptionIap` derived from it. Absent, the gateway offers no
41
- * `commerceV2`; the v1 `commerce` surface is the bridge's either way.
39
+ * Installs the commerce provider (`createDevvitCommerceProvider`) as the
40
+ * gateway's `commerce`, and its capability block as the snapshot's
41
+ * `commerce`. Absent, the gateway sells nothing: it has no `commerce` and
42
+ * reports `createUnsupportedCommerceCapabilities()`, whatever the server
43
+ * snapshot says.
42
44
  *
43
- * Not yet usable in production before C2: the backend lookup verifier for
44
- * `devvit.order.v1` does not exist yet, so a verify request for a Devvit
45
- * purchase is refused today instead of answering `pending`, and no journal
46
- * record can reach `granted`. Enable it once the backend can answer.
45
+ * A purchase is granted by the backend ledger only: the fulfillment handler
46
+ * writes the ledger entry, and the backend's `devvit.order.v1` lookup
47
+ * verifier answers the reconciler's verify request from it. Owned
48
+ * entitlements come from that ledger through `@mpgd/game-services`, not
49
+ * from the payments client.
47
50
  */
48
51
  readonly commerce?: DevvitGatewayCommerceOptions;
49
52
  }
package/dist/index.js CHANGED
@@ -1,12 +1,14 @@
1
- import { bridgeStorageLoadProtocol, commerceV2UnavailableBridgeErrorCode, createBridgeError, createCommerceV2UnavailableBridgeError, decodeBridgeStorageLoadData, } from '@mpgd/bridge';
1
+ import { bridgeStorageLoadProtocol, commerceV2UnavailableBridgeErrorCode, createBridgeError, createCommerceV2UnavailableBridgeError, createNotificationsV2UnavailableBridgeError, decodeBridgeStorageLoadData, isBridgeNotificationsV2Method, } from '@mpgd/bridge';
2
2
  import { createBridgeOrpcClient, defaultBridgeRpcEndpoint, } from '@mpgd/bridge/orpc';
3
- import { PlatformOperationError, } from '@mpgd/platform';
4
- import { assertCommerceBridgeGetAvailablePurchasesResult, assertCommerceBridgeGetCapabilitiesResult, CommerceError, commerceProtocol, deriveLegacyCommerceCapabilities, isCommerceErrorCode, } from '@mpgd/platform/commerce';
3
+ import { createUnsupportedCommerceCapabilities, PlatformOperationError, } from '@mpgd/platform';
4
+ import { assertCommerceBridgeGetAvailablePurchasesResult, assertCommerceBridgeGetCapabilitiesResult, CommerceError, commerceProtocol, isCommerceErrorCode, } from '@mpgd/platform/commerce';
5
+ import { withShareIntentCheck } from '@mpgd/platform/routes';
5
6
  import { assertDevvitOrderEvidence } from './commerce-contract.js';
6
7
  import { createDevvitCommerceProvider, } from './commerce-provider.js';
7
8
  export * from './commerce-contract.js';
8
9
  export * from './commerce-provider.js';
9
10
  export * from './payments.js';
11
+ export * from './catalog-products.js';
10
12
  export const defaultDevvitRpcEndpoint = defaultBridgeRpcEndpoint;
11
13
  /** Bridge error of `createDevvitOrpcBridge` when the oRPC request itself failed. */
12
14
  export const devvitBridgeNetworkErrorCode = 'DEVVIT_BRIDGE_NETWORK_ERROR';
@@ -44,7 +46,7 @@ export function createDevvitPlatformGateway(input) {
44
46
  }
45
47
  return response.data;
46
48
  }
47
- const commerceV2 = input.commerce === undefined
49
+ const commerce = input.commerce === undefined
48
50
  ? undefined
49
51
  : createDevvitCommerceProvider({
50
52
  ...input.commerce,
@@ -53,14 +55,20 @@ export function createDevvitPlatformGateway(input) {
53
55
  return {
54
56
  target: 'reddit',
55
57
  async getCapabilities() {
56
- const capabilities = { ...await request('runtime.getCapabilities', {}) };
57
- if (commerceV2 === undefined) {
58
- return capabilities;
58
+ const { nativeIap: _nativeIap, subscriptionIap: _subscriptionIap, commerce: _serverCommerce, ...capabilities } = await request('runtime.getCapabilities', {});
59
+ const availability = capabilities.providerAvailability;
60
+ if (typeof availability === 'object' && availability !== null && !Array.isArray(availability)) {
61
+ const { nativeIap: _retiredNativeIap, subscriptionIap: _retiredSubscriptionIap, ...current } = availability;
62
+ capabilities.providerAvailability = current;
59
63
  }
60
64
  // The webview provider owns the block: the server snapshot stays honest
61
- // about the server, and the flags derive from the block it reports.
62
- const commerce = await commerceV2.getCapabilities();
63
- return { ...capabilities, ...deriveLegacyCommerceCapabilities(commerce), commerce };
65
+ // about the server, and a gateway without a provider sells nothing.
66
+ return {
67
+ ...capabilities,
68
+ commerce: commerce === undefined
69
+ ? createUnsupportedCommerceCapabilities()
70
+ : await commerce.getCapabilities(),
71
+ };
64
72
  },
65
73
  identity: {
66
74
  getPlayer: () => request('identity.getPlayer', {}),
@@ -71,21 +79,17 @@ export function createDevvitPlatformGateway(input) {
71
79
  getLaunchIntent: () => request('presentation.getLaunchIntent', {}),
72
80
  requestGameSurface: (payload) => request('presentation.requestGameSurface', payload),
73
81
  },
74
- sharing: {
82
+ // The bridge's host may build a share URL from the link, so it only ever
83
+ // sees an intent the ShareIntent contract accepts, with the link normalized.
84
+ sharing: withShareIntentCheck({
75
85
  share: (payload) => request('share.share', payload),
76
86
  readInboundShare: () => request('share.readInboundShare', {}),
77
- },
87
+ }),
78
88
  notifications: {
79
89
  getStatus: (topic) => request('notifications.getStatus', { topic }),
80
90
  requestSubscription: (topic) => request('notifications.requestSubscription', { topic }),
81
91
  },
82
- commerce: {
83
- getProducts: () => request('commerce.getProducts', {}),
84
- purchase: (payload) => request('commerce.purchase', payload),
85
- restore: () => request('commerce.restore', {}),
86
- getEntitlements: () => request('commerce.getEntitlements', {}),
87
- },
88
- ...(commerceV2 === undefined ? {} : { commerceV2 }),
92
+ ...(commerce === undefined ? {} : { commerce }),
89
93
  ads: {
90
94
  preload: (payload) => request('ads.preload', payload),
91
95
  showRewarded: (payload) => request('ads.showRewarded', payload),
@@ -286,10 +290,14 @@ export function createDevvitSandboxBridge() {
286
290
  const storage = new Map();
287
291
  return {
288
292
  async request(input) {
293
+ // Notifications v2 arrives with the Devvit provider (P3); until then
294
+ // every v2 method answers the dedicated code.
295
+ if (isBridgeNotificationsV2Method(input.method)) {
296
+ return createNotificationsV2UnavailableBridgeError(input, 'Devvit sandbox bridge');
297
+ }
289
298
  switch (input.method) {
290
299
  case 'runtime.getCapabilities':
291
300
  return ok(input, {
292
- nativeIap: false,
293
301
  nativeAds: false,
294
302
  rewardedAds: false,
295
303
  interstitialAds: false,
@@ -300,6 +308,7 @@ export function createDevvitSandboxBridge() {
300
308
  socialShare: false,
301
309
  haptics: false,
302
310
  localizedContent: true,
311
+ commerce: createUnsupportedCommerceCapabilities(),
303
312
  });
304
313
  case 'identity.getPlayer':
305
314
  return ok(input, {
@@ -333,18 +342,6 @@ export function createDevvitSandboxBridge() {
333
342
  return ok(input, 'approval-required');
334
343
  case 'notifications.requestSubscription':
335
344
  return ok(input, 'unavailable');
336
- case 'commerce.getProducts':
337
- case 'commerce.getEntitlements':
338
- return ok(input, []);
339
- case 'commerce.purchase':
340
- return ok(input, {
341
- status: 'cancelled',
342
- entitlementIds: [],
343
- });
344
- case 'commerce.restore':
345
- return ok(input, {
346
- restoredEntitlements: [],
347
- });
348
345
  case 'ads.preload':
349
346
  return ok(input, {});
350
347
  case 'leaderboard.open':
@@ -384,7 +381,7 @@ export function createDevvitSandboxBridge() {
384
381
  saved: key !== undefined,
385
382
  });
386
383
  }
387
- // Commerce v2 providers arrive with the B-phase PRs (B6 for Devvit).
384
+ // The sandbox sells nothing: every commerce method fails closed.
388
385
  case 'commerce.v2.getCapabilities':
389
386
  case 'commerce.v2.fetchProducts':
390
387
  case 'commerce.v2.requestPurchase':
@@ -1,4 +1,4 @@
1
- import type { CommerceAdapter, Entitlement, PlatformEvidenceEnvelope, PlatformGateway, ProductInfo, PurchaseResult } from '@mpgd/platform';
1
+ import type { PlatformEvidenceEnvelope, ProductInfo, PurchaseResult } from '@mpgd/platform';
2
2
  /**
3
3
  * Why a Devvit checkout ended without an order (`OrderResultEvent` with
4
4
  * `STATUS_ERROR` or `STATUS_CANCELLED`). `code` is a commerce v2 error code
@@ -19,20 +19,18 @@ export interface DevvitCheckoutResult {
19
19
  /** Read by the commerce v2 provider for a `cancelled` or `failed` checkout. */
20
20
  readonly error?: DevvitCheckoutFailure;
21
21
  }
22
+ /**
23
+ * The checkout half of the Reddit payments client: the commerce v2 provider
24
+ * opens the checkout through it and needs nothing else. Owned entitlements are
25
+ * not read from the client; the backend ledger answers them through
26
+ * `@mpgd/game-services`, and the paid orders come from the server bridge.
27
+ */
22
28
  export interface DevvitPaymentsClient {
23
29
  purchase(sku: string): Promise<DevvitCheckoutResult>;
24
- getEntitlements(): Promise<readonly Entitlement[]>;
25
30
  }
26
31
  export interface DevvitCommerceProduct {
27
32
  readonly info: ProductInfo;
28
33
  readonly sku: string;
29
34
  }
30
- export interface CreateDevvitCommerceAdapterInput {
31
- readonly products: readonly DevvitCommerceProduct[];
32
- readonly client: DevvitPaymentsClient;
33
- readonly onCheckoutError?: (error: unknown) => void;
34
- }
35
- export declare function createDevvitCommerceAdapter(input: CreateDevvitCommerceAdapterInput): CommerceAdapter;
36
- export declare function withDevvitCommerceAdapter(gateway: PlatformGateway, commerce: CommerceAdapter): PlatformGateway;
37
35
  /** Frozen copies of the configured products; duplicate ids or SKUs and blank SKUs are rejected. */
38
36
  export declare function normalizeDevvitCommerceProducts(input: readonly DevvitCommerceProduct[]): readonly DevvitCommerceProduct[];
package/dist/payments.js CHANGED
@@ -1,58 +1,3 @@
1
- export function createDevvitCommerceAdapter(input) {
2
- const products = normalizeDevvitCommerceProducts(input.products);
3
- const productsById = new Map(products.map((product) => [product.info.id, product]));
4
- return {
5
- async getProducts() {
6
- return products.map((product) => product.info);
7
- },
8
- async purchase(request) {
9
- const product = productsById.get(request.productId);
10
- if (product === undefined) {
11
- return failedPurchase();
12
- }
13
- try {
14
- // Reddit's platform order ID is authoritative. Do not attach client operation
15
- // metadata whose identifiers can exceed Devvit's metadata constraints.
16
- const result = await input.client.purchase(product.sku);
17
- return {
18
- status: result.status,
19
- ...(result.orderId === undefined ? {} : { transactionId: result.orderId }),
20
- // Devvit server fulfillment and a subsequent entitlement read are authoritative.
21
- entitlementIds: [],
22
- ...(result.evidence === undefined ? {} : { evidence: result.evidence }),
23
- };
24
- }
25
- catch (error) {
26
- input.onCheckoutError?.(error);
27
- return failedPurchase();
28
- }
29
- },
30
- async restore() {
31
- return { restoredEntitlements: await input.client.getEntitlements() };
32
- },
33
- async getEntitlements() {
34
- return input.client.getEntitlements();
35
- },
36
- };
37
- }
38
- export function withDevvitCommerceAdapter(gateway, commerce) {
39
- if (gateway.target !== 'reddit') {
40
- throw new TypeError('Devvit commerce can only be installed on a reddit gateway.');
41
- }
42
- if ('getTargetRuntime' in gateway && typeof gateway.getTargetRuntime === 'function') {
43
- throw new TypeError('Devvit commerce must be installed before target availability is applied.');
44
- }
45
- return {
46
- ...gateway,
47
- async getCapabilities() {
48
- return {
49
- ...await gateway.getCapabilities(),
50
- nativeIap: true,
51
- };
52
- },
53
- commerce,
54
- };
55
- }
56
1
  /** Frozen copies of the configured products; duplicate ids or SKUs and blank SKUs are rejected. */
57
2
  export function normalizeDevvitCommerceProducts(input) {
58
3
  const productIds = new Set();
@@ -75,9 +20,3 @@ export function normalizeDevvitCommerceProducts(input) {
75
20
  });
76
21
  }));
77
22
  }
78
- function failedPurchase() {
79
- return Object.freeze({
80
- status: 'failed',
81
- entitlementIds: [],
82
- });
83
- }
@@ -1,4 +1,5 @@
1
1
  export * from './commerce-bridge.js';
2
+ export { createDevvitCommerceBridgeProducts, defaultDevvitCatalogTarget, type DevvitCatalogBridgeProduct, type DevvitCatalogLike, type DevvitCatalogProductLike, } from '../catalog-products.js';
2
3
  export * from './payment-order.js';
3
4
  export * from './post-operation.js';
4
5
  export * from './redis-post-operation-store.js';
@@ -1,4 +1,5 @@
1
1
  export * from './commerce-bridge.js';
2
+ export { createDevvitCommerceBridgeProducts, defaultDevvitCatalogTarget, } from '../catalog-products.js';
2
3
  export * from './payment-order.js';
3
4
  export * from './post-operation.js';
4
5
  export * from './redis-post-operation-store.js';
package/dist/web.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ShareResult } from '@mpgd/platform';
2
+ import type { DevvitCheckoutResult, DevvitPaymentsClient } from './payments.js';
2
3
  import { type DevvitSurfaceOptions, type DevvitSurfaceResult } from './surface.js';
3
4
  import { type DevvitPreviewViewModeOptions, type DevvitPreviewViewModeResult, type DevvitViewModeOptions, type DevvitViewModeResult } from './view-mode.js';
4
5
  export type { DevvitSurfaceClient, DevvitSurfaceMode, DevvitSurfaceOptions, DevvitSurfaceResult, } from './surface.js';
@@ -30,3 +31,30 @@ export declare function requestDevvitExpandedMode(event: MouseEvent, entry: stri
30
31
  * completed sharing. Devvit does not report a completion callback.
31
32
  */
32
33
  export declare function presentDevvitShareSheet(options: DevvitShareSheetOptions): Promise<ShareResult>;
34
+ /** `OrderResultEvent` of `@devvit/payments/client`, as the webview reads it. */
35
+ export interface DevvitOrderResultEventLike {
36
+ /** `OrderResultStatus`: 0 cancelled, 1 success, 2 error (or its name). */
37
+ readonly status: number | string;
38
+ readonly orderId?: string | undefined;
39
+ readonly errorCode?: number | undefined;
40
+ readonly errorMessage?: string | undefined;
41
+ }
42
+ /** The checkout client `createDevvitWebPaymentsClient` returns; the v2 provider needs nothing else. */
43
+ export type DevvitWebPaymentsClient = DevvitPaymentsClient;
44
+ /**
45
+ * Maps one Reddit checkout answer to the adapter's `DevvitCheckoutResult`:
46
+ * `STATUS_SUCCESS` with an order id is `completed` (the order id is Reddit's;
47
+ * the backend ledger decides the grant), `STATUS_SUCCESS` without one is
48
+ * `pending` so the reconciler resolves it from the paid-order list,
49
+ * `STATUS_CANCELLED` is `cancelled`, and `STATUS_ERROR` is `failed` with the
50
+ * platform code kept as `storeCode`. An unknown status is `pending`: an order
51
+ * may exist and nothing is granted or dropped on the client.
52
+ */
53
+ export declare function toDevvitCheckoutResult(event: DevvitOrderResultEventLike): DevvitCheckoutResult;
54
+ /**
55
+ * `purchase()` from `@devvit/payments/client` (re-exported by
56
+ * `@devvit/web/client`) as the checkout client of `createDevvitPlatformGateway({
57
+ * commerce: { client } })`. One SKU per checkout, no client metadata: Reddit's
58
+ * order id is the only identity the purchase carries.
59
+ */
60
+ export declare function createDevvitWebPaymentsClient(): DevvitWebPaymentsClient;
package/dist/web.js CHANGED
@@ -65,3 +65,67 @@ function isAbortError(error) {
65
65
  && error !== null
66
66
  && error.name === 'AbortError';
67
67
  }
68
+ const successStatuses = new Set([1, 'STATUS_SUCCESS', 'SUCCESS']);
69
+ const cancelledStatuses = new Set([
70
+ 0,
71
+ 'STATUS_CANCELLED',
72
+ 'CANCELLED',
73
+ ]);
74
+ const errorStatuses = new Set([2, 'STATUS_ERROR', 'ERROR']);
75
+ /**
76
+ * Maps one Reddit checkout answer to the adapter's `DevvitCheckoutResult`:
77
+ * `STATUS_SUCCESS` with an order id is `completed` (the order id is Reddit's;
78
+ * the backend ledger decides the grant), `STATUS_SUCCESS` without one is
79
+ * `pending` so the reconciler resolves it from the paid-order list,
80
+ * `STATUS_CANCELLED` is `cancelled`, and `STATUS_ERROR` is `failed` with the
81
+ * platform code kept as `storeCode`. An unknown status is `pending`: an order
82
+ * may exist and nothing is granted or dropped on the client.
83
+ */
84
+ export function toDevvitCheckoutResult(event) {
85
+ const orderId = typeof event.orderId === 'string' && event.orderId.trim().length > 0
86
+ ? event.orderId.trim()
87
+ : undefined;
88
+ if (successStatuses.has(event.status)) {
89
+ return orderId === undefined ? { status: 'pending' } : { status: 'completed', orderId };
90
+ }
91
+ if (cancelledStatuses.has(event.status)) {
92
+ return {
93
+ status: 'cancelled',
94
+ ...(orderId === undefined ? {} : { orderId }),
95
+ error: { code: 'USER_CANCELLED', storeCode: 'DEVVIT_CHECKOUT_CANCELLED', retryable: false },
96
+ };
97
+ }
98
+ if (errorStatuses.has(event.status)) {
99
+ const storeCode = event.errorCode === undefined
100
+ ? 'DEVVIT_CHECKOUT_FAILED'
101
+ : String(event.errorCode);
102
+ return {
103
+ status: 'failed',
104
+ ...(orderId === undefined ? {} : { orderId }),
105
+ error: {
106
+ code: storeCode,
107
+ storeCode,
108
+ ...(typeof event.errorMessage === 'string' && event.errorMessage.length > 0
109
+ ? { message: event.errorMessage }
110
+ : {}),
111
+ },
112
+ };
113
+ }
114
+ return { status: 'pending', ...(orderId === undefined ? {} : { orderId }) };
115
+ }
116
+ /**
117
+ * `purchase()` from `@devvit/payments/client` (re-exported by
118
+ * `@devvit/web/client`) as the checkout client of `createDevvitPlatformGateway({
119
+ * commerce: { client } })`. One SKU per checkout, no client metadata: Reddit's
120
+ * order id is the only identity the purchase carries.
121
+ */
122
+ export function createDevvitWebPaymentsClient() {
123
+ return {
124
+ async purchase(sku) {
125
+ if (typeof sku !== 'string' || sku.trim().length === 0) {
126
+ throw new TypeError('Devvit checkout requires a non-empty SKU.');
127
+ }
128
+ return toDevvitCheckoutResult(await browserClient.purchase(sku));
129
+ },
130
+ };
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/adapter-devvit",
3
- "version": "0.10.1",
3
+ "version": "0.12.0",
4
4
  "description": "Reddit Devvit Web platform adapter for mpgd games.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -55,9 +55,9 @@
55
55
  ],
56
56
  "dependencies": {
57
57
  "@devvit/web": "0.14.1",
58
- "@mpgd/bridge": "0.12.3",
59
- "@mpgd/platform": "0.22.0",
60
- "@mpgd/game-services": "0.22.0"
58
+ "@mpgd/game-services": "0.24.0",
59
+ "@mpgd/platform": "0.23.0",
60
+ "@mpgd/bridge": "0.13.0"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/node": "^24.0.0",