@m2c/checkout 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 M2C
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,298 @@
1
+ # @m2c/checkout
2
+
3
+ Headless browser checkout SDK for [M2C](https://m2cmarkets.com). It runs an M2C
4
+ auction, redirects the customer to the winning vendor's hosted checkout, and
5
+ reflects the conversion status back to your UI when they return.
6
+
7
+ It is **headless**: no components, no styles. You render every pixel (including
8
+ the trigger button); the SDK gives you state, events, and a typed terminal
9
+ result.
10
+
11
+ It holds **no secrets and does no cryptography**. The signed, retried merchant
12
+ webhook to your backend remains the source of truth for fulfillment - a return
13
+ to `success_url` proves the customer came back, not that they paid. Treat this
14
+ SDK's status as advisory UX.
15
+
16
+ ```
17
+ npm install @m2c/checkout
18
+ ```
19
+
20
+ ESM, zero runtime dependencies, ships `.d.ts` types. Targets evergreen browsers
21
+ (ES2020). Importing the module is SSR-safe; the lifecycle methods need a DOM.
22
+
23
+ ## Two modes
24
+
25
+ ### Backend-initiated (recommended)
26
+
27
+ Your backend creates the checkout with its secret key (the existing
28
+ `POST /api/v1/auction` call **is** the create step) and forwards
29
+ `checkout_url`, `request_id`, and `ttl` to the browser. No key ships in the
30
+ client.
31
+
32
+ ```ts
33
+ import { createClient } from '@m2c/checkout';
34
+
35
+ const client = createClient({
36
+ baseUrl: 'https://api.m2cmarkets.com',
37
+ // Your backend already receives the M2C webhook, so it is the authoritative
38
+ // status source. Point the SDK at your own endpoint.
39
+ statusSource: { kind: 'url', template: 'https://shop.example/checkout-status?ref={request_id}' },
40
+ });
41
+
42
+ // session = { checkoutUrl, requestId, ttl } from your backend
43
+ await client.startFromSession(session);
44
+ ```
45
+
46
+ ### Client-initiated (no-backend shortcut)
47
+
48
+ The browser calls the auction directly with a **publishable** key. For merchants
49
+ without a backend. Reliable only for low-stakes, instant, in-session digital
50
+ delivery: a customer who never returns is never confirmed client-side.
51
+
52
+ Live publishable keys require HTTPS origins. Test publishable keys
53
+ (`pub_test_...`) may also allow loopback HTTP origins such as
54
+ `http://127.0.0.1:5173` for local sandbox testing.
55
+
56
+ ```ts
57
+ const client = createClient({
58
+ baseUrl: 'https://api.m2cmarkets.com',
59
+ publishableKey: 'pub_live_...',
60
+ // default statusSource is { kind: 'm2c' } - poll the M2C advisory read endpoint
61
+ });
62
+
63
+ await client.start({
64
+ transactionValue: 49.99, // major units, e.g. dollars
65
+ currency: 'USD',
66
+ description: 'Pro plan',
67
+ successUrl: 'https://shop.example/checkout/return',
68
+ cancelUrl: 'https://shop.example/checkout/canceled',
69
+ });
70
+ ```
71
+
72
+ ## The redirect / resume model
73
+
74
+ The default launch is a **same-tab full-page redirect** (`location.assign`), for
75
+ maximum compatibility with vendor 3-D Secure / bank redirect flows that break
76
+ inside popups and iframes.
77
+
78
+ Because the redirect tears down the page, `start` / `startFromSession` **navigate
79
+ away and do not resolve in place**. You get the outcome on your return page by
80
+ calling `resume()`:
81
+
82
+ ```ts
83
+ // On your success_url and cancel_url pages:
84
+ const client = createClient({ baseUrl: 'https://api.m2cmarkets.com', publishableKey: 'pub_live_...' });
85
+
86
+ const result = await client.resume({ outcome: 'success' }); // or 'cancel'
87
+ // result is null if no checkout was in progress, otherwise one of:
88
+ // { status: 'completed', requestId }
89
+ // { status: 'failed', requestId }
90
+ // { status: 'canceled', requestId }
91
+ // { status: 'pending_timeout', requestId } <- poll window elapsed; ask your webhook
92
+ ```
93
+
94
+ The SDK cannot tell `success_url` from `cancel_url` on its own, so pass
95
+ `outcome`. A `cancel` return resolves `canceled` immediately; a `success` return
96
+ polls the status source to a terminal result.
97
+
98
+ `start*` writes a small resume record to `sessionStorage` (namespaced by
99
+ `storageKey`, default `m2c.checkout`) before navigating. It is read and cleared
100
+ on the return page, so a refresh does not re-poll a stale checkout.
101
+
102
+ ### Popup / new-tab launch
103
+
104
+ For apps that must keep the original page alive, such as web games, opt into a
105
+ separate checkout window:
106
+
107
+ ```ts
108
+ const client = createClient({
109
+ baseUrl: 'https://api.m2cmarkets.com',
110
+ publishableKey: 'pub_live_...',
111
+ launchMode: 'popup', // or 'new_tab'
112
+ returnTimeoutMs: 60000, // optional fallback for abandoned popup/new-tab flows
113
+ });
114
+ ```
115
+
116
+ Call `start()` directly from a user click/tap. The SDK pre-opens a blank window
117
+ before the auction request, clears `opener`, then navigates that window after
118
+ the checkout URL is known. If the browser blocks the window, `start()` rejects
119
+ with `InvalidRequest` before creating the auction.
120
+
121
+ The default storage for popup/new-tab launch writes the resume record to
122
+ `localStorage` as well as `sessionStorage`, so the return page in the checkout
123
+ window can call `resume()`. If you inject custom `storage`, make sure it is
124
+ visible to both the opener page and the return page. Use the same client
125
+ factory on the opener and return page, or at least pass the same `launchMode`
126
+ and storage configuration:
127
+
128
+ ```ts
129
+ const client = createClient({
130
+ baseUrl: 'https://api.m2cmarkets.com',
131
+ publishableKey: 'pub_live_...',
132
+ launchMode: 'popup', // same value used before start()
133
+ });
134
+
135
+ // On your success_url / cancel_url page in the checkout window:
136
+ await client.resume({ outcome: 'success' });
137
+ ```
138
+
139
+ When the return page calls `resume()`, the SDK also broadcasts the terminal
140
+ result back to the original same-origin page. In popup/new-tab mode, the
141
+ `start()` / `startFromSession()` promise in the original page resolves with the
142
+ same result.
143
+
144
+ This handoff does not make `successUrl` authoritative. `completed` and `failed`
145
+ still come from the configured status source, and fulfillment should still be
146
+ driven server-side from webhook state.
147
+
148
+ If the checkout window observably closes before a return page publishes a
149
+ result, the original page resolves `start()` / `startFromSession()` with
150
+ `{ status: 'window_closed', requestId }`. This is not a payment status and does
151
+ not mean the purchase failed or was canceled. Some hosted checkout pages sever
152
+ opener observability while the window is still alive; in that case the SDK keeps
153
+ waiting for the return handoff, then treats opener focus as an abandonment
154
+ signal if no return result appears.
155
+
156
+ Set `returnTimeoutMs` if you want popup/new-tab mode to stop waiting after a
157
+ fixed window when no return handoff arrives. This is an advisory UX fallback;
158
+ choose a value long enough for a normal customer checkout, and keep server-side
159
+ webhook state as the fulfillment source of truth.
160
+
161
+ To reconcile an ambiguous outcome after the fact - a `window_closed` or
162
+ `pending_timeout` that may have completed server-side - re-read the status for
163
+ the `requestId` with `checkStatus`, a one-shot read against the configured
164
+ status source:
165
+
166
+ ```ts
167
+ const status = await client.checkStatus(requestId);
168
+ // 'processing' | 'completed' | 'failed' | 'canceled'
169
+ ```
170
+
171
+ `checkStatus` does not change the client's lifecycle state, and (like the poll)
172
+ it is advisory - your webhook remains the source of truth.
173
+
174
+ For example:
175
+
176
+ ```ts
177
+ const result = await client.start({
178
+ transactionValue: 49.99,
179
+ successUrl: 'https://shop.example/checkout/return',
180
+ cancelUrl: 'https://shop.example/checkout/canceled',
181
+ });
182
+ ```
183
+
184
+ The checkout window still lands on your return page because that is where the
185
+ vendor redirects the customer. The original page receives the result through a
186
+ same-origin `BroadcastChannel` / `localStorage` handoff; the SDK does not rely
187
+ on `window.opener`.
188
+
189
+ ## Progress events
190
+
191
+ ```ts
192
+ const unsubscribe = client.onStateChange((state, ctx) => {
193
+ // state: idle | creating | ready | launching | awaiting_return | returned
194
+ // | polling | completed | failed | canceled | pending_timeout
195
+ // | window_closed | error
196
+ // ctx: { requestId?, error? } (error is set only on the 'error' state)
197
+ render(state);
198
+ });
199
+ ```
200
+
201
+ ## Status sources
202
+
203
+ Configure where status is read from after the return:
204
+
205
+ - `{ kind: 'm2c' }` - poll the M2C advisory read endpoint with the publishable
206
+ key (client-initiated only). The default.
207
+ - `{ kind: 'url', template }` - poll your endpoint; `{request_id}` is
208
+ substituted. Answer from your webhook-recorded state. Recommended for
209
+ backend-initiated mode.
210
+ - `{ kind: 'callback', checkStatus }` - call your async function on the backoff
211
+ schedule. A function cannot survive a full-page redirect, so **re-supply it on
212
+ the return page** via `createClient({ statusSource })` or
213
+ `resume({ statusSource })`.
214
+
215
+ Status readers may return the client statuses (`processing`, `completed`,
216
+ `failed`, `canceled`) or webhook-native statuses from your own store (`pending`,
217
+ `abandoned`, `refunded`, `chargedback`). The SDK maps them to the client result
218
+ enum before resolving.
219
+
220
+ The poll uses bounded exponential backoff (immediate, then 1s, 2s, 4s, 8s,
221
+ capped at 8s, ~90s total window; override with `poll`). Correlation is always
222
+ `request_id`, which you get from the auction response and your webhook records -
223
+ no pre-coordination needed.
224
+
225
+ > A push (`subscribe`) adapter is reserved in the type surface but not
226
+ > implemented yet; use `m2c`, `url`, or `callback`.
227
+
228
+ ## Error handling
229
+
230
+ Rejections are `M2CCheckoutError` with a typed `code`:
231
+
232
+ | code | meaning |
233
+ |---|---|
234
+ | `InvalidRequest` | bad input / unsupported currency / bad URL (400, or client-side) |
235
+ | `OriginNotAllowed` | the page origin is not on the key's allowlist (403) |
236
+ | `AccountSuspended` | the merchant account is suspended (403) |
237
+ | `NoVendorsAvailable` | no linked/eligible vendor produced a bid (404) |
238
+ | `RateLimited` | slow down; see `retryAfterSeconds` (429) |
239
+ | `ServiceUnavailable` | transient backend failure (5xx) |
240
+ | `CheckoutExpired` | the TTL lapsed before launch (backend mode cannot re-mint) |
241
+ | `Network` | no HTTP response (fetch failure) |
242
+
243
+ `pending_timeout` is **not** an error - it resolves as a terminal result. During
244
+ polling, transient read failures (rate limit, 5xx, network, a not-yet-visible
245
+ status row) are swallowed and retried; a developer-actionable failure (bad
246
+ origin, invalid request) rejects so you see it.
247
+
248
+ ## Framework recipes
249
+
250
+ Vanilla:
251
+
252
+ ```ts
253
+ button.onclick = () => client.start({ /* ... */ }).catch(showError);
254
+ client.onStateChange((s) => (statusEl.textContent = s));
255
+ ```
256
+
257
+ React (drive a state variable from the event; resume on the return route):
258
+
259
+ ```tsx
260
+ useEffect(() => client.onStateChange(setState), []);
261
+ const onBuy = () => client.start({ /* ... */ }).catch(setError);
262
+
263
+ // On the return route:
264
+ useEffect(() => {
265
+ client.resume({ outcome: 'success' }).then(setResult).catch(setError);
266
+ }, []);
267
+ ```
268
+
269
+ ## Test mode
270
+
271
+ Use test keys (`pub_test_...`) and forward a backend session created with a
272
+ `sec_test_...` key. The auction runs against the in-server sandbox vendors and
273
+ returns a synthetic M2C-hosted `checkout_url`; the status read is scoped to the
274
+ test flag derived from the key. No special code path - the same flow drives the
275
+ sandbox.
276
+
277
+ ## Server-side / non-browser usage
278
+
279
+ All DOM access is guarded, so importing the module on a server never throws.
280
+ `start` / `startFromSession` / `resume` throw `InvalidRequest` if no
281
+ `sessionStorage` / navigation is available; inject `storage`, `navigate`, and
282
+ `fetch` to drive the SDK in a non-browser environment (this is how the test
283
+ suite runs).
284
+
285
+ ## What this SDK does not do
286
+
287
+ - Capture card data or render the vendor's checkout page (M2C is a router; the
288
+ vendor owns PCI scope).
289
+ - Decide fulfillment. Grant goods server-side off the M2C webhook. See the
290
+ fulfillment webhook receiver examples that ship alongside the SDKs.
291
+ - Ship UI. Headless by design.
292
+
293
+ ## Packaging note
294
+
295
+ This package currently publishes ESM only, matching the rest of the M2C SDK
296
+ workspace and its plain-`tsc`, zero-dependency build. Every modern bundler and
297
+ Node 18+ consume it directly; a CJS build can be added without an API change if
298
+ a consumer needs it.
@@ -0,0 +1,21 @@
1
+ import type { NormalizedConfig, StartParams } from './types.js';
2
+ export interface AuctionSession {
3
+ checkoutUrl: string;
4
+ requestId: string;
5
+ ttl?: number;
6
+ }
7
+ /**
8
+ * Build the auction request body from client-initiated params. The server
9
+ * decoder rejects unknown fields, so only the contract keys are emitted, and
10
+ * each is validated against the wire limits first. `country` / `customer_ip`
11
+ * are intentionally never sent: the server derives them from the observed
12
+ * connection for publishable callers.
13
+ */
14
+ export declare function buildAuctionBody(params: StartParams, allowInsecureUrls: boolean): string;
15
+ /**
16
+ * Run a client-initiated auction with the publishable key. The browser sets the
17
+ * `Origin` header (it is forbidden to set from script), which the server gates
18
+ * against the key's allowlist. Resolves the minimal publishable session
19
+ * ({ checkout_url, ttl, request_id }); rejects with a typed error otherwise.
20
+ */
21
+ export declare function runAuction(config: NormalizedConfig, params: StartParams): Promise<AuctionSession>;
@@ -0,0 +1,136 @@
1
+ import { M2CCheckoutError, auctionErrorForResponse, parseRetryAfter } from './errors.js';
2
+ import { assertTransactionValue, validateCheckoutUrl, validateDescription, validateDeviceType, validateReference, validateReferrer, validateSegments, validateUrl, } from './validate.js';
3
+ const AUCTION_PATH = '/api/v1/auction';
4
+ /**
5
+ * Build the auction request body from client-initiated params. The server
6
+ * decoder rejects unknown fields, so only the contract keys are emitted, and
7
+ * each is validated against the wire limits first. `country` / `customer_ip`
8
+ * are intentionally never sent: the server derives them from the observed
9
+ * connection for publishable callers.
10
+ */
11
+ export function buildAuctionBody(params, allowInsecureUrls) {
12
+ assertTransactionValue(params.transactionValue);
13
+ const wire = {
14
+ transaction_value: params.transactionValue,
15
+ success_url: validateUrl(params.successUrl, 'successUrl', allowInsecureUrls),
16
+ cancel_url: validateUrl(params.cancelUrl, 'cancelUrl', allowInsecureUrls),
17
+ };
18
+ if (params.currency !== undefined) {
19
+ if (typeof params.currency !== 'string' || params.currency === '') {
20
+ throw new M2CCheckoutError('InvalidRequest', 'currency must be a non-empty string');
21
+ }
22
+ // The server's allowlist (model.IsSupportedCurrency) is the authority; we
23
+ // only guard the shape and let an unsupported code return a clean 400.
24
+ wire.currency = params.currency;
25
+ }
26
+ const description = validateDescription(params.description);
27
+ if (description !== undefined)
28
+ wire.description = description;
29
+ const segments = validateSegments(params.segments);
30
+ if (segments !== undefined)
31
+ wire.segments = segments;
32
+ if (params.language !== undefined) {
33
+ if (typeof params.language !== 'string') {
34
+ throw new M2CCheckoutError('InvalidRequest', 'language must be a string');
35
+ }
36
+ wire.language = params.language;
37
+ }
38
+ const deviceType = validateDeviceType(params.deviceType);
39
+ if (deviceType !== undefined)
40
+ wire.device_type = deviceType;
41
+ const referrer = validateReferrer(params.referrer);
42
+ if (referrer !== undefined)
43
+ wire.referrer = referrer;
44
+ const reference = validateReference(params.reference);
45
+ if (reference !== undefined)
46
+ wire.reference = reference;
47
+ return JSON.stringify(wire);
48
+ }
49
+ /**
50
+ * Run a client-initiated auction with the publishable key. The browser sets the
51
+ * `Origin` header (it is forbidden to set from script), which the server gates
52
+ * against the key's allowlist. Resolves the minimal publishable session
53
+ * ({ checkout_url, ttl, request_id }); rejects with a typed error otherwise.
54
+ */
55
+ export async function runAuction(config, params) {
56
+ if (!config.publishableKey) {
57
+ throw new M2CCheckoutError('InvalidRequest', 'publishableKey is required for client-initiated start()');
58
+ }
59
+ if (!config.fetch) {
60
+ throw new M2CCheckoutError('InvalidRequest', 'no fetch implementation available; pass config.fetch or run where global fetch exists');
61
+ }
62
+ const body = buildAuctionBody(params, config.allowInsecureUrls);
63
+ let res;
64
+ try {
65
+ res = await config.fetch(`${config.baseUrl}${AUCTION_PATH}`, {
66
+ method: 'POST',
67
+ headers: {
68
+ 'Content-Type': 'application/json',
69
+ 'X-API-Key': config.publishableKey,
70
+ },
71
+ body,
72
+ // No ambient credentials: the publishable key in the header is the auth.
73
+ credentials: 'omit',
74
+ // M2C never redirects this route; a redirect would re-send the key to
75
+ // another host, so treat any redirect as a failure rather than follow it.
76
+ redirect: 'error',
77
+ });
78
+ }
79
+ catch (err) {
80
+ throw new M2CCheckoutError('Network', `auction request failed: ${err.message}`, {
81
+ cause: err,
82
+ });
83
+ }
84
+ const text = await res.text().catch(() => '');
85
+ if (res.status !== 200) {
86
+ throw auctionErrorForResponse(res.status, extractErrorMessage(text), parseRetryAfter(res.headers.get('retry-after')));
87
+ }
88
+ return parseAuctionResponse(text, config.allowInsecureUrls);
89
+ }
90
+ function parseAuctionResponse(text, allowInsecureUrls) {
91
+ let parsed;
92
+ try {
93
+ parsed = JSON.parse(text);
94
+ }
95
+ catch {
96
+ throw new M2CCheckoutError('Unknown', 'auction response was not valid JSON');
97
+ }
98
+ if (typeof parsed !== 'object' || parsed === null) {
99
+ throw new M2CCheckoutError('Unknown', 'auction response had an unexpected shape');
100
+ }
101
+ const obj = parsed;
102
+ const winner = obj.winner;
103
+ const requestId = obj.request_id;
104
+ if (typeof requestId !== 'string' || requestId === '') {
105
+ throw new M2CCheckoutError('Unknown', 'auction response was missing request_id');
106
+ }
107
+ if (typeof winner !== 'object' || winner === null) {
108
+ throw new M2CCheckoutError('Unknown', 'auction response was missing winner');
109
+ }
110
+ const checkoutUrl = winner.checkout_url;
111
+ const ttl = winner.ttl;
112
+ if (typeof checkoutUrl !== 'string' || checkoutUrl === '') {
113
+ throw new M2CCheckoutError('Unknown', 'auction response was missing winner.checkout_url');
114
+ }
115
+ return {
116
+ checkoutUrl: validateCheckoutUrl(checkoutUrl, 'winner.checkout_url', allowInsecureUrls),
117
+ requestId,
118
+ ttl: typeof ttl === 'number' ? ttl : undefined,
119
+ };
120
+ }
121
+ function extractErrorMessage(text) {
122
+ if (!text)
123
+ return undefined;
124
+ try {
125
+ const parsed = JSON.parse(text);
126
+ if (typeof parsed === 'object' && parsed !== null) {
127
+ const err = parsed.error;
128
+ if (typeof err === 'string')
129
+ return err;
130
+ }
131
+ }
132
+ catch {
133
+ // Non-JSON error body; fall through to the raw text.
134
+ }
135
+ return text;
136
+ }
@@ -0,0 +1,33 @@
1
+ import type { PollDeps } from './poll.js';
2
+ import type { CheckoutClient, CheckoutResult, CheckoutState, ClientConfig, ClientStatus, NormalizedConfig, ResumeParams, StartFromSessionParams, StartParams, StateChangeListener } from './types.js';
3
+ /** Create a headless checkout client. Safe to import in SSR; lifecycle methods need a DOM. */
4
+ export declare function createClient(config: ClientConfig): CheckoutClient;
5
+ declare class CheckoutClientImpl implements CheckoutClient {
6
+ private readonly config;
7
+ private state;
8
+ private requestId;
9
+ private readonly listeners;
10
+ private readonly pollDeps;
11
+ constructor(config: NormalizedConfig, pollDeps?: PollDeps);
12
+ getState(): CheckoutState;
13
+ onStateChange(listener: StateChangeListener): () => void;
14
+ start(params: StartParams): Promise<CheckoutResult>;
15
+ startFromSession(params: StartFromSessionParams): Promise<CheckoutResult>;
16
+ resume(params?: ResumeParams): Promise<CheckoutResult | null>;
17
+ checkStatus(requestId: string): Promise<ClientStatus>;
18
+ private persistAndLaunch;
19
+ private prepareAsyncLaunchWindow;
20
+ private waitForReturnResult;
21
+ private publishReturnResult;
22
+ private launchCheckout;
23
+ private openBlankCheckoutWindow;
24
+ private closeLaunchWindow;
25
+ private setState;
26
+ private fail;
27
+ private requireIdle;
28
+ private requireStorage;
29
+ private requireLaunchEnvironment;
30
+ private requireLaunchableStatusSource;
31
+ }
32
+ declare function normalizeConfig(config: ClientConfig): NormalizedConfig;
33
+ export { CheckoutClientImpl, normalizeConfig };