@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 +32 -0
- package/dist/catalog-products.d.ts +48 -0
- package/dist/catalog-products.js +78 -0
- package/dist/commerce-provider.d.ts +1 -1
- package/dist/index.d.ts +11 -8
- package/dist/index.js +30 -33
- package/dist/payments.d.ts +7 -9
- package/dist/payments.js +0 -61
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/dist/web.d.ts +28 -0
- package/dist/web.js +64 -0
- package/package.json +4 -4
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:
|
|
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
|
|
39
|
-
* `
|
|
40
|
-
* `
|
|
41
|
-
*
|
|
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
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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,
|
|
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
|
|
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 =
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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':
|
package/dist/payments.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/server/index.js
CHANGED
|
@@ -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.
|
|
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/
|
|
59
|
-
"@mpgd/platform": "0.
|
|
60
|
-
"@mpgd/
|
|
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",
|