@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.
Files changed (37) hide show
  1. package/dist/cjs/browser/mount.js +308 -0
  2. package/dist/cjs/browser/payerClient.js +135 -0
  3. package/dist/cjs/checkout.js +17 -0
  4. package/dist/cjs/core/client.js +77 -0
  5. package/dist/cjs/core/config.js +38 -0
  6. package/dist/cjs/core/errors.js +112 -0
  7. package/dist/cjs/core/serviceToken.js +81 -0
  8. package/dist/cjs/index.js +41 -0
  9. package/dist/cjs/resources/checkoutSessions.js +26 -0
  10. package/dist/cjs/resources/paymentIntents.js +30 -0
  11. package/dist/cjs/resources/paymentLinks.js +23 -0
  12. package/dist/cjs/resources/webhooks.js +65 -0
  13. package/dist/esm/browser/mount.js +298 -0
  14. package/dist/esm/browser/payerClient.js +132 -0
  15. package/dist/esm/checkout.js +12 -0
  16. package/dist/esm/core/client.js +74 -0
  17. package/dist/esm/core/config.js +34 -0
  18. package/dist/esm/core/errors.js +102 -0
  19. package/dist/esm/core/serviceToken.js +78 -0
  20. package/dist/esm/index.js +28 -0
  21. package/dist/esm/resources/checkoutSessions.js +21 -0
  22. package/dist/esm/resources/paymentIntents.js +26 -0
  23. package/dist/esm/resources/paymentLinks.js +19 -0
  24. package/dist/esm/resources/webhooks.js +61 -0
  25. package/dist/types/browser/mount.d.ts +80 -0
  26. package/dist/types/browser/payerClient.d.ts +23 -0
  27. package/dist/types/checkout.d.ts +2 -0
  28. package/dist/types/core/client.d.ts +17 -0
  29. package/dist/types/core/config.d.ts +30 -0
  30. package/dist/types/core/errors.d.ts +54 -0
  31. package/dist/types/core/serviceToken.d.ts +11 -0
  32. package/dist/types/index.d.ts +22 -0
  33. package/dist/types/resources/checkoutSessions.d.ts +15 -0
  34. package/dist/types/resources/paymentIntents.d.ts +29 -0
  35. package/dist/types/resources/paymentLinks.d.ts +29 -0
  36. package/dist/types/resources/webhooks.d.ts +18 -0
  37. 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
+ }