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