@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.
@@ -0,0 +1,85 @@
1
+ import type { PlatformEvidenceEnvelope, PlatformProviderAvailability } from '@mpgd/platform';
2
+ import { type CommerceCapabilities, type StoreId, type StorePurchase } from '@mpgd/platform/commerce';
3
+ /**
4
+ * Commerce v2 contract pieces shared by the Devvit webview provider and the
5
+ * Devvit server bridge handler: the store id, the RFC profile, the
6
+ * `devvit.order.v1` evidence envelope, and the order-to-purchase mapping.
7
+ * This module imports no Devvit SDK.
8
+ */
9
+ /** The open store id of the Devvit provider (RFC "Capabilities"). */
10
+ export declare const devvitCommerceStoreId: 'devvit';
11
+ /**
12
+ * Evidence schema of a Devvit order reported by the client (a checkout result
13
+ * or the server's paid-order list). It names the order; the backend's
14
+ * server-push lookup decides from the ledger the fulfillment handler wrote
15
+ * (`devvit.payment-order.v1`), never from this envelope alone.
16
+ */
17
+ export declare const devvitOrderEvidenceSchema: 'devvit.order.v1';
18
+ /** Bounds what one order list can turn into available purchases. */
19
+ export declare const maximumDevvitAvailableOrders = 64;
20
+ export type DevvitOrderEvidenceSource = 'checkout' | 'order-list';
21
+ export type DevvitOrderEnvironment = 'sandbox' | 'production';
22
+ /** The Reddit order status an order-list envelope reports: paid and awaiting delivery, or delivered. */
23
+ export type DevvitOrderEvidenceStatus = 'PAID' | 'DELIVERED';
24
+ export interface DevvitOrderEvidencePayload {
25
+ readonly orderId: string;
26
+ readonly sku: string;
27
+ readonly source: DevvitOrderEvidenceSource;
28
+ readonly environment?: DevvitOrderEnvironment;
29
+ readonly status?: DevvitOrderEvidenceStatus;
30
+ }
31
+ /**
32
+ * A paid Devvit order as the order list reports it: `delivered` says whether
33
+ * Reddit already acknowledged its delivery (`DELIVERED`), which it does as
34
+ * soon as the fulfillment handler answered; absent, the order is `PAID` and
35
+ * the handler has not settled it yet.
36
+ */
37
+ export interface DevvitPaidOrder {
38
+ readonly orderId: string;
39
+ readonly sku: string;
40
+ /** ISO timestamp of the payment (`Order.updatedAt`, else `createdAt`). */
41
+ readonly paidAt?: string;
42
+ readonly environment?: DevvitOrderEnvironment;
43
+ readonly delivered?: boolean;
44
+ }
45
+ /**
46
+ * The RFC profile row for `devvit`: consumable and durable one-time products,
47
+ * no subscription, no quantity, `lifecycle.finish: 'none'` (the fulfillment
48
+ * handler settles on the server; the client has nothing to finish),
49
+ * `authority.evidence: 'server-push'`, and `accountBinding: 'host'` (the
50
+ * purchasing Reddit account comes from the server request context).
51
+ * `lifecycle.pending` covers the server-side pending of a server-push store:
52
+ * verify answers `pending` until the fulfillment push wrote the ledger; Devvit
53
+ * reports no store-side pending purchase.
54
+ */
55
+ export declare function createDevvitCommerceCapabilities(availability: PlatformProviderAvailability): CommerceCapabilities;
56
+ export declare function createDevvitOrderEvidence(payload: DevvitOrderEvidencePayload): PlatformEvidenceEnvelope;
57
+ /**
58
+ * Validates a `devvit.order.v1` envelope the way a server registry entry must
59
+ * before it looks the order up: the schema, a bounded order id and SKU, a
60
+ * known source, and optional environment and status. Throws `TypeError`
61
+ * otherwise.
62
+ */
63
+ export declare function assertDevvitOrderEvidence(envelope: PlatformEvidenceEnvelope): DevvitOrderEvidencePayload;
64
+ /** A bounded, trimmed, control-character-free identifier, or nothing. */
65
+ export declare function readDevvitIdentifier(value: unknown): string | undefined;
66
+ /** An ISO timestamp for a Devvit order time, or nothing when it is absent or unparseable. */
67
+ export declare function readDevvitOrderTime(value: unknown): string | undefined;
68
+ export interface DevvitStorePurchaseInput {
69
+ readonly store: StoreId;
70
+ readonly orderId: string;
71
+ readonly sku: string;
72
+ readonly logicalId: string;
73
+ readonly source: DevvitOrderEvidenceSource;
74
+ readonly paidAt?: string | undefined;
75
+ readonly environment?: DevvitOrderEnvironment | undefined;
76
+ /** Known from the order list only; a checkout result carries no status. */
77
+ readonly delivered?: boolean | undefined;
78
+ }
79
+ /**
80
+ * A paid Devvit order as a `purchased` store purchase: the order id is both
81
+ * `transactionId` and `token` (RFC "Client contract": host order id), the
82
+ * evidence is the `devvit.order.v1` envelope, and the account binding is the
83
+ * host identity. An order the contract rejects throws `INVALID_RESPONSE`.
84
+ */
85
+ export declare function createDevvitStorePurchase(input: DevvitStorePurchaseInput): StorePurchase;
@@ -0,0 +1,145 @@
1
+ import { assertStorePurchase, CommerceError, } from '@mpgd/platform/commerce';
2
+ /**
3
+ * Commerce v2 contract pieces shared by the Devvit webview provider and the
4
+ * Devvit server bridge handler: the store id, the RFC profile, the
5
+ * `devvit.order.v1` evidence envelope, and the order-to-purchase mapping.
6
+ * This module imports no Devvit SDK.
7
+ */
8
+ /** The open store id of the Devvit provider (RFC "Capabilities"). */
9
+ export const devvitCommerceStoreId = 'devvit';
10
+ /**
11
+ * Evidence schema of a Devvit order reported by the client (a checkout result
12
+ * or the server's paid-order list). It names the order; the backend's
13
+ * server-push lookup decides from the ledger the fulfillment handler wrote
14
+ * (`devvit.payment-order.v1`), never from this envelope alone.
15
+ */
16
+ export const devvitOrderEvidenceSchema = 'devvit.order.v1';
17
+ /** Bounds what one order list can turn into available purchases. */
18
+ export const maximumDevvitAvailableOrders = 64;
19
+ /**
20
+ * The RFC profile row for `devvit`: consumable and durable one-time products,
21
+ * no subscription, no quantity, `lifecycle.finish: 'none'` (the fulfillment
22
+ * handler settles on the server; the client has nothing to finish),
23
+ * `authority.evidence: 'server-push'`, and `accountBinding: 'host'` (the
24
+ * purchasing Reddit account comes from the server request context).
25
+ * `lifecycle.pending` covers the server-side pending of a server-push store:
26
+ * verify answers `pending` until the fulfillment push wrote the ledger; Devvit
27
+ * reports no store-side pending purchase.
28
+ */
29
+ export function createDevvitCommerceCapabilities(availability) {
30
+ return {
31
+ purchase: { consumable: true, durable: true, subscription: false, quantity: false },
32
+ catalog: { products: true, offers: false, localizedPrice: false },
33
+ lifecycle: {
34
+ availablePurchases: true,
35
+ pending: true,
36
+ restore: true,
37
+ events: true,
38
+ finish: 'none',
39
+ },
40
+ authority: { evidence: 'server-push', accountBinding: 'host' },
41
+ availability,
42
+ };
43
+ }
44
+ export function createDevvitOrderEvidence(payload) {
45
+ return {
46
+ schema: devvitOrderEvidenceSchema,
47
+ payload: {
48
+ orderId: payload.orderId,
49
+ sku: payload.sku,
50
+ source: payload.source,
51
+ ...(payload.environment === undefined ? {} : { environment: payload.environment }),
52
+ ...(payload.status === undefined ? {} : { status: payload.status }),
53
+ },
54
+ };
55
+ }
56
+ /**
57
+ * Validates a `devvit.order.v1` envelope the way a server registry entry must
58
+ * before it looks the order up: the schema, a bounded order id and SKU, a
59
+ * known source, and optional environment and status. Throws `TypeError`
60
+ * otherwise.
61
+ */
62
+ export function assertDevvitOrderEvidence(envelope) {
63
+ if (envelope.schema !== devvitOrderEvidenceSchema) {
64
+ throw new TypeError(`Devvit order evidence schema must be ${devvitOrderEvidenceSchema}.`);
65
+ }
66
+ const { payload } = envelope;
67
+ const orderId = readDevvitIdentifier(payload.orderId);
68
+ const sku = readDevvitIdentifier(payload.sku);
69
+ if (orderId === undefined) {
70
+ throw new TypeError('Devvit order evidence must carry an orderId.');
71
+ }
72
+ if (sku === undefined) {
73
+ throw new TypeError('Devvit order evidence must carry a sku.');
74
+ }
75
+ const { source, environment, status } = payload;
76
+ if (source !== 'checkout' && source !== 'order-list') {
77
+ throw new TypeError('Devvit order evidence source must be checkout or order-list.');
78
+ }
79
+ if (environment !== undefined && environment !== 'sandbox' && environment !== 'production') {
80
+ throw new TypeError('Devvit order evidence environment must be sandbox or production.');
81
+ }
82
+ if (status !== undefined && status !== 'PAID' && status !== 'DELIVERED') {
83
+ throw new TypeError('Devvit order evidence status must be PAID or DELIVERED.');
84
+ }
85
+ return {
86
+ orderId,
87
+ sku,
88
+ source,
89
+ ...(environment === undefined ? {} : { environment }),
90
+ ...(status === undefined ? {} : { status }),
91
+ };
92
+ }
93
+ /** A bounded, trimmed, control-character-free identifier, or nothing. */
94
+ export function readDevvitIdentifier(value) {
95
+ if (typeof value !== 'string'
96
+ || value.length === 0
97
+ || value.length > 256
98
+ || value.trim() !== value
99
+ || /[\p{Cc}\p{Cf}]/u.test(value)) {
100
+ return undefined;
101
+ }
102
+ return value;
103
+ }
104
+ /** An ISO timestamp for a Devvit order time, or nothing when it is absent or unparseable. */
105
+ export function readDevvitOrderTime(value) {
106
+ if (typeof value !== 'string' || value.length === 0 || value.length > 64) {
107
+ return undefined;
108
+ }
109
+ const parsed = Date.parse(value);
110
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : undefined;
111
+ }
112
+ /**
113
+ * A paid Devvit order as a `purchased` store purchase: the order id is both
114
+ * `transactionId` and `token` (RFC "Client contract": host order id), the
115
+ * evidence is the `devvit.order.v1` envelope, and the account binding is the
116
+ * host identity. An order the contract rejects throws `INVALID_RESPONSE`.
117
+ */
118
+ export function createDevvitStorePurchase(input) {
119
+ try {
120
+ return assertStorePurchase({
121
+ store: input.store,
122
+ transactionId: input.orderId,
123
+ storeSku: input.sku,
124
+ logicalId: input.logicalId,
125
+ quantity: 1,
126
+ state: 'purchased',
127
+ ...(input.paidAt === undefined ? {} : { purchasedAt: input.paidAt }),
128
+ token: input.orderId,
129
+ evidence: createDevvitOrderEvidence({
130
+ orderId: input.orderId,
131
+ sku: input.sku,
132
+ source: input.source,
133
+ ...(input.environment === undefined ? {} : { environment: input.environment }),
134
+ ...(input.delivered === undefined ? {} : { status: input.delivered ? 'DELIVERED' : 'PAID' }),
135
+ }),
136
+ accountBinding: { kind: 'host-identity' },
137
+ });
138
+ }
139
+ catch (error) {
140
+ throw new CommerceError('INVALID_RESPONSE', {
141
+ message: 'Devvit reported an invalid order.',
142
+ cause: error,
143
+ });
144
+ }
145
+ }
@@ -0,0 +1,93 @@
1
+ import type { PlatformProviderAvailability } from '@mpgd/platform';
2
+ import { type CommerceProvider, type StoreId } from '@mpgd/platform/commerce';
3
+ import { type DevvitPaidOrder } from './commerce-contract.js';
4
+ import { type DevvitCommerceProduct, type DevvitPaymentsClient } from './payments.js';
5
+ /**
6
+ * Refreshes an order reported ahead of the list (by a checkout result or a
7
+ * signal) survives without a listing showing it; it is forgotten on the next
8
+ * one, so a re-listed order is reported again.
9
+ */
10
+ export declare const unlistedOrderGraceRefreshes = 7;
11
+ /** Store codes the provider attaches to failures of its own. */
12
+ export declare const devvitCommerceStoreCodes: Readonly<{
13
+ readonly productNotConfigured: 'DEVVIT_PRODUCT_NOT_CONFIGURED';
14
+ readonly checkoutFailed: 'DEVVIT_CHECKOUT_FAILED';
15
+ readonly checkoutCancelled: 'DEVVIT_CHECKOUT_CANCELLED';
16
+ readonly ordersUnavailable: 'DEVVIT_ORDERS_UNAVAILABLE';
17
+ }>;
18
+ /**
19
+ * The server-push lookup of the Devvit provider: the user's recent paid
20
+ * orders, each marked `delivered` once Reddit acknowledged its delivery
21
+ * (which happens as soon as the fulfillment handler answered). The order
22
+ * list is server-only in Devvit (`payments.getOrders`), so the webview reads
23
+ * it through the server bridge (`commerce.v2.getAvailablePurchases`); the
24
+ * conformance harness reads it from its fake payments plugin.
25
+ */
26
+ export interface DevvitOrderSource {
27
+ listPaidOrders(): Promise<readonly DevvitPaidOrder[]>;
28
+ /** Whether the server side serves commerce v2; defaults to `available`. */
29
+ getAvailability?(): Promise<PlatformProviderAvailability>;
30
+ }
31
+ export interface DevvitOrdersChange {
32
+ /**
33
+ * Paid orders the signaller already knows, for example the order a
34
+ * realtime message named. The provider reports them at once and still
35
+ * re-reads the order source; an order for an unconfigured SKU is ignored.
36
+ */
37
+ readonly orders?: readonly DevvitPaidOrder[];
38
+ }
39
+ export type DevvitOrdersChangeListener = (change?: DevvitOrdersChange) => void;
40
+ export interface CreateDevvitCommerceProviderInput {
41
+ readonly products: readonly DevvitCommerceProduct[];
42
+ /** Opens the Reddit checkout (`purchase()` from `@devvit/payments/client`). */
43
+ readonly client: Pick<DevvitPaymentsClient, 'purchase'>;
44
+ readonly orders: DevvitOrderSource;
45
+ /** Defaults to `devvit`; conformance vectors name their fixture store. */
46
+ readonly store?: StoreId;
47
+ /**
48
+ * Signals that the paid-order list may have changed, for example because the
49
+ * webview became visible again. The provider reports the orders the signal
50
+ * carries at once, then re-reads the list and emits `purchaseUpdated` for
51
+ * paid orders it has not reported yet. Observed only while a listener
52
+ * exists. Absent, orders are read on demand only.
53
+ */
54
+ readonly onOrdersChanged?: (listener: DevvitOrdersChangeListener) => () => void;
55
+ readonly onCheckoutError?: (error: unknown) => void;
56
+ }
57
+ export interface DevvitCommerceProvider extends CommerceProvider {
58
+ restorePurchases(): Promise<void>;
59
+ /** Stops observing the order signal; the provider answers no further events. */
60
+ dispose(): void;
61
+ }
62
+ /**
63
+ * Commerce v2 provider for Devvit (RFC profile `devvit`, see
64
+ * `createDevvitCommerceCapabilities`). Reddit's fulfillment handler settles a
65
+ * purchase on the server: it writes the backend ledger, and the client learns
66
+ * of the grant through the backend's server-push lookup verifier, which
67
+ * answers `pending` until that entry exists. The provider therefore never
68
+ * grants and has nothing to finish.
69
+ *
70
+ * - `requestPurchase` opens the Reddit checkout. A checkout that already
71
+ * carries the created order id answers `purchased` with the order id as
72
+ * `transactionId` and `token` and `devvit.order.v1` evidence; the backend
73
+ * verify is what grants it. A checkout without an order id, or one that
74
+ * ended `pending`, is `launched`: an order may exist, and the reconciler
75
+ * resolves it from `getAvailablePurchases`. A dismissed checkout is
76
+ * `cancelled`; a failed one is `failed` with the platform code as
77
+ * `storeCode` (`OUTCOME_UNKNOWN` and `ALREADY_OWNED` classes stay open as
78
+ * `launched`, as the RFC requires, and so does a `purchase()` call that
79
+ * rejects, since a missing response does not prove a missing order).
80
+ * - `getAvailablePurchases` lists the paid orders whose delivery the server
81
+ * has not acknowledged (the order source without its delivered entries),
82
+ * bounded by `maximumDevvitAvailableOrders`; `includePending` adds nothing
83
+ * because Devvit has no store-side pending order.
84
+ * - `restorePurchases` re-reads the order source and emits `purchaseUpdated`
85
+ * once per order not reported before, delivered orders included: Reddit
86
+ * marks an order delivered as soon as the fulfillment handler answered, so
87
+ * a session that lost its checkout result finds the order this way and the
88
+ * backend lookup answers `granted` for it. The first listener triggers the
89
+ * same read. Events before the first `subscribe` are buffered.
90
+ * - The provider emits no `purchaseError`: every checkout outcome is a
91
+ * synchronous result of the purchase call.
92
+ */
93
+ export declare function createDevvitCommerceProvider(input: CreateDevvitCommerceProviderInput): DevvitCommerceProvider;