@mpgd/adapter-devvit 0.9.20 → 0.10.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/dist/index.js CHANGED
@@ -1,7 +1,15 @@
1
- import { bridgeStorageLoadProtocol, createBridgeError, createCommerceV2UnavailableBridgeError, decodeBridgeStorageLoadData, } from '@mpgd/bridge';
1
+ import { bridgeStorageLoadProtocol, commerceV2UnavailableBridgeErrorCode, createBridgeError, createCommerceV2UnavailableBridgeError, decodeBridgeStorageLoadData, } 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';
5
+ import { assertDevvitOrderEvidence } from './commerce-contract.js';
6
+ import { createDevvitCommerceProvider, } from './commerce-provider.js';
7
+ export * from './commerce-contract.js';
8
+ export * from './commerce-provider.js';
3
9
  export * from './payments.js';
4
10
  export const defaultDevvitRpcEndpoint = defaultBridgeRpcEndpoint;
11
+ /** Bridge error of `createDevvitOrpcBridge` when the oRPC request itself failed. */
12
+ export const devvitBridgeNetworkErrorCode = 'DEVVIT_BRIDGE_NETWORK_ERROR';
5
13
  export class DevvitBridgeError extends Error {
6
14
  code;
7
15
  requestId;
@@ -36,10 +44,23 @@ export function createDevvitPlatformGateway(input) {
36
44
  }
37
45
  return response.data;
38
46
  }
