@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.
@@ -0,0 +1,178 @@
1
+ import type { M2CCheckoutError } from './errors.js';
2
+ /**
3
+ * Client-facing conversion status, mapped server-side from the internal
4
+ * `auction_logs.conversion_status` (see foundations 4c). `processing` is the
5
+ * only non-terminal value; the others end a poll.
6
+ */
7
+ export type ClientStatus = 'processing' | 'completed' | 'failed' | 'canceled';
8
+ /** Lifecycle states the SDK passes through. Drives the merchant's progress UI. */
9
+ export type CheckoutState = 'idle' | 'creating' | 'ready' | 'launching' | 'awaiting_return' | 'returned' | 'polling' | 'completed' | 'failed' | 'canceled' | 'pending_timeout' | 'window_closed' | 'error';
10
+ /** Terminal outcome of a checkout. `pending_timeout` resolves; it does not reject. */
11
+ export type CheckoutResult = {
12
+ status: 'completed';
13
+ requestId: string;
14
+ } | {
15
+ status: 'failed';
16
+ requestId: string;
17
+ } | {
18
+ status: 'canceled';
19
+ requestId: string;
20
+ } | {
21
+ status: 'pending_timeout';
22
+ requestId: string;
23
+ } | {
24
+ status: 'window_closed';
25
+ requestId: string;
26
+ };
27
+ export interface StateChangeContext {
28
+ /** The auction request id once known. */
29
+ requestId?: string;
30
+ /** Set only on the `error` state. */
31
+ error?: M2CCheckoutError;
32
+ }
33
+ export type StateChangeListener = (state: CheckoutState, ctx: StateChangeContext) => void;
34
+ /** Bounded-exponential-backoff poll schedule. A single shared definition; see foundations 6. */
35
+ export interface PollConfig {
36
+ /** Delay before the second poll (the first poll is immediate). */
37
+ firstDelayMs: number;
38
+ /** Ceiling each delay doubles toward. */
39
+ maxDelayMs: number;
40
+ /** Multiplier applied to the delay each attempt. */
41
+ factor: number;
42
+ /** Total wall-clock budget; on elapse the poll resolves to a `pending_timeout`. */
43
+ windowMs: number;
44
+ }
45
+ /** Adapter for a push status channel (SSE/websocket). Reserved; see README. */
46
+ export type SubscribeAdapter = (requestId: string, onStatus: (status: ClientStatus) => void) => () => void;
47
+ /**
48
+ * Where the SDK reads status from after the customer returns.
49
+ * - `m2c`: the M2C advisory read endpoint (client-initiated; needs the publishable key).
50
+ * - `url`: a merchant endpoint template; `{request_id}` is substituted.
51
+ * - `callback`: an arbitrary function the merchant supplies.
52
+ * - `subscribe`: a push adapter (reserved for a future release).
53
+ */
54
+ export type StatusSource = {
55
+ kind: 'm2c';
56
+ } | {
57
+ kind: 'url';
58
+ template: string;
59
+ } | {
60
+ kind: 'callback';
61
+ checkStatus: (requestId: string) => Promise<ClientStatus>;
62
+ } | {
63
+ kind: 'subscribe';
64
+ subscribe: SubscribeAdapter;
65
+ };
66
+ /** The subset of the Web Storage API the SDK uses. `sessionStorage` satisfies it. */
67
+ export interface CheckoutStorage {
68
+ getItem(key: string): string | null;
69
+ setItem(key: string, value: string): void;
70
+ removeItem(key: string): void;
71
+ }
72
+ /** Performs the page navigation to the vendor checkout. Defaults to `location.assign`. */
73
+ export type Navigate = (url: string) => void;
74
+ /**
75
+ * How checkout is launched. `redirect` is the default and is the most reliable.
76
+ * `new_tab` and `popup` pre-open a same-origin blank window before async work,
77
+ * then navigate it after the checkout URL is known.
78
+ */
79
+ export type LaunchMode = 'redirect' | 'new_tab' | 'popup';
80
+ /** Opens a browser window/tab for popup launch mode. Defaults to `window.open`. */
81
+ export type OpenCheckoutWindow = (url: string, target: string, features?: string) => Window | null;
82
+ export interface ClientConfig {
83
+ /** Bid API base, e.g. `https://api.m2cmarkets.com`. */
84
+ baseUrl: string;
85
+ /** Required only for client-initiated `start()` and the `m2c` status source. */
86
+ publishableKey?: string;
87
+ /** Default status source. Defaults to `{ kind: 'm2c' }`. */
88
+ statusSource?: StatusSource;
89
+ /** Override the default poll backoff/window. */
90
+ poll?: Partial<PollConfig>;
91
+ /** Storage namespace for the resume record. Defaults to `m2c.checkout`. */
92
+ storageKey?: string;
93
+ /** Injectable `fetch` (testing / non-standard runtimes). Defaults to global `fetch`. */
94
+ fetch?: typeof fetch;
95
+ /** Injectable storage. Defaults to browser storage when available. */
96
+ storage?: CheckoutStorage;
97
+ /** Injectable navigation. Defaults to `location.assign`. */
98
+ navigate?: Navigate;
99
+ /** Where to open the vendor checkout. Defaults to `redirect`. */
100
+ launchMode?: LaunchMode;
101
+ /** Injectable `window.open` equivalent for `new_tab` / `popup` launch modes. */
102
+ openWindow?: OpenCheckoutWindow;
103
+ /**
104
+ * Popup/new-tab fallback: resolve `window_closed` if no return handoff arrives
105
+ * within this many milliseconds. Off by default.
106
+ */
107
+ returnTimeoutMs?: number;
108
+ /**
109
+ * Permit `http://` base and return URLs to loopback hosts (local dev only).
110
+ * Off by default: production return URLs must be `https`.
111
+ */
112
+ allowInsecureUrls?: boolean;
113
+ }
114
+ /** Fields for a client-initiated auction. Money is in major currency units (e.g. 49.99). */
115
+ export interface StartParams {
116
+ transactionValue: number;
117
+ currency?: string;
118
+ description?: string;
119
+ successUrl: string;
120
+ cancelUrl: string;
121
+ segments?: string[];
122
+ language?: string;
123
+ deviceType?: string;
124
+ referrer?: string;
125
+ reference?: string;
126
+ }
127
+ /** A backend-created session forwarded to the client (the blessed default mode). */
128
+ export interface StartFromSessionParams {
129
+ checkoutUrl: string;
130
+ requestId: string;
131
+ /** Remaining validity in seconds, if the backend forwarded it. */
132
+ ttl?: number;
133
+ }
134
+ export interface ResumeParams {
135
+ /**
136
+ * Which return URL this page is. The SDK cannot tell success_url from
137
+ * cancel_url on its own, so the merchant declares it. Defaults to `success`.
138
+ */
139
+ outcome?: 'success' | 'cancel';
140
+ /**
141
+ * Re-supply a non-serializable status source (`callback`) on the return page;
142
+ * a function cannot survive the full-page redirect.
143
+ */
144
+ statusSource?: StatusSource;
145
+ }
146
+ export interface CheckoutClient {
147
+ /** Client-initiated: run the auction, then launch the vendor checkout. */
148
+ start(params: StartParams): Promise<CheckoutResult>;
149
+ /** Backend-initiated: launch a checkout the backend already created. */
150
+ startFromSession(params: StartFromSessionParams): Promise<CheckoutResult>;
151
+ /** Call on the return page. Resolves the terminal result, or null if no checkout was in progress. */
152
+ resume(params?: ResumeParams): Promise<CheckoutResult | null>;
153
+ /**
154
+ * One-shot status read for a requestId via the configured status source.
155
+ * Reconciles an ambiguous outcome (`window_closed` / `pending_timeout`) after
156
+ * the fact; does not change lifecycle state and may resolve `processing`.
157
+ */
158
+ checkStatus(requestId: string): Promise<ClientStatus>;
159
+ /** Subscribe to lifecycle transitions. Returns an unsubscribe function. */
160
+ onStateChange(listener: StateChangeListener): () => void;
161
+ /** The current lifecycle state. */
162
+ getState(): CheckoutState;
163
+ }
164
+ /** Fully-resolved configuration the internal modules operate on. */
165
+ export interface NormalizedConfig {
166
+ baseUrl: string;
167
+ publishableKey?: string;
168
+ statusSource: StatusSource;
169
+ poll: PollConfig;
170
+ storageKey: string;
171
+ fetch?: typeof fetch;
172
+ storage?: CheckoutStorage;
173
+ navigate?: Navigate;
174
+ launchMode: LaunchMode;
175
+ openWindow?: OpenCheckoutWindow;
176
+ returnTimeoutMs?: number;
177
+ allowInsecureUrls: boolean;
178
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ export declare const MAX_TRANSACTION_VALUE = 5000000000;
2
+ export declare const UUID_RE: RegExp;
3
+ /**
4
+ * UTF-8 byte length. The server measures string limits in bytes (Go `len`), so
5
+ * we must too; `String.length` counts UTF-16 units and would diverge on
6
+ * multi-byte input. TextEncoder is available in browsers and Node.
7
+ */
8
+ export declare function utf8Bytes(value: string): number;
9
+ /**
10
+ * A strict subset of the server's loopback check: localhost, ::1, 127.0.0.0/8.
11
+ * Gates the dev-only `allowInsecureUrls` escape hatch, never a security
12
+ * boundary - the server re-validates every URL. Keeping it a subset guarantees
13
+ * any host this accepts the server also treats as loopback.
14
+ */
15
+ export declare function isLoopbackHost(host: string): boolean;
16
+ export declare function assertTransactionValue(value: unknown): asserts value is number;
17
+ /**
18
+ * Validate a base URL or return URL: a parseable absolute URL with a host, and
19
+ * `https` unless `allowInsecure` permits loopback `http`. Returns the original
20
+ * string so callers can use the validated value inline.
21
+ */
22
+ export declare function validateUrl(value: unknown, label: string, allowInsecure: boolean): string;
23
+ /**
24
+ * Validate a server-issued checkout_url. The byte cap mirrors the auction
25
+ * engine's maxCheckoutURLLength so the SDK does not reject a URL the server
26
+ * intentionally allowed.
27
+ */
28
+ export declare function validateCheckoutUrl(value: unknown, label: string, allowInsecure: boolean): string;
29
+ /** Validate the optional auction fields against the wire contract. */
30
+ export declare function validateDescription(value: unknown): string | undefined;
31
+ export declare function validateReferrer(value: unknown): string | undefined;
32
+ export declare function validateReference(value: unknown): string | undefined;
33
+ export declare function validateDeviceType(value: unknown): string | undefined;
34
+ export declare function validateSegments(value: unknown): string[] | undefined;
@@ -0,0 +1,138 @@
1
+ import { M2CCheckoutError } from './errors.js';
2
+ // Mirror the bid server's wire limits (model/bid.go, foundations 4a) so a
3
+ // contract violation fails client-side with a clear message instead of a
4
+ // round-trip and a generic 400.
5
+ export const MAX_TRANSACTION_VALUE = 5000000000;
6
+ const MAX_RETURN_URL_BYTES = 2048;
7
+ const MAX_CHECKOUT_URL_BYTES = 4096;
8
+ const MAX_DESCRIPTION_BYTES = 256;
9
+ const MAX_SEGMENTS = 20;
10
+ const MAX_SEGMENT_BYTES = 128;
11
+ const MAX_REFERRER_BYTES = 2048;
12
+ const MAX_REFERENCE_BYTES = 512;
13
+ const MAX_DEVICE_TYPE_BYTES = 64;
14
+ // Canonical UUID. request_ids are always minted by the server, so this only
15
+ // catches a backend forwarding the wrong field in startFromSession.
16
+ export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
17
+ function invalid(message) {
18
+ return new M2CCheckoutError('InvalidRequest', message);
19
+ }
20
+ /**
21
+ * UTF-8 byte length. The server measures string limits in bytes (Go `len`), so
22
+ * we must too; `String.length` counts UTF-16 units and would diverge on
23
+ * multi-byte input. TextEncoder is available in browsers and Node.
24
+ */
25
+ export function utf8Bytes(value) {
26
+ return new TextEncoder().encode(value).length;
27
+ }
28
+ /**
29
+ * A strict subset of the server's loopback check: localhost, ::1, 127.0.0.0/8.
30
+ * Gates the dev-only `allowInsecureUrls` escape hatch, never a security
31
+ * boundary - the server re-validates every URL. Keeping it a subset guarantees
32
+ * any host this accepts the server also treats as loopback.
33
+ */
34
+ export function isLoopbackHost(host) {
35
+ const h = host.toLowerCase();
36
+ if (h === 'localhost' || h === '::1' || h === '[::1]') {
37
+ return true;
38
+ }
39
+ const octets = h.split('.');
40
+ return (octets.length === 4 &&
41
+ octets[0] === '127' &&
42
+ octets.every((part) => /^\d+$/.test(part) && Number(part) <= 255));
43
+ }
44
+ export function assertTransactionValue(value) {
45
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
46
+ throw invalid('transactionValue must be a finite number');
47
+ }
48
+ if (value <= 0) {
49
+ throw invalid('transactionValue must be greater than 0');
50
+ }
51
+ if (value > MAX_TRANSACTION_VALUE) {
52
+ throw invalid(`transactionValue must be at most ${MAX_TRANSACTION_VALUE} major units`);
53
+ }
54
+ }
55
+ /**
56
+ * Validate a base URL or return URL: a parseable absolute URL with a host, and
57
+ * `https` unless `allowInsecure` permits loopback `http`. Returns the original
58
+ * string so callers can use the validated value inline.
59
+ */
60
+ export function validateUrl(value, label, allowInsecure) {
61
+ return validateHttpUrl(value, label, allowInsecure, MAX_RETURN_URL_BYTES);
62
+ }
63
+ /**
64
+ * Validate a server-issued checkout_url. The byte cap mirrors the auction
65
+ * engine's maxCheckoutURLLength so the SDK does not reject a URL the server
66
+ * intentionally allowed.
67
+ */
68
+ export function validateCheckoutUrl(value, label, allowInsecure) {
69
+ return validateHttpUrl(value, label, allowInsecure, MAX_CHECKOUT_URL_BYTES);
70
+ }
71
+ function validateHttpUrl(value, label, allowInsecure, maxBytes) {
72
+ if (typeof value !== 'string' || value === '') {
73
+ throw invalid(`${label} is required`);
74
+ }
75
+ if (utf8Bytes(value) > maxBytes) {
76
+ throw invalid(`${label} must be at most ${maxBytes} bytes`);
77
+ }
78
+ let url;
79
+ try {
80
+ url = new URL(value);
81
+ }
82
+ catch {
83
+ throw invalid(`${label} must be a valid absolute URL`);
84
+ }
85
+ if (!url.host) {
86
+ throw invalid(`${label} must include a host`);
87
+ }
88
+ if (url.protocol === 'https:') {
89
+ return value;
90
+ }
91
+ if (allowInsecure && url.protocol === 'http:' && isLoopbackHost(url.hostname)) {
92
+ return value;
93
+ }
94
+ throw invalid(`${label} must be an https URL (set allowInsecureUrls to permit loopback http for dev)`);
95
+ }
96
+ function assertOptionalString(value, label, maxBytes) {
97
+ if (value === undefined)
98
+ return undefined;
99
+ if (typeof value !== 'string') {
100
+ throw invalid(`${label} must be a string`);
101
+ }
102
+ if (utf8Bytes(value) > maxBytes) {
103
+ throw invalid(`${label} must be at most ${maxBytes} bytes`);
104
+ }
105
+ return value;
106
+ }
107
+ /** Validate the optional auction fields against the wire contract. */
108
+ export function validateDescription(value) {
109
+ return assertOptionalString(value, 'description', MAX_DESCRIPTION_BYTES);
110
+ }
111
+ export function validateReferrer(value) {
112
+ return assertOptionalString(value, 'referrer', MAX_REFERRER_BYTES);
113
+ }
114
+ export function validateReference(value) {
115
+ return assertOptionalString(value, 'reference', MAX_REFERENCE_BYTES);
116
+ }
117
+ export function validateDeviceType(value) {
118
+ return assertOptionalString(value, 'deviceType', MAX_DEVICE_TYPE_BYTES);
119
+ }
120
+ export function validateSegments(value) {
121
+ if (value === undefined)
122
+ return undefined;
123
+ if (!Array.isArray(value)) {
124
+ throw invalid('segments must be an array of strings');
125
+ }
126
+ if (value.length > MAX_SEGMENTS) {
127
+ throw invalid(`segments must have at most ${MAX_SEGMENTS} entries`);
128
+ }
129
+ for (const entry of value) {
130
+ if (typeof entry !== 'string') {
131
+ throw invalid('segments must be an array of strings');
132
+ }
133
+ if (utf8Bytes(entry) > MAX_SEGMENT_BYTES) {
134
+ throw invalid(`each segment must be at most ${MAX_SEGMENT_BYTES} bytes`);
135
+ }
136
+ }
137
+ return value;
138
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@m2c/checkout",
3
+ "version": "0.1.0",
4
+ "description": "Headless browser checkout SDK for M2C: run an auction, redirect to the winning vendor's hosted checkout, and reflect conversion status.",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=18"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ }
16
+ },
17
+ "sideEffects": false,
18
+ "files": [
19
+ "dist",
20
+ "README.md"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.json",
24
+ "check:package-files": "node scripts/assert-local-example-unpublished.mjs",
25
+ "typecheck": "tsc -p tsconfig.test.json",
26
+ "test": "node --import tsx --test test/**/*.test.ts",
27
+ "serve:example": "npm run build && node example/server.mjs",
28
+ "prepack": "npm run build && npm run check:package-files",
29
+ "prepublishOnly": "npm run check:package-files"
30
+ },
31
+ "keywords": [
32
+ "m2c",
33
+ "payments",
34
+ "checkout",
35
+ "browser",
36
+ "sdk"
37
+ ],
38
+ "author": {
39
+ "name": "M2C Markets",
40
+ "url": "https://m2cmarkets.com"
41
+ },
42
+ "license": "MIT",
43
+ "bugs": {
44
+ "url": "https://m2cmarkets.com/contact"
45
+ },
46
+ "homepage": "https://m2cmarkets.com/docs",
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^20.11.0",
52
+ "tsx": "^4.7.0",
53
+ "typescript": "^5.4.0"
54
+ }
55
+ }