@mpgd/adapter-devvit 0.11.0 → 0.12.1

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 |
@@ -41,9 +41,8 @@ export interface CreateDevvitCommerceProductsOptions {
41
41
  */
42
42
  export declare function createDevvitCommerceBridgeProducts(catalog: DevvitCatalogLike, target?: string): readonly DevvitCatalogBridgeProduct[];
43
43
  /**
44
- * The webview products for `createDevvitPlatformGateway({ commerce: { products } })`
45
- * and `createDevvitCommerceAdapter`, derived from the same catalog entries as
46
- * `createDevvitCommerceBridgeProducts`, so the webview and the server agree on
47
- * every SKU.
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.
48
47
  */
49
48
  export declare function createDevvitCommerceProducts(catalog: DevvitCatalogLike, options?: CreateDevvitCommerceProductsOptions): readonly DevvitCommerceProduct[];
@@ -32,10 +32,9 @@ export function createDevvitCommerceBridgeProducts(catalog, target = defaultDevv
32
32
  return Object.freeze(products);
33
33
  }
34
34
  /**
35
- * The webview products for `createDevvitPlatformGateway({ commerce: { products } })`
36
- * and `createDevvitCommerceAdapter`, derived from the same catalog entries as
37
- * `createDevvitCommerceBridgeProducts`, so the webview and the server agree on
38
- * every SKU.
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.
39
38
  */
40
39
  export function createDevvitCommerceProducts(catalog, options = {}) {
41
40
  const target = options.target ?? defaultDevvitCatalogTarget;
@@ -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
@@ -36,15 +36,17 @@ export interface DevvitPlatformGatewayOptions {
36
36
  readonly fallbackBridge?: DevvitBridge;
37
37
  readonly rpcEndpoint?: BridgeRpcEndpoint;
38
38
  /**
39
- * Installs the commerce v2 provider (`createDevvitCommerceProvider`) as
40
- * `commerceV2`, with the `commerce` capability block and `nativeIap` and
41
- * `subscriptionIap` derived from it. Absent, the gateway offers no
42
- * `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.
43
44
  *
44
- * Not yet usable in production before C2: the backend lookup verifier for
45
- * `devvit.order.v1` does not exist yet, so a verify request for a Devvit
46
- * purchase is refused today instead of answering `pending`, and no journal
47
- * 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.
48
50
  */
49
51
  readonly commerce?: DevvitGatewayCommerceOptions;
50
52
  }
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
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';
@@ -45,7 +46,7 @@ export function createDevvitPlatformGateway(input) {
45
46
  }
46
47
  return response.data;
47
48
  }
48
- const commerceV2 = input.commerce === undefined
49
+ const commerce = input.commerce === undefined
49
50
  ? undefined
50
51
  : createDevvitCommerceProvider({
51
52
  ...input.commerce,
@@ -54,14 +55,20 @@ export function createDevvitPlatformGateway(input) {
54
55
  return {
55
56
  target: 'reddit',
56
57
  async getCapabilities() {
57
- const capabilities = { ...await request('runtime.getCapabilities', {}) };
58
- if (commerceV2 === undefined) {
59
- 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;
60
63
  }
61
64
  // The webview provider owns the block: the server snapshot stays honest
62
- // about the server, and the flags derive from the block it reports.
63
- const commerce = await commerceV2.getCapabilities();
64
- 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
+ };
65
72
  },
66
73
  identity: {
67
74
  getPlayer: () => request('identity.getPlayer', {}),
@@ -72,21 +79,17 @@ export function createDevvitPlatformGateway(input) {
72
79
  getLaunchIntent: () => request('presentation.getLaunchIntent', {}),
73
80
  requestGameSurface: (payload) => request('presentation.requestGameSurface', payload),
74
81
  },
75
- 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({
76
85
  share: (payload) => request('share.share', payload),
77
86
  readInboundShare: () => request('share.readInboundShare', {}),
78
- },
87
+ }),
79
88
  notifications: {
80
89
  getStatus: (topic) => request('notifications.getStatus', { topic }),
81
90
  requestSubscription: (topic) => request('notifications.requestSubscription', { topic }),
82
91
  },
83
- commerce: {
84
- getProducts: () => request('commerce.getProducts', {}),
85
- purchase: (payload) => request('commerce.purchase', payload),
86
- restore: () => request('commerce.restore', {}),
87
- getEntitlements: () => request('commerce.getEntitlements', {}),
88
- },
89
- ...(commerceV2 === undefined ? {} : { commerceV2 }),
92
+ ...(commerce === undefined ? {} : { commerce }),
90
93
  ads: {
91
94
  preload: (payload) => request('ads.preload', payload),
92
95
  showRewarded: (payload) => request('ads.showRewarded', payload),
@@ -287,10 +290,14 @@ export function createDevvitSandboxBridge() {
287
290
  const storage = new Map();
288
291
  return {
289
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
+ }
290
298
  switch (input.method) {
291
299
  case 'runtime.getCapabilities':
292
300
  return ok(input, {
293
- nativeIap: false,
294
301
  nativeAds: false,
295
302
  rewardedAds: false,
296
303
  interstitialAds: false,
@@ -301,6 +308,7 @@ export function createDevvitSandboxBridge() {
301
308
  socialShare: false,
302
309
  haptics: false,
303
310
  localizedContent: true,
311
+ commerce: createUnsupportedCommerceCapabilities(),
304
312
  });
305
313
  case 'identity.getPlayer':
306
314
  return ok(input, {
@@ -334,18 +342,6 @@ export function createDevvitSandboxBridge() {
334
342
  return ok(input, 'approval-required');
335
343
  case 'notifications.requestSubscription':
336
344
  return ok(input, 'unavailable');
337
- case 'commerce.getProducts':
338
- case 'commerce.getEntitlements':
339
- return ok(input, []);
340
- case 'commerce.purchase':
341
- return ok(input, {
342
- status: 'cancelled',
343
- entitlementIds: [],
344
- });
345
- case 'commerce.restore':
346
- return ok(input, {
347
- restoredEntitlements: [],
348
- });
349
345
  case 'ads.preload':
350
346
  return ok(input, {});
351
347
  case 'leaderboard.open':
@@ -385,7 +381,7 @@ export function createDevvitSandboxBridge() {
385
381
  saved: key !== undefined,
386
382
  });
387
383
  }
388
- // Commerce v2 providers arrive with the B-phase PRs (B6 for Devvit).
384
+ // The sandbox sells nothing: every commerce method fails closed.
389
385
  case 'commerce.v2.getCapabilities':
390
386
  case 'commerce.v2.fetchProducts':
391
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
- }
package/dist/web.d.ts CHANGED
@@ -39,8 +39,8 @@ export interface DevvitOrderResultEventLike {
39
39
  readonly errorCode?: number | undefined;
40
40
  readonly errorMessage?: string | undefined;
41
41
  }
42
- /** The checkout half of `DevvitPaymentsClient`; the v2 provider needs nothing else. */
43
- export type DevvitWebPaymentsClient = Pick<DevvitPaymentsClient, 'purchase'>;
42
+ /** The checkout client `createDevvitWebPaymentsClient` returns; the v2 provider needs nothing else. */
43
+ export type DevvitWebPaymentsClient = DevvitPaymentsClient;
44
44
  /**
45
45
  * Maps one Reddit checkout answer to the adapter's `DevvitCheckoutResult`:
46
46
  * `STATUS_SUCCESS` with an order id is `completed` (the order id is Reddit's;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/adapter-devvit",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "description": "Reddit Devvit Web platform adapter for mpgd games.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -55,13 +55,13 @@
55
55
  ],
56
56
  "dependencies": {
57
57
  "@devvit/web": "0.14.1",
58
- "@mpgd/game-services": "0.23.0",
59
- "@mpgd/bridge": "0.12.3",
60
- "@mpgd/platform": "0.22.0"
58
+ "@mpgd/bridge": "0.13.1",
59
+ "@mpgd/platform": "0.23.1",
60
+ "@mpgd/game-services": "0.24.1"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/node": "^24.0.0",
64
- "ttsc": "0.30.4",
64
+ "ttsc": "0.32.1",
65
65
  "typescript": "7.0.2",
66
66
  "vitest": "^4.0.0"
67
67
  },