@peable.to/sdk 0.1.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/dist/cjs/browser/mount.js +308 -0
- package/dist/cjs/browser/payerClient.js +135 -0
- package/dist/cjs/checkout.js +17 -0
- package/dist/cjs/core/client.js +77 -0
- package/dist/cjs/core/config.js +38 -0
- package/dist/cjs/core/errors.js +112 -0
- package/dist/cjs/core/serviceToken.js +81 -0
- package/dist/cjs/index.js +41 -0
- package/dist/cjs/resources/checkoutSessions.js +26 -0
- package/dist/cjs/resources/paymentIntents.js +30 -0
- package/dist/cjs/resources/paymentLinks.js +23 -0
- package/dist/cjs/resources/webhooks.js +65 -0
- package/dist/esm/browser/mount.js +298 -0
- package/dist/esm/browser/payerClient.js +132 -0
- package/dist/esm/checkout.js +12 -0
- package/dist/esm/core/client.js +74 -0
- package/dist/esm/core/config.js +34 -0
- package/dist/esm/core/errors.js +102 -0
- package/dist/esm/core/serviceToken.js +78 -0
- package/dist/esm/index.js +28 -0
- package/dist/esm/resources/checkoutSessions.js +21 -0
- package/dist/esm/resources/paymentIntents.js +26 -0
- package/dist/esm/resources/paymentLinks.js +19 -0
- package/dist/esm/resources/webhooks.js +61 -0
- package/dist/types/browser/mount.d.ts +80 -0
- package/dist/types/browser/payerClient.d.ts +23 -0
- package/dist/types/checkout.d.ts +2 -0
- package/dist/types/core/client.d.ts +17 -0
- package/dist/types/core/config.d.ts +30 -0
- package/dist/types/core/errors.d.ts +54 -0
- package/dist/types/core/serviceToken.d.ts +11 -0
- package/dist/types/index.d.ts +22 -0
- package/dist/types/resources/checkoutSessions.d.ts +15 -0
- package/dist/types/resources/paymentIntents.d.ts +29 -0
- package/dist/types/resources/paymentLinks.d.ts +29 -0
- package/dist/types/resources/webhooks.d.ts +18 -0
- package/package.json +83 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// `PeableCheckout.mount(...)` — the embeddable "Pay with Peable" button
|
|
2
|
+
// (Fase 2 SDK plan, Task 6). Plain DOM, no framework, so it embeds on any
|
|
3
|
+
// site. Renders a button into the host element, fetches + subscribes to the
|
|
4
|
+
// payment intent via the Task 5 payer client, drives a typed
|
|
5
|
+
// `settled`/`confirming`/`failed`/`error` event emitter, and on click either
|
|
6
|
+
// opens the `peable://pay` deep link (mobile) or shows a QR of it (desktop).
|
|
7
|
+
//
|
|
8
|
+
// Same `./core/*` boundary as `payerClient.ts`: the only import from `./core`
|
|
9
|
+
// is `core/errors.ts`, a pure status-mapper with no service-token/secret
|
|
10
|
+
// machinery, so it carries none of the merchant-authed code out of bounds for
|
|
11
|
+
// this browser bundle.
|
|
12
|
+
import createQrCode from 'qrcode-generator';
|
|
13
|
+
import { createPeableCheckout } from './payerClient.js';
|
|
14
|
+
import { PeableApiError, PeableError, PeableInvalidRequestError } from '../core/errors.js';
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Intent id derivation (pure, unit-tested independently of the DOM).
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
/**
|
|
19
|
+
* `pi_` followed by ≥24 lowercase hex chars — the Gateway's payment-intent id
|
|
20
|
+
* format. Mirrors `INTENT_ID_PATTERN` in the wallet's
|
|
21
|
+
* `packages/frontend/src/pay/payment-request.ts` byte-for-byte, and matches
|
|
22
|
+
* what `newId('pi')` in `packages/backend/src/lib/ids.ts` actually mints (12
|
|
23
|
+
* random bytes → 24 hex chars).
|
|
24
|
+
*/
|
|
25
|
+
const INTENT_ID_PATTERN = /^pi_[0-9a-f]{24,}$/;
|
|
26
|
+
/**
|
|
27
|
+
* Derive and validate the payment intent id for `mount()`. When `intentId` is
|
|
28
|
+
* omitted, the id IS the prefix of `clientSecret` before `_secret_`
|
|
29
|
+
* (`clientSecretFor` in `packages/backend/src/lib/ids.ts` mints
|
|
30
|
+
* `${id}_secret_<hex>`, so `clientSecret.split('_secret_')[0]` recovers it).
|
|
31
|
+
* Either way the result must look like a real Gateway-minted id AND
|
|
32
|
+
* `clientSecret` must actually belong to it — a malformed or mismatched pair
|
|
33
|
+
* is a caller integration bug, and `mount()` must never render a button
|
|
34
|
+
* wired to a bogus id.
|
|
35
|
+
*/
|
|
36
|
+
export function deriveIntentId(clientSecret, intentId) {
|
|
37
|
+
const id = intentId ?? clientSecret.split('_secret_')[0] ?? '';
|
|
38
|
+
if (!INTENT_ID_PATTERN.test(id)) {
|
|
39
|
+
throw new PeableInvalidRequestError(`PeableCheckout.mount: invalid or missing payment intent id ("${id}") — ` +
|
|
40
|
+
'pass `intentId` explicitly or a well-formed `clientSecret`');
|
|
41
|
+
}
|
|
42
|
+
if (!clientSecret.startsWith(`${id}_secret_`)) {
|
|
43
|
+
throw new PeableInvalidRequestError('PeableCheckout.mount: clientSecret does not belong to the given intentId');
|
|
44
|
+
}
|
|
45
|
+
return id;
|
|
46
|
+
}
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Deep link (pure, unit-tested independently of the DOM).
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
/** The `peable://pay` deep-link scheme + host — mirrors `PAYMENT_REQUEST_PREFIX`
|
|
51
|
+
* in the wallet's `packages/frontend/src/pay/payment-request.ts`. */
|
|
52
|
+
const PAYMENT_REQUEST_PREFIX = 'peable://pay';
|
|
53
|
+
/**
|
|
54
|
+
* Build an `peable://pay?...` deep link. Param names AND percent-encoding
|
|
55
|
+
* must match the wallet's parser byte-for-byte
|
|
56
|
+
* (`packages/frontend/src/pay/payment-request.ts`'s `parseQuery`, which
|
|
57
|
+
* splits on `&` then `=` and `decodeURIComponent`s each side raw) —
|
|
58
|
+
* deliberately built by hand with `encodeURIComponent` rather than
|
|
59
|
+
* `URLSearchParams`, whose `application/x-www-form-urlencoded` encoding
|
|
60
|
+
* (e.g. space → `+`) is a different scheme than plain `decodeURIComponent`
|
|
61
|
+
* expects on the other end.
|
|
62
|
+
*/
|
|
63
|
+
export function buildPayDeepLink(params) {
|
|
64
|
+
const pairs = [
|
|
65
|
+
['intent', params.intentId],
|
|
66
|
+
['secret', params.clientSecret],
|
|
67
|
+
['address', params.address],
|
|
68
|
+
['amount', params.amount],
|
|
69
|
+
['network', params.network],
|
|
70
|
+
];
|
|
71
|
+
const query = pairs
|
|
72
|
+
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
|
73
|
+
.join('&');
|
|
74
|
+
return `${PAYMENT_REQUEST_PREFIX}?${query}`;
|
|
75
|
+
}
|
|
76
|
+
/** Only a phone can plausibly have the Peable wallet app installed to catch
|
|
77
|
+
* the `peable://` deep link — desktop gets a QR to scan with it instead. */
|
|
78
|
+
const MOBILE_USER_AGENT_PATTERN = /Android|iPhone|iPad|iPod/i;
|
|
79
|
+
export function isMobileUserAgent(userAgent) {
|
|
80
|
+
return MOBILE_USER_AGENT_PATTERN.test(userAgent);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* A minimal typed pub/sub. The public `on`/`emit` are fully type-checked per
|
|
84
|
+
* event; the internal `Map` necessarily erases each handler's specific
|
|
85
|
+
* payload type (it stores handlers for every event key in one collection),
|
|
86
|
+
* so the single cast at the storage boundary is intentional, not a type-safety
|
|
87
|
+
* hole — nothing outside this function ever sees the erased type.
|
|
88
|
+
*/
|
|
89
|
+
export function createEmitter() {
|
|
90
|
+
const handlers = new Map();
|
|
91
|
+
return {
|
|
92
|
+
on(event, handler) {
|
|
93
|
+
const set = handlers.get(event) ?? new Set();
|
|
94
|
+
set.add(handler);
|
|
95
|
+
handlers.set(event, set);
|
|
96
|
+
},
|
|
97
|
+
emit(event, payload) {
|
|
98
|
+
const set = handlers.get(event);
|
|
99
|
+
if (!set)
|
|
100
|
+
return;
|
|
101
|
+
for (const handler of set) {
|
|
102
|
+
handler(payload);
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
removeAll() {
|
|
106
|
+
handlers.clear();
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// DOM rendering — exercised only by the real-browser Step 3 check (deferred;
|
|
112
|
+
// see the Task 6 report), not by the headless unit suite.
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
const BUTTON_CLASS = 'peable-checkout-button';
|
|
115
|
+
const OVERLAY_CLASS = 'peable-checkout-qr-overlay';
|
|
116
|
+
/** Statuses that still accept a payment — everything else disables the button. */
|
|
117
|
+
const PAYABLE_STATUSES = new Set([
|
|
118
|
+
'created',
|
|
119
|
+
'awaiting_approval',
|
|
120
|
+
'approved',
|
|
121
|
+
]);
|
|
122
|
+
function resolveTarget(target) {
|
|
123
|
+
const element = typeof target === 'string' ? document.querySelector(target) : target;
|
|
124
|
+
if (!(element instanceof Element)) {
|
|
125
|
+
throw new PeableInvalidRequestError(typeof target === 'string'
|
|
126
|
+
? `PeableCheckout.mount: no element matches selector "${target}"`
|
|
127
|
+
: 'PeableCheckout.mount: target is not a DOM Element');
|
|
128
|
+
}
|
|
129
|
+
return element;
|
|
130
|
+
}
|
|
131
|
+
function renderButton(container) {
|
|
132
|
+
const button = document.createElement('button');
|
|
133
|
+
button.type = 'button';
|
|
134
|
+
button.className = BUTTON_CLASS;
|
|
135
|
+
button.textContent = 'Pay with Peable';
|
|
136
|
+
button.disabled = true; // enabled once the initial snapshot loads
|
|
137
|
+
button.style.cssText =
|
|
138
|
+
'padding:12px 24px;border-radius:8px;border:none;background:#5b21b6;' +
|
|
139
|
+
'color:#fff;font:600 15px system-ui,sans-serif;cursor:pointer;';
|
|
140
|
+
container.replaceChildren(button);
|
|
141
|
+
return button;
|
|
142
|
+
}
|
|
143
|
+
function updateButton(button, intent) {
|
|
144
|
+
button.disabled = !PAYABLE_STATUSES.has(intent.status);
|
|
145
|
+
switch (intent.status) {
|
|
146
|
+
case 'settled':
|
|
147
|
+
button.textContent = 'Paid';
|
|
148
|
+
break;
|
|
149
|
+
case 'confirming':
|
|
150
|
+
button.textContent = 'Confirming…';
|
|
151
|
+
break;
|
|
152
|
+
case 'broadcast':
|
|
153
|
+
button.textContent = 'Payment received';
|
|
154
|
+
break;
|
|
155
|
+
case 'failed':
|
|
156
|
+
case 'expired':
|
|
157
|
+
case 'rejected':
|
|
158
|
+
button.textContent = 'Payment unavailable';
|
|
159
|
+
break;
|
|
160
|
+
default:
|
|
161
|
+
button.textContent = 'Pay with Peable';
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Show a QR of the deep link for a desktop payer to scan with the wallet
|
|
166
|
+
* app. `qr.createSvgTag()` is `qrcode-generator`'s own trusted output — a
|
|
167
|
+
* grid of `<rect>` "modules" encoding `deepLink` as opaque data, never as
|
|
168
|
+
* literal SVG text — so assigning it via `innerHTML` carries no injection
|
|
169
|
+
* risk from `deepLink`'s content.
|
|
170
|
+
*/
|
|
171
|
+
function showQrOverlay(deepLink) {
|
|
172
|
+
const qr = createQrCode(0, 'M'); // type 0 = auto-sized, level M = 15% error correction
|
|
173
|
+
qr.addData(deepLink);
|
|
174
|
+
qr.make();
|
|
175
|
+
const overlay = document.createElement('div');
|
|
176
|
+
overlay.className = OVERLAY_CLASS;
|
|
177
|
+
overlay.style.cssText =
|
|
178
|
+
'position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;' +
|
|
179
|
+
'justify-content:center;background:rgba(0,0,0,0.5);';
|
|
180
|
+
overlay.addEventListener('click', (event) => {
|
|
181
|
+
if (event.target === overlay)
|
|
182
|
+
overlay.remove();
|
|
183
|
+
});
|
|
184
|
+
const card = document.createElement('div');
|
|
185
|
+
card.style.cssText =
|
|
186
|
+
'background:#fff;border-radius:12px;padding:24px;display:flex;' +
|
|
187
|
+
'flex-direction:column;align-items:center;gap:12px;';
|
|
188
|
+
card.innerHTML = qr.createSvgTag({ cellSize: 4, margin: 4, scalable: true });
|
|
189
|
+
const caption = document.createElement('p');
|
|
190
|
+
caption.textContent = 'Scan with the Peable app';
|
|
191
|
+
caption.style.cssText = 'margin:0;font:500 14px system-ui,sans-serif;color:#111;';
|
|
192
|
+
const closeButton = document.createElement('button');
|
|
193
|
+
closeButton.type = 'button';
|
|
194
|
+
closeButton.textContent = 'Close';
|
|
195
|
+
closeButton.style.cssText =
|
|
196
|
+
'padding:8px 16px;border-radius:6px;border:1px solid #d1d5db;background:#fff;' +
|
|
197
|
+
'font:500 14px system-ui,sans-serif;cursor:pointer;';
|
|
198
|
+
closeButton.addEventListener('click', () => overlay.remove());
|
|
199
|
+
card.append(caption, closeButton);
|
|
200
|
+
overlay.appendChild(card);
|
|
201
|
+
document.body.appendChild(overlay);
|
|
202
|
+
}
|
|
203
|
+
function toPeableError(error) {
|
|
204
|
+
if (error instanceof PeableError)
|
|
205
|
+
return error;
|
|
206
|
+
return new PeableApiError(error instanceof Error ? error.message : String(error));
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Render the "Pay with Peable" button into `target` and start tracking the
|
|
210
|
+
* payment intent. Returns synchronously — before any network call resolves —
|
|
211
|
+
* so a caller's `.on(...)` registrations (called immediately after `mount()`
|
|
212
|
+
* returns, in the same synchronous tick) are always in place before the
|
|
213
|
+
* first `settled`/`confirming`/`failed`/`error` event can fire on the
|
|
214
|
+
* following microtask.
|
|
215
|
+
*/
|
|
216
|
+
function mount(target, opts) {
|
|
217
|
+
const element = resolveTarget(target);
|
|
218
|
+
const intentId = deriveIntentId(opts.clientSecret, opts.intentId);
|
|
219
|
+
const clientSecret = opts.clientSecret;
|
|
220
|
+
const emitter = createEmitter();
|
|
221
|
+
const payerClient = createPeableCheckout({ gatewayUrl: opts.gatewayUrl });
|
|
222
|
+
const button = renderButton(element);
|
|
223
|
+
let latestIntent = null;
|
|
224
|
+
let subscription = null;
|
|
225
|
+
let destroyed = false;
|
|
226
|
+
function handleIntentUpdate(intent) {
|
|
227
|
+
latestIntent = intent;
|
|
228
|
+
updateButton(button, intent);
|
|
229
|
+
if (intent.status === 'settled') {
|
|
230
|
+
emitter.emit('settled', intent);
|
|
231
|
+
}
|
|
232
|
+
else if (intent.status === 'confirming') {
|
|
233
|
+
emitter.emit('confirming', intent);
|
|
234
|
+
}
|
|
235
|
+
else if (intent.status === 'failed' ||
|
|
236
|
+
intent.status === 'expired' ||
|
|
237
|
+
intent.status === 'rejected') {
|
|
238
|
+
emitter.emit('failed', intent);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
button.addEventListener('click', () => {
|
|
242
|
+
if (!latestIntent)
|
|
243
|
+
return; // initial snapshot hasn't loaded yet
|
|
244
|
+
const deepLink = buildPayDeepLink({
|
|
245
|
+
intentId,
|
|
246
|
+
clientSecret,
|
|
247
|
+
address: latestIntent.address,
|
|
248
|
+
amount: latestIntent.amount,
|
|
249
|
+
network: latestIntent.network,
|
|
250
|
+
});
|
|
251
|
+
if (isMobileUserAgent(navigator.userAgent)) {
|
|
252
|
+
window.location.href = deepLink;
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
showQrOverlay(deepLink);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
// `destroyed` closes a race a caller CAN legitimately hit: calling
|
|
259
|
+
// `destroy()` while `getPaymentIntent`/`subscribe` is still in flight (e.g.
|
|
260
|
+
// the host page unmounts the widget immediately). Without this guard, a
|
|
261
|
+
// subscription created AFTER `destroy()` ran would overwrite `subscription`
|
|
262
|
+
// (still `null` at destroy time) and never get torn down — a leaked socket
|
|
263
|
+
// connection for the rest of the tab's life.
|
|
264
|
+
void payerClient
|
|
265
|
+
.getPaymentIntent(intentId, clientSecret)
|
|
266
|
+
.then((intent) => {
|
|
267
|
+
if (destroyed)
|
|
268
|
+
return null;
|
|
269
|
+
handleIntentUpdate(intent);
|
|
270
|
+
return payerClient.subscribe(intentId, clientSecret, handleIntentUpdate);
|
|
271
|
+
})
|
|
272
|
+
.then((sub) => {
|
|
273
|
+
if (!sub)
|
|
274
|
+
return;
|
|
275
|
+
if (destroyed) {
|
|
276
|
+
sub.unsubscribe();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
subscription = sub;
|
|
280
|
+
})
|
|
281
|
+
.catch((error) => {
|
|
282
|
+
if (destroyed)
|
|
283
|
+
return;
|
|
284
|
+
emitter.emit('error', toPeableError(error));
|
|
285
|
+
});
|
|
286
|
+
return {
|
|
287
|
+
on(event, handler) {
|
|
288
|
+
emitter.on(event, handler);
|
|
289
|
+
},
|
|
290
|
+
destroy() {
|
|
291
|
+
destroyed = true;
|
|
292
|
+
subscription?.unsubscribe();
|
|
293
|
+
emitter.removeAll();
|
|
294
|
+
button.remove();
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
export const PeableCheckout = { mount };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The payer-side REST+socket client core — the "./checkout" entry's payer
|
|
2
|
+
// capability (Fase 2 SDK plan, Task 5). Mirrors the wallet's
|
|
3
|
+
// `gateway-client.ts`/`gateway-socket.ts` but standalone: no
|
|
4
|
+
// `@oxyhq/services`/`@oxyhq/core` — this is a third-party browser bundle, so
|
|
5
|
+
// it talks to the Gateway over bare `fetch` + `socket.io-client`.
|
|
6
|
+
//
|
|
7
|
+
// Holds NO service token and NO private key — only a public `client_secret`,
|
|
8
|
+
// the exact payer capability the wallet's own payer path already uses
|
|
9
|
+
// (non-custody invariant). `core/errors.ts` is the one `./core/*` import: it
|
|
10
|
+
// is a pure status-code→`PeableError` mapper with no service-token/secret
|
|
11
|
+
// machinery, so reusing it here (instead of throwing bare `Error`) does not
|
|
12
|
+
// carry any of the merchant-authed code `checkout.ts` warns against into the
|
|
13
|
+
// browser bundle.
|
|
14
|
+
import { io } from 'socket.io-client';
|
|
15
|
+
import { errorFromResponse, PeableApiError, PeableInvalidRequestError } from '../core/errors.js';
|
|
16
|
+
const DEFAULT_GATEWAY_URL = 'https://api.peable.to';
|
|
17
|
+
async function readJsonBody(response) {
|
|
18
|
+
const text = await response.text();
|
|
19
|
+
if (text.length === 0)
|
|
20
|
+
return undefined;
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(text);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Factory for the payer-side client core.
|
|
30
|
+
*/
|
|
31
|
+
export function createPeableCheckout(opts = {}) {
|
|
32
|
+
const baseUrl = (opts.gatewayUrl ?? DEFAULT_GATEWAY_URL).replace(/\/$/, '');
|
|
33
|
+
async function getPaymentIntent(id, clientSecret) {
|
|
34
|
+
let response;
|
|
35
|
+
try {
|
|
36
|
+
response = await fetch(`${baseUrl}/v1/payment_intents/${encodeURIComponent(id)}`, {
|
|
37
|
+
headers: { 'X-Peable-Client-Secret': clientSecret },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (cause) {
|
|
41
|
+
throw new PeableApiError(`Failed to reach the Peable Gateway at ${baseUrl}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
42
|
+
}
|
|
43
|
+
const body = await readJsonBody(response);
|
|
44
|
+
if (!response.ok)
|
|
45
|
+
throw errorFromResponse(response.status, body);
|
|
46
|
+
return body;
|
|
47
|
+
}
|
|
48
|
+
async function submitTx(id, clientSecret, txid) {
|
|
49
|
+
let response;
|
|
50
|
+
try {
|
|
51
|
+
response = await fetch(`${baseUrl}/v1/payment_intents/${encodeURIComponent(id)}/submit_tx`, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: { 'Content-Type': 'application/json' },
|
|
54
|
+
body: JSON.stringify({ client_secret: clientSecret, txid }),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
catch (cause) {
|
|
58
|
+
throw new PeableApiError(`Failed to reach the Peable Gateway at ${baseUrl}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
59
|
+
}
|
|
60
|
+
const body = await readJsonBody(response);
|
|
61
|
+
if (!response.ok)
|
|
62
|
+
throw errorFromResponse(response.status, body);
|
|
63
|
+
return body;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Opens its OWN socket per call (never a shared/module-level connection —
|
|
67
|
+
* this SDK has no session to key a singleton off) and connects with NO
|
|
68
|
+
* handshake auth token: the Gateway's connection auth is identity-optional
|
|
69
|
+
* (`optionalSocketAuth`, `packages/backend/src/realtime/socket.ts`) exactly
|
|
70
|
+
* so an anonymous checkout/embed payer can reach it. `subscribe`'s ack is
|
|
71
|
+
* the real authorization check — it requires this intent's `client_secret`
|
|
72
|
+
* — so nothing here needs an Oxy session.
|
|
73
|
+
*/
|
|
74
|
+
async function subscribe(id, clientSecret, onUpdate) {
|
|
75
|
+
const socket = io(baseUrl, {
|
|
76
|
+
transports: ['websocket'],
|
|
77
|
+
});
|
|
78
|
+
let ack;
|
|
79
|
+
try {
|
|
80
|
+
ack = await socket.emitWithAck('subscribe', { intentId: id, clientSecret });
|
|
81
|
+
}
|
|
82
|
+
catch (cause) {
|
|
83
|
+
socket.disconnect();
|
|
84
|
+
throw new PeableApiError(`Failed to reach the Peable Gateway realtime layer at ${baseUrl}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
85
|
+
}
|
|
86
|
+
if (!ack.ok) {
|
|
87
|
+
socket.disconnect();
|
|
88
|
+
// The Gateway's `subscribe` handler returns this SAME {ok:false} for
|
|
89
|
+
// an unknown intent AND a wrong client_secret (anti-enumeration — see
|
|
90
|
+
// `realtime/socket.ts`), so this is deliberately InvalidRequestError,
|
|
91
|
+
// not PermissionError: there is no signal here to tell the two apart.
|
|
92
|
+
throw new PeableInvalidRequestError(`Gateway rejected subscription to payment intent ${id} ` +
|
|
93
|
+
'(unknown payment intent or invalid client_secret)');
|
|
94
|
+
}
|
|
95
|
+
// The socket only ever carries this one subscription, but filter by id
|
|
96
|
+
// anyway — matching the wallet's per-intent filter exactly means a future
|
|
97
|
+
// change there (e.g. a shared connection) can't silently leak another
|
|
98
|
+
// intent's updates into this caller's `onUpdate`.
|
|
99
|
+
const listener = (intent) => {
|
|
100
|
+
if (intent.id === id) {
|
|
101
|
+
onUpdate(intent);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
socket.on('intent.updated', listener);
|
|
105
|
+
// socket.io-client's default reconnection restores the TRANSPORT, but the
|
|
106
|
+
// server spins up a fresh Socket with no room membership — so every
|
|
107
|
+
// reconnect (mobile network blip, backgrounded tab, LB cycle) must re-run
|
|
108
|
+
// the room-join handshake or `intent.updated` silently stops arriving for
|
|
109
|
+
// the rest of the subscription's life. A hosted checkout tab can sit open
|
|
110
|
+
// unattended for minutes while a payment settles, with no app-lifecycle
|
|
111
|
+
// trigger to notice and recover — unlike the wallet, this can't rely on
|
|
112
|
+
// the user reopening the screen. `reconnect` is a Manager-level event
|
|
113
|
+
// (`socket.io`, not `socket` itself) in socket.io-client.
|
|
114
|
+
const onReconnect = () => {
|
|
115
|
+
void socket.emitWithAck('subscribe', { intentId: id, clientSecret }).catch(() => {
|
|
116
|
+
// Fire-and-forget: `onUpdate` has no error channel to surface a
|
|
117
|
+
// failed resubscribe (e.g. the intent expired between drop and
|
|
118
|
+
// reconnect) — socket.io keeps retrying the transport regardless, so
|
|
119
|
+
// there is nothing actionable to do with this rejection here.
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
socket.io.on('reconnect', onReconnect);
|
|
123
|
+
return {
|
|
124
|
+
unsubscribe() {
|
|
125
|
+
socket.off('intent.updated', listener);
|
|
126
|
+
socket.io.off('reconnect', onReconnect);
|
|
127
|
+
socket.disconnect();
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
return { getPaymentIntent, subscribe, submitTx };
|
|
132
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Browser entry (`@peable.to/sdk/checkout`) — the anonymous payer-side client
|
|
2
|
+
// core (Task 5) plus the embeddable pay-button widget (Task 6). Deliberately
|
|
3
|
+
// imports nothing from `./core/*` beyond `core/errors.ts` (a pure
|
|
4
|
+
// status-mapper with no service-token/secret machinery): the browser bundle
|
|
5
|
+
// must never carry the merchant-authed code or a path to a secret.
|
|
6
|
+
//
|
|
7
|
+
// `packages/checkout` (the hosted checkout page, a separate package) can
|
|
8
|
+
// still consume `createPeableCheckout` directly instead of `PeableCheckout`
|
|
9
|
+
// when it wants to build its own UI over the payer client core (see the
|
|
10
|
+
// checkout+links plan's `intentClient.ts`).
|
|
11
|
+
export { createPeableCheckout, } from './browser/payerClient.js';
|
|
12
|
+
export { PeableCheckout, } from './browser/mount.js';
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// `RestClient` — the thin HTTP layer every resource namespace calls through.
|
|
2
|
+
// Prefixes `baseURL`, attaches the bearer service token, sets
|
|
3
|
+
// `Idempotency-Key` when given, serializes query params, parses JSON, and
|
|
4
|
+
// maps every non-2xx response via `errors.ts`.
|
|
5
|
+
import { errorFromResponse, PeableApiError } from './errors.js';
|
|
6
|
+
const HTTP_UNAUTHORIZED = 401;
|
|
7
|
+
function buildUrl(baseURL, path, query) {
|
|
8
|
+
const search = new URLSearchParams();
|
|
9
|
+
if (query) {
|
|
10
|
+
for (const [key, value] of Object.entries(query)) {
|
|
11
|
+
if (value === undefined)
|
|
12
|
+
continue;
|
|
13
|
+
search.set(key, String(value));
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const queryString = search.toString();
|
|
17
|
+
return `${baseURL}${path}${queryString ? `?${queryString}` : ''}`;
|
|
18
|
+
}
|
|
19
|
+
async function readJsonBody(response) {
|
|
20
|
+
const text = await response.text();
|
|
21
|
+
if (text.length === 0)
|
|
22
|
+
return undefined;
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(text);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function createRestClient(config, tokenProvider, deps = {}) {
|
|
31
|
+
const fetchImpl = deps.fetch ?? fetch;
|
|
32
|
+
async function performRequest(method, path, opts, allowRetryOn401) {
|
|
33
|
+
const token = await tokenProvider.getToken();
|
|
34
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
35
|
+
if (opts.body !== undefined)
|
|
36
|
+
headers['Content-Type'] = 'application/json';
|
|
37
|
+
if (opts.idempotencyKey !== undefined)
|
|
38
|
+
headers['Idempotency-Key'] = opts.idempotencyKey;
|
|
39
|
+
if (opts.headers)
|
|
40
|
+
Object.assign(headers, opts.headers);
|
|
41
|
+
let response;
|
|
42
|
+
try {
|
|
43
|
+
response = await fetchImpl(buildUrl(config.baseURL, path, opts.query), {
|
|
44
|
+
method,
|
|
45
|
+
headers,
|
|
46
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch (cause) {
|
|
50
|
+
throw new PeableApiError(`Failed to reach the Peable Gateway at ${config.baseURL}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
51
|
+
}
|
|
52
|
+
// A 401 here means the cached service token expired or was revoked
|
|
53
|
+
// mid-flight (it was valid when minted, or a prior request would have
|
|
54
|
+
// failed at `tokenProvider.getToken()` already). Invalidate the cache and
|
|
55
|
+
// retry exactly once with a freshly minted token before giving up.
|
|
56
|
+
if (response.status === HTTP_UNAUTHORIZED && allowRetryOn401) {
|
|
57
|
+
tokenProvider.invalidate();
|
|
58
|
+
return performRequest(method, path, opts, false);
|
|
59
|
+
}
|
|
60
|
+
// The Gateway echoes a date-based `Peable-Version` response header
|
|
61
|
+
// (Stripe parity). No SDK behavior depends on it yet, so it is
|
|
62
|
+
// deliberately not read here — nothing to do with it today.
|
|
63
|
+
const body = await readJsonBody(response);
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
throw errorFromResponse(response.status, body);
|
|
66
|
+
}
|
|
67
|
+
return body;
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
request(method, path, opts = {}) {
|
|
71
|
+
return performRequest(method, path, opts, true);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Configuration for the server-side `Peable` client. The SDK is configured
|
|
2
|
+
// with a confidential `ApplicationCredential` (`{publicKey, secret}`, the
|
|
3
|
+
// SAME credential Oxy Console already issues for a `type:'service'`
|
|
4
|
+
// credential) — never a bespoke "Peable API key". See the SDK plan's
|
|
5
|
+
// "Architecture / auth" section.
|
|
6
|
+
/** Default Gateway host (`api.peable.to`) — the SDK never sends a `livemode`
|
|
7
|
+
* flag; the credential's `environment` rides in the minted service JWT and
|
|
8
|
+
* the Gateway resolves test/live from it. */
|
|
9
|
+
export const DEFAULT_GATEWAY_BASE_URL = 'https://api.peable.to';
|
|
10
|
+
/** Default oxy-api host used to mint the short-lived service token. */
|
|
11
|
+
export const DEFAULT_OXY_API_URL = 'https://api.oxy.so';
|
|
12
|
+
function stripTrailingSlash(url) {
|
|
13
|
+
return url.endsWith('/') ? url.slice(0, -1) : url;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Validate and normalize an `PeableConfig`. Throws a plain `Error` for a
|
|
17
|
+
* missing `publicKey`/`secret` — a programmer error caught at construction
|
|
18
|
+
* time, not an API-response failure, so it deliberately does not go through
|
|
19
|
+
* the `PeableError` hierarchy (which models mapped HTTP responses).
|
|
20
|
+
*/
|
|
21
|
+
export function resolveConfig(config) {
|
|
22
|
+
if (!config.publicKey) {
|
|
23
|
+
throw new Error('Peable: `publicKey` is required');
|
|
24
|
+
}
|
|
25
|
+
if (!config.secret) {
|
|
26
|
+
throw new Error('Peable: `secret` is required');
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
publicKey: config.publicKey,
|
|
30
|
+
secret: config.secret,
|
|
31
|
+
baseURL: stripTrailingSlash(config.baseURL ?? DEFAULT_GATEWAY_BASE_URL),
|
|
32
|
+
oxyApiUrl: stripTrailingSlash(config.oxyApiUrl ?? DEFAULT_OXY_API_URL),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The `PeableError` hierarchy — every non-2xx Gateway/oxy-api response is
|
|
2
|
+
// mapped into one of these, never a bare `Error` or a raw `Response`.
|
|
3
|
+
export class PeableError extends Error {
|
|
4
|
+
constructor(type, message, details = {}) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'PeableError';
|
|
7
|
+
this.type = type;
|
|
8
|
+
this.statusCode = details.statusCode;
|
|
9
|
+
this.code = details.code;
|
|
10
|
+
Object.setPrototypeOf(this, PeableError.prototype);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class PeableAuthenticationError extends PeableError {
|
|
14
|
+
constructor(message, details = {}) {
|
|
15
|
+
super('authentication_error', message, details);
|
|
16
|
+
this.name = 'PeableAuthenticationError';
|
|
17
|
+
Object.setPrototypeOf(this, PeableAuthenticationError.prototype);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class PeableInvalidRequestError extends PeableError {
|
|
21
|
+
constructor(message, details = {}) {
|
|
22
|
+
super('invalid_request_error', message, details);
|
|
23
|
+
this.name = 'PeableInvalidRequestError';
|
|
24
|
+
Object.setPrototypeOf(this, PeableInvalidRequestError.prototype);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export class PeablePermissionError extends PeableError {
|
|
28
|
+
constructor(message, details = {}) {
|
|
29
|
+
super('permission_error', message, details);
|
|
30
|
+
this.name = 'PeablePermissionError';
|
|
31
|
+
Object.setPrototypeOf(this, PeablePermissionError.prototype);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export class PeableApiError extends PeableError {
|
|
35
|
+
constructor(message, details = {}) {
|
|
36
|
+
super('api_error', message, details);
|
|
37
|
+
this.name = 'PeableApiError';
|
|
38
|
+
Object.setPrototypeOf(this, PeableApiError.prototype);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Thrown by `webhooks.constructEvent` on a bad/stale/tampered signature. */
|
|
42
|
+
export class PeableSignatureVerificationError extends PeableError {
|
|
43
|
+
constructor(message) {
|
|
44
|
+
super('signature_verification_error', message);
|
|
45
|
+
this.name = 'PeableSignatureVerificationError';
|
|
46
|
+
Object.setPrototypeOf(this, PeableSignatureVerificationError.prototype);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function isNestedGatewayErrorBody(body) {
|
|
50
|
+
if (typeof body !== 'object' || body === null || !('error' in body))
|
|
51
|
+
return false;
|
|
52
|
+
const { error } = body;
|
|
53
|
+
if (typeof error !== 'object' || error === null)
|
|
54
|
+
return false;
|
|
55
|
+
const nested = error;
|
|
56
|
+
return typeof nested.type === 'string' && typeof nested.message === 'string';
|
|
57
|
+
}
|
|
58
|
+
function isFlatGatewayErrorBody(body) {
|
|
59
|
+
if (typeof body !== 'object' || body === null || !('error' in body))
|
|
60
|
+
return false;
|
|
61
|
+
const { error } = body;
|
|
62
|
+
return typeof error === 'string';
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Map an HTTP status + parsed JSON body into a typed `PeableError`.
|
|
66
|
+
*
|
|
67
|
+
* The Oxy ecosystem returns TWO distinct error envelopes depending on WHERE a
|
|
68
|
+
* request was rejected:
|
|
69
|
+
* - The Gateway's own route handlers use the Stripe-style nested envelope
|
|
70
|
+
* `{ error: { type, message } }` (`lib/http.ts`'s `sendError`).
|
|
71
|
+
* - `@oxyhq/core`'s Express auth middleware (`oxyClient.serviceAuth()` /
|
|
72
|
+
* `requireScope()` — rejecting a missing/expired/insufficiently-scoped
|
|
73
|
+
* service token BEFORE a route handler ever runs) and oxy-api's own
|
|
74
|
+
* `ApiError.toJSON()` use a FLAT envelope: `{ error: <CODE_STRING>,
|
|
75
|
+
* message, code? }`.
|
|
76
|
+
*
|
|
77
|
+
* A caller of this SDK hits the flat shape on every auth failure (expired
|
|
78
|
+
* token, missing scope, bad credentials at mint time), so the HTTP status
|
|
79
|
+
* code — not `error.type` — is the primary classifier here; the raw
|
|
80
|
+
* upstream `type`/`code` string is still attached to the resulting error
|
|
81
|
+
* (`.code`) for callers who want it.
|
|
82
|
+
*/
|
|
83
|
+
export function errorFromResponse(status, body) {
|
|
84
|
+
let upstreamCode;
|
|
85
|
+
let message = `Peable request failed with status ${status}`;
|
|
86
|
+
if (isNestedGatewayErrorBody(body)) {
|
|
87
|
+
upstreamCode = body.error.type;
|
|
88
|
+
message = body.error.message;
|
|
89
|
+
}
|
|
90
|
+
else if (isFlatGatewayErrorBody(body)) {
|
|
91
|
+
upstreamCode = body.error;
|
|
92
|
+
message = body.message || message;
|
|
93
|
+
}
|
|
94
|
+
const details = { statusCode: status, code: upstreamCode };
|
|
95
|
+
if (status === 401)
|
|
96
|
+
return new PeableAuthenticationError(message, details);
|
|
97
|
+
if (status === 403)
|
|
98
|
+
return new PeablePermissionError(message, details);
|
|
99
|
+
if (status >= 400 && status < 500)
|
|
100
|
+
return new PeableInvalidRequestError(message, details);
|
|
101
|
+
return new PeableApiError(message, details);
|
|
102
|
+
}
|