47
+ const commerceV2 = input.commerce === undefined
48
+ ? undefined
49
+ : createDevvitCommerceProvider({
50
+ ...input.commerce,
51
+ orders: createDevvitBridgeOrderSource(request),
52
+ });
39
53
  return {
40
54
  target: 'reddit',
41
55
  async getCapabilities() {
42
- return { ...await request('runtime.getCapabilities', {}) };
56
+ const capabilities = { ...await request('runtime.getCapabilities', {}) };
57
+ if (commerceV2 === undefined) {
58
+ return capabilities;
59
+ }
60
+ // 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 };
43
64
  },
44
65
  identity: {
45
66
  getPlayer: () => request('identity.getPlayer', {}),
@@ -64,6 +85,7 @@ export function createDevvitPlatformGateway(input) {
64
85
  restore: () => request('commerce.restore', {}),
65
86
  getEntitlements: () => request('commerce.getEntitlements', {}),
66
87
  },
88
+ ...(commerceV2 === undefined ? {} : { commerceV2 }),
67
89
  ads: {
68
90
  preload: (payload) => request('ads.preload', payload),
69
91
  showRewarded: (payload) => request('ads.showRewarded', payload),
@@ -94,6 +116,135 @@ export function createDevvitPlatformGateway(input) {
94
116
  },
95
117
  };
96
118
  }
119
+ /**
120
+ * The webview provider's order source over the server bridge: the paid orders
121
+ * the server lists as available purchases, mapped back to orders (the
122
+ * provider rebuilds the purchases under its own store id), and the
123
+ * availability the server reports. `COMMERCE_V2_UNAVAILABLE` from a bridge
124
+ * without the commerce handler is `configuration-required`; another bridge
125
+ * failure that is retryable (or the transport failing) keeps the availability
126
+ * the server last answered for the capability read (`temporarily-unavailable`
127
+ * before any answer; a non-retryable failure or an invalid answer fails
128
+ * closed) and is
129
+ * a `CommerceError` for a list read (`NETWORK` for a transport failure,
130
+ * otherwise `UNKNOWN` with the bridge code as `storeCode`).
131
+ */
132
+ export function createDevvitBridgeOrderSource(request) {
133
+ /** The availability the server last answered; kept across transient read failures. */
134
+ let lastAvailability;
135
+ return {
136
+ async listPaidOrders() {
137
+ let data;
138
+ try {
139
+ data = await request('commerce.v2.getAvailablePurchases', {});
140
+ }
141
+ catch (error) {
142
+ throw toCommerceError(error);
143
+ }
144
+ let purchases;
145
+ try {
146
+ purchases = assertCommerceBridgeGetAvailablePurchasesResult(data).purchases;
147
+ }
148
+ catch (error) {
149
+ throw new CommerceError('INVALID_RESPONSE', {
150
+ message: 'Devvit server bridge returned invalid data for commerce.v2.getAvailablePurchases.',
151
+ cause: error,
152
+ });
153
+ }
154
+ const orders = [];
155
+ for (const purchase of purchases) {
156
+ let environment;
157
+ let delivered;
158
+ try {
159
+ const evidence = assertDevvitOrderEvidence(purchase.evidence);
160
+ environment = evidence.environment;
161
+ delivered = evidence.status === undefined ? undefined : evidence.status === 'DELIVERED';
162
+ }
163
+ catch {
164
+ // The server listed an order under another evidence schema; the
165
+ // order itself is still the server's word.
166
+ environment = undefined;
167
+ }
168
+ orders.push({
169
+ orderId: purchase.transactionId,
170
+ sku: purchase.storeSku,
171
+ ...(purchase.purchasedAt === undefined ? {} : { paidAt: purchase.purchasedAt }),
172
+ ...(environment === undefined ? {} : { environment }),
173
+ ...(delivered === undefined ? {} : { delivered }),
174
+ });
175
+ }
176
+ return orders;
177
+ },
178
+ async getAvailability() {
179
+ let data;
180
+ try {
181
+ data = await request('commerce.v2.getCapabilities', { protocol: commerceProtocol });
182
+ }
183
+ catch (error) {
184
+ if (error instanceof DevvitBridgeError
185
+ && error.code === commerceV2UnavailableBridgeErrorCode) {
186
+ // The server does not serve commerce v2: fail closed, whatever it answered before.
187
+ lastAvailability = undefined;
188
+ return 'configuration-required';
189
+ }
190
+ if (isTransientBridgeFailure(error)) {
191
+ // A retryable failure keeps the last answer the server gave, so one
192
+ // timed-out read does not hide the shop; the first read fails closed.
193
+ return lastAvailability ?? 'temporarily-unavailable';
194
+ }
195
+ // Anything else is not transient: fail closed and forget the old answer.
196
+ lastAvailability = undefined;
197
+ return 'temporarily-unavailable';
198
+ }
199
+ try {
200
+ lastAvailability = assertCommerceBridgeGetCapabilitiesResult(data).capabilities.availability;
201
+ return lastAvailability;
202
+ }
203
+ catch {
204
+ // The server answered something the contract rejects: fail closed.
205
+ lastAvailability = undefined;
206
+ return 'temporarily-unavailable';
207
+ }
208
+ },
209
+ };
210
+ }
211
+ /**
212
+ * A bridge failure as a commerce error: a closed server
213
+ * (`COMMERCE_V2_UNAVAILABLE`) is `CONFIGURATION_REQUIRED`, a transport failure
214
+ * (`DEVVIT_BRIDGE_NETWORK_ERROR`) is `NETWORK`, a v2 code is kept, and any
215
+ * other bridge code is `UNKNOWN` with the code as `storeCode`.
216
+ */
217
+ /** A retryable bridge error, or the oRPC transport failing: the server may answer again shortly. */
218
+ function isTransientBridgeFailure(error) {
219
+ if (error instanceof DevvitBridgeError) {
220
+ return error.retryable || error.code === devvitBridgeNetworkErrorCode;
221
+ }
222
+ return error instanceof PlatformOperationError && error.retryable;
223
+ }
224
+ function toCommerceError(error) {
225
+ if (error instanceof CommerceError) {
226
+ return error;
227
+ }
228
+ if (error instanceof DevvitBridgeError || error instanceof PlatformOperationError) {
229
+ const code = error.code === commerceV2UnavailableBridgeErrorCode
230
+ ? 'CONFIGURATION_REQUIRED'
231
+ : error.code === devvitBridgeNetworkErrorCode
232
+ ? 'NETWORK'
233
+ : isCommerceErrorCode(error.code)
234
+ ? error.code
235
+ : 'UNKNOWN';
236
+ return new CommerceError(code, {
237
+ message: error.message,
238
+ retryable: code === 'NETWORK' ? true : error.retryable,
239
+ ...(code === error.code ? {} : { storeCode: error.code }),
240
+ cause: error,
241
+ });
242
+ }
243
+ return new CommerceError('UNKNOWN', {
244
+ message: `Devvit server bridge request failed: ${errorMessage(error)}`,
245
+ cause: error,
246
+ });
247
+ }
97
248
  export function createDevvitOrpcBridge(input = {}) {
98
249
  const client = input.client ?? createBridgeOrpcClient({
99
250
  url: input.endpoint ?? defaultDevvitRpcEndpoint,
@@ -105,7 +256,7 @@ export function createDevvitOrpcBridge(input = {}) {
105
256
  return await client.request(bridgeRequest);
106
257
  }
107
258
  catch (error) {
108
- return createBridgeError(bridgeRequest.id, 'DEVVIT_BRIDGE_NETWORK_ERROR', `Devvit oRPC bridge request failed: ${errorMessage(error)}`, true);
259
+ return createBridgeError(bridgeRequest.id, devvitBridgeNetworkErrorCode, `Devvit oRPC bridge request failed: ${errorMessage(error)}`, true);
109
260
  }
110
261
  },
111
262
  };
@@ -1,8 +1,23 @@
1
1
  import type { CommerceAdapter, Entitlement, PlatformEvidenceEnvelope, PlatformGateway, ProductInfo, PurchaseResult } from '@mpgd/platform';
2
+ /**
3
+ * Why a Devvit checkout ended without an order (`OrderResultEvent` with
4
+ * `STATUS_ERROR` or `STATUS_CANCELLED`). `code` is a commerce v2 error code
5
+ * when the client could classify the failure, otherwise the platform's own
6
+ * code (`String(errorCode)`), which the v2 provider keeps as `storeCode`.
7
+ */
8
+ export interface DevvitCheckoutFailure {
9
+ readonly code: string;
10
+ readonly storeCode?: string;
11
+ readonly message?: string;
12
+ readonly retryable?: boolean;
13
+ }
2
14
  export interface DevvitCheckoutResult {
3
15
  readonly status: PurchaseResult['status'];
16
+ /** `OrderResultEvent.orderId`: present once Reddit created the order. */
4
17
  readonly orderId?: string;
5
18
  readonly evidence?: PlatformEvidenceEnvelope;
19
+ /** Read by the commerce v2 provider for a `cancelled` or `failed` checkout. */
20
+ readonly error?: DevvitCheckoutFailure;
6
21
  }
7
22
  export interface DevvitPaymentsClient {
8
23
  purchase(sku: string): Promise<DevvitCheckoutResult>;
@@ -19,3 +34,5 @@ export interface CreateDevvitCommerceAdapterInput {
19
34
  }
20
35
  export declare function createDevvitCommerceAdapter(input: CreateDevvitCommerceAdapterInput): CommerceAdapter;
21
36
  export declare function withDevvitCommerceAdapter(gateway: PlatformGateway, commerce: CommerceAdapter): PlatformGateway;
37
+ /** Frozen copies of the configured products; duplicate ids or SKUs and blank SKUs are rejected. */
38
+ export declare function normalizeDevvitCommerceProducts(input: readonly DevvitCommerceProduct[]): readonly DevvitCommerceProduct[];
package/dist/payments.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export function createDevvitCommerceAdapter(input) {
2
- const products = normalizeProducts(input.products);
2
+ const products = normalizeDevvitCommerceProducts(input.products);
3
3
  const productsById = new Map(products.map((product) => [product.info.id, product]));
4
4
  return {
5
5
  async getProducts() {
@@ -53,7 +53,8 @@ export function withDevvitCommerceAdapter(gateway, commerce) {
53
53
  commerce,
54
54
  };
55
55
  }
56
- function normalizeProducts(input) {
56
+ /** Frozen copies of the configured products; duplicate ids or SKUs and blank SKUs are rejected. */
57
+ export function normalizeDevvitCommerceProducts(input) {
57
58
  const productIds = new Set();
58
59
  const skus = new Set();
59
60
  return Object.freeze(input.map((product) => {
@@ -0,0 +1,111 @@
1
+ import { type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
2
+ import { type StoreId } from '@mpgd/platform/commerce';
3
+ /**
4
+ * Host side of commerce v2 for Devvit: the server bridge serves the six
5
+ * `commerce.v2.*` methods from the Devvit `payments` plugin
6
+ * (`@devvit/payments/server`, re-exported by `@devvit/web/server`). The
7
+ * plugin runs in the request context of the authenticated Reddit user:
8
+ * Reddit documents server-side `payments.getOrders()` as the current user's
9
+ * orders, newest first (Devvit docs, "Manage Payments", "Check orders"),
10
+ * which the fulfillment handler's own user binding backs. The checkout is a webview
11
+ * effect, so `requestPurchase` is answered by the client provider, never
12
+ * here. This module imports no Devvit SDK; the plugin surface it needs is
13
+ * the structural `DevvitServerPaymentsLike`.
14
+ */
15
+ /** Bridge error for a commerce v2 payload the typia assert rejected. */
16
+ export declare const devvitCommerceV2InvalidPayloadBridgeErrorCode: 'COMMERCE_V2_INVALID_PAYLOAD';
17
+ /** Bridge error for `finishTransaction`: the Devvit profile has nothing to finish. */
18
+ export declare const devvitCommerceV2MethodUnsupportedBridgeErrorCode: 'COMMERCE_V2_METHOD_UNSUPPORTED';
19
+ /** Bridge error for `requestPurchase` reaching the server: the checkout opens in the webview. */
20
+ export declare const devvitCommerceCheckoutClientOnlyBridgeErrorCode: 'DEVVIT_COMMERCE_CHECKOUT_CLIENT_ONLY';
21
+ /** Bridge error when the payments plugin fails; retryable. */
22
+ export declare const devvitPaymentsUnavailableBridgeErrorCode: 'DEVVIT_PAYMENTS_UNAVAILABLE';
23
+ /** `Order.status` as `@devvit/protos` encodes it (`OrderStatus` enum) or as the handler types spell it. */
24
+ export type DevvitPaymentsOrderStatusLike = number | string;
25
+ /** The fields of a `@devvit/payments` `Order` the handler reads. */
26
+ export interface DevvitPaymentsOrderLike {
27
+ readonly id: string;
28
+ readonly status: DevvitPaymentsOrderStatusLike;
29
+ readonly createdAt?: string | null | undefined;
30
+ readonly updatedAt?: string | null | undefined;
31
+ readonly products: readonly {
32
+ readonly sku: string;
33
+ }[];
34
+ readonly environment?: number | string | undefined;
35
+ }
36
+ /** The fields of a `@devvit/payments` `Product` the handler reads. */
37
+ export interface DevvitPaymentsProductLike {
38
+ readonly sku: string;
39
+ readonly name: string;
40
+ readonly description: string;
41
+ readonly price?: {
42
+ readonly amount: number;
43
+ readonly currency: number | string;
44
+ } | undefined;
45
+ readonly accountingType: number | string;
46
+ }
47
+ /** The subset of the Devvit `payments` plugin the handler calls (`PaymentsClient` fits). */
48
+ export interface DevvitServerPaymentsLike {
49
+ getOrders(filters: {
50
+ readonly limit: number;
51
+ readonly cursor?: string;
52
+ /** `OrderStatus` hint; `@devvit/payments` 0.14 ignores it, so the handler filters itself. */
53
+ readonly status?: number;
54
+ }): Promise<{
55
+ readonly orders: readonly DevvitPaymentsOrderLike[];
56
+ readonly pageInfo?: {
57
+ readonly hasNextPage: boolean;
58
+ readonly endCursor: string;
59
+ } | undefined;
60
+ }>;
61
+ getProducts(filters?: {
62
+ readonly skus?: readonly string[];
63
+ }): Promise<{
64
+ readonly products: readonly DevvitPaymentsProductLike[];
65
+ }>;
66
+ }
67
+ export interface DevvitCommerceBridgeProduct {
68
+ readonly productId: string;
69
+ readonly sku: string;
70
+ }
71
+ export interface CreateDevvitCommerceV2BridgeHandlerInput {
72
+ readonly payments: DevvitServerPaymentsLike;
73
+ /** The game's logical products and their Devvit SKUs; orders for other SKUs are not listed. */
74
+ readonly products: readonly DevvitCommerceBridgeProduct[];
75
+ /** Defaults to `devvit`. */
76
+ readonly store?: StoreId;
77
+ readonly warn?: ((message: string) => void) | undefined;
78
+ }
79
+ export interface DevvitCommerceV2BridgeHandler {
80
+ /** Answers one of the six `commerce.v2.*` methods; other methods are rejected. */
81
+ request(input: BridgeRequest): Promise<BridgeResponse>;
82
+ }
83
+ /**
84
+ * Serves `commerce.v2.*` from the payments plugin:
85
+ *
86
+ * - `getCapabilities` answers the Devvit profile (`createDevvitCommerceCapabilities`).
87
+ * - `fetchProducts` lists the configured SKUs from `payments.getProducts`
88
+ * (instant and consumable goods as `consumable`, durable goods as
89
+ * `durable`; time-limited goods are not listed) priced in Reddit Gold.
90
+ * - `getAvailablePurchases` lists the authenticated user's `PAID` and
91
+ * `DELIVERED` orders for configured SKUs from `payments.getOrders` as
92
+ * `purchased` (the order id as `transactionId` and `token`,
93
+ * `devvit.order.v1` evidence whose `status` says which), bounded by
94
+ * `maximumDevvitAvailableOrders`. Reddit marks an order `DELIVERED` as soon
95
+ * as the fulfillment handler answered, so the delivered orders are the ones
96
+ * whose ledger entry exists; the webview provider lists only the `PAID`
97
+ * ones as available and reports both as events, so a session that lost its
98
+ * checkout result still finds the order. The scan reads pages of 64
99
+ * (newest first, as the Devvit docs describe) and stops after two
100
+ * consecutive pages without a paid or delivered order or after
101
+ * `maximumOrderPages`, warning when pages are left.
102
+ * - `requestPurchase` is refused with `DEVVIT_COMMERCE_CHECKOUT_CLIENT_ONLY`,
103
+ * `finishTransaction` with `COMMERCE_V2_METHOD_UNSUPPORTED`, and
104
+ * `restorePurchases` answers `{ restored: false }`: the webview provider
105
+ * re-reads the list itself.
106
+ *
107
+ * Payloads and results are validated with the `@mpgd/platform/commerce`
108
+ * asserts; a plugin failure crosses the bridge as the retryable
109
+ * `DEVVIT_PAYMENTS_UNAVAILABLE`.
110
+ */
111
+ export declare function createDevvitCommerceV2BridgeHandler(input: CreateDevvitCommerceV2BridgeHandlerInput): DevvitCommerceV2BridgeHandler;