@relume_io/partner-node 0.0.0-stage → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1041 @@
1
+ import {
2
+ EMBED_HEADER,
3
+ EMBED_HEADER_VALUE,
4
+ MAX_BODY_BYTES,
5
+ MAX_SIGNATURES,
6
+ REQUEST_ID_HEADER,
7
+ SIGNATURE_HEADER,
8
+ SIGNATURE_TOLERANCE_SECONDS,
9
+ absent,
10
+ computeSignature,
11
+ errorResponse,
12
+ isEmail,
13
+ isHttpsUrl,
14
+ isRecord,
15
+ isString,
16
+ isWhole,
17
+ jsonResponse,
18
+ methodNotAllowed,
19
+ mountedRouteOf,
20
+ parseSignatureHeader,
21
+ payloadTooLarge,
22
+ preflight,
23
+ refuseSessionRequest,
24
+ routeOf,
25
+ signCall,
26
+ verifySignature
27
+ } from "./chunk-HPVVBUR7.js";
28
+
29
+ // src/adapters/express.ts
30
+ var PayloadTooLargeError = class extends Error {
31
+ };
32
+ var BodyAlreadyReadError = class extends Error {
33
+ constructor() {
34
+ super(
35
+ "The request body was already read by a body parser, so the Relume signature can't be checked. Mount relume.express() before express.json(), or give it express.raw({ type: 'application/json' })."
36
+ );
37
+ this.name = "BodyAlreadyReadError";
38
+ }
39
+ };
40
+ function header(request, name) {
41
+ const value = request.headers[name.toLowerCase()];
42
+ if (Array.isArray(value)) return value[0] ?? null;
43
+ return value ?? null;
44
+ }
45
+ async function readRawBody(request, maxBodyBytes) {
46
+ if (request.body instanceof Uint8Array) return request.body;
47
+ if (typeof request.body === "string") return request.body;
48
+ if (request.readableEnded) throw new BodyAlreadyReadError();
49
+ if (Number(header(request, "content-length")) > maxBodyBytes) {
50
+ throw new PayloadTooLargeError();
51
+ }
52
+ return new Promise((resolve, reject) => {
53
+ const chunks = [];
54
+ let size = 0;
55
+ const cleanup = () => {
56
+ request.off("data", onData);
57
+ request.off("end", onEnd);
58
+ request.off("error", onError);
59
+ };
60
+ const onData = (chunk) => {
61
+ const bytes = typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk;
62
+ size += bytes.byteLength;
63
+ if (size > maxBodyBytes) {
64
+ cleanup();
65
+ chunks.length = 0;
66
+ request.resume();
67
+ reject(new PayloadTooLargeError());
68
+ return;
69
+ }
70
+ chunks.push(bytes);
71
+ };
72
+ const onEnd = () => {
73
+ cleanup();
74
+ const body = new Uint8Array(size);
75
+ let offset = 0;
76
+ for (const chunk of chunks) {
77
+ body.set(chunk, offset);
78
+ offset += chunk.byteLength;
79
+ }
80
+ resolve(body);
81
+ };
82
+ const onError = (error) => {
83
+ cleanup();
84
+ reject(error);
85
+ };
86
+ request.on("data", onData);
87
+ request.on("end", onEnd);
88
+ request.on("error", onError);
89
+ });
90
+ }
91
+ function send(request, response, result) {
92
+ if (response.headersSent) return;
93
+ response.statusCode = result.status;
94
+ for (const [name, value] of Object.entries(result.headers)) {
95
+ response.setHeader(name, value);
96
+ }
97
+ response.end(
98
+ request.method === "HEAD" || result.status === 204 ? void 0 : JSON.stringify(result.body)
99
+ );
100
+ }
101
+ function expressHandler(client, maxBodyBytes, onError) {
102
+ return (request, response, next) => {
103
+ const route = mountedRouteOf(
104
+ new URL(request.url ?? "/", "http://localhost").pathname,
105
+ Boolean(request.baseUrl)
106
+ );
107
+ if (!route) {
108
+ if (next) next();
109
+ else
110
+ send(request, response, errorResponse(404, "not_found", "Not found."));
111
+ return;
112
+ }
113
+ if (request.method === "OPTIONS") {
114
+ if (next) next();
115
+ else send(request, response, preflight(route));
116
+ return;
117
+ }
118
+ const wrongMethod = methodNotAllowed(route, request.method);
119
+ if (wrongMethod) {
120
+ send(request, response, wrongMethod);
121
+ return;
122
+ }
123
+ const run = async () => {
124
+ if (route === "session") return client.handleSession(request);
125
+ let rawBody;
126
+ try {
127
+ rawBody = await readRawBody(request, maxBodyBytes);
128
+ } catch (error) {
129
+ if (error instanceof PayloadTooLargeError) return payloadTooLarge();
130
+ if (error instanceof BodyAlreadyReadError) {
131
+ onError(error, route);
132
+ return errorResponse(
133
+ 500,
134
+ "internal_error",
135
+ "The partner failed to handle the call."
136
+ );
137
+ }
138
+ throw error;
139
+ }
140
+ return client.handleCall({
141
+ rawBody,
142
+ signatureHeader: header(request, SIGNATURE_HEADER),
143
+ requestId: header(request, REQUEST_ID_HEADER)
144
+ });
145
+ };
146
+ run().then(
147
+ (result) => send(request, response, result),
148
+ (error) => {
149
+ if (next) next(error);
150
+ else {
151
+ onError(error, route);
152
+ send(
153
+ request,
154
+ response,
155
+ errorResponse(
156
+ 500,
157
+ "internal_error",
158
+ "The partner failed to handle the call."
159
+ )
160
+ );
161
+ }
162
+ }
163
+ );
164
+ };
165
+ }
166
+
167
+ // src/adapters/fetch.ts
168
+ function toResponse(response, head) {
169
+ const empty = head || response.status === 204;
170
+ return new Response(empty ? null : JSON.stringify(response.body), {
171
+ status: response.status,
172
+ headers: response.headers
173
+ });
174
+ }
175
+ async function readCapped(request, maxBodyBytes) {
176
+ if (Number(request.headers.get("content-length")) > maxBodyBytes) {
177
+ return null;
178
+ }
179
+ if (!request.body) return new Uint8Array(0);
180
+ const reader = request.body.getReader();
181
+ const chunks = [];
182
+ let size = 0;
183
+ for (; ; ) {
184
+ const { done, value } = await reader.read();
185
+ if (done) break;
186
+ size += value.byteLength;
187
+ if (size > maxBodyBytes) {
188
+ reader.releaseLock();
189
+ return null;
190
+ }
191
+ chunks.push(value);
192
+ }
193
+ const body = new Uint8Array(size);
194
+ let offset = 0;
195
+ for (const chunk of chunks) {
196
+ body.set(chunk, offset);
197
+ offset += chunk.byteLength;
198
+ }
199
+ return body;
200
+ }
201
+ function fetchHandler(client, maxBodyBytes) {
202
+ return async (request) => {
203
+ const head = request.method === "HEAD";
204
+ const route = routeOf(new URL(request.url).pathname);
205
+ if (!route) {
206
+ return toResponse(errorResponse(404, "not_found", "Not found."), head);
207
+ }
208
+ if (request.method === "OPTIONS") return toResponse(preflight(route), true);
209
+ const wrongMethod = methodNotAllowed(route, request.method);
210
+ if (wrongMethod) return toResponse(wrongMethod, head);
211
+ if (route === "session") {
212
+ return toResponse(await client.handleSession(request), head);
213
+ }
214
+ const rawBody = await readCapped(request, maxBodyBytes);
215
+ if (rawBody === null) return toResponse(payloadTooLarge(), false);
216
+ return toResponse(
217
+ await client.handleCall({
218
+ rawBody,
219
+ signatureHeader: request.headers.get(SIGNATURE_HEADER),
220
+ requestId: request.headers.get(REQUEST_ID_HEADER)
221
+ }),
222
+ false
223
+ );
224
+ };
225
+ }
226
+
227
+ // src/api.ts
228
+ var DEFAULT_API_BASE_URL = "https://apis.relume.io/nova/partner/v1";
229
+ var SDK_VERSION = "2.0.0";
230
+ var USER_AGENT = `relume-partner-node/${SDK_VERSION}`;
231
+ var DEFAULT_API_TIMEOUT_MS = 15e3;
232
+ var DEFAULT_MAX_RETRIES = 2;
233
+ var RETRY_BASE_DELAY_MS = 500;
234
+ var RETRY_MAX_DELAY_MS = 8e3;
235
+ var MAX_RETRY_AFTER_MS = 6e4;
236
+ var NON_RETRYABLE_5XX = /* @__PURE__ */ new Set([501, 505]);
237
+ var RelumeError = class extends Error {
238
+ /** The HTTP status, or 0 when no response arrived. */
239
+ status;
240
+ /** The API's `error.code`, safe to switch on. */
241
+ code;
242
+ requestId;
243
+ constructor(input) {
244
+ super(input.message, { cause: input.cause });
245
+ this.name = "RelumeError";
246
+ this.status = input.status;
247
+ this.code = input.code;
248
+ this.requestId = input.requestId ?? null;
249
+ }
250
+ };
251
+ async function apiRequest(config, method, path, options = {}) {
252
+ const { body, query } = options;
253
+ let response;
254
+ try {
255
+ response = await config.fetch(
256
+ `${config.baseUrl}${path}${queryString(query)}`,
257
+ {
258
+ method,
259
+ headers: {
260
+ Authorization: `Bearer ${config.secretKey}`,
261
+ Accept: "application/json",
262
+ "User-Agent": USER_AGENT,
263
+ ...body === void 0 ? {} : { "Content-Type": "application/json" }
264
+ },
265
+ body: body === void 0 ? void 0 : JSON.stringify(body),
266
+ signal: AbortSignal.timeout(config.timeoutMs),
267
+ // Not 'error': Cloudflare Workers refuse it. A redirect is turned
268
+ // into a network error below instead.
269
+ redirect: "manual"
270
+ }
271
+ );
272
+ } catch (cause) {
273
+ const timedOut = cause instanceof Error && (cause.name === "TimeoutError" || cause.name === "AbortError");
274
+ throw new RelumeError({
275
+ status: 0,
276
+ code: timedOut ? "timeout" : "network_error",
277
+ message: timedOut ? `The Relume API didn't answer within ${config.timeoutMs}ms.` : "Couldn't reach the Relume API.",
278
+ cause
279
+ });
280
+ }
281
+ if (response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) {
282
+ const status = response.status === 0 ? "" : ` (${response.status})`;
283
+ throw new RedirectedError({
284
+ status: 0,
285
+ code: "network_error",
286
+ message: `The Relume API answered with a redirect${status}; check apiBaseUrl.`
287
+ });
288
+ }
289
+ const text = await response.text().catch(() => "");
290
+ let parsed = null;
291
+ if (text) {
292
+ try {
293
+ parsed = JSON.parse(text);
294
+ } catch {
295
+ parsed = null;
296
+ }
297
+ }
298
+ return {
299
+ status: response.status,
300
+ body: parsed,
301
+ requestId: response.headers.get(REQUEST_ID_HEADER),
302
+ retryAfterMs: parseRetryAfter(
303
+ response.headers.get("Retry-After"),
304
+ config.now()
305
+ )
306
+ };
307
+ }
308
+ function queryString(query) {
309
+ if (!query) return "";
310
+ const params = new URLSearchParams();
311
+ for (const [key, value] of Object.entries(query)) {
312
+ if (value !== void 0) params.set(key, String(value));
313
+ }
314
+ const encoded = params.toString();
315
+ return encoded ? `?${encoded}` : "";
316
+ }
317
+ function parseRetryAfter(header2, nowMs) {
318
+ if (header2 === null) return null;
319
+ const value = header2.trim();
320
+ if (/^\d+$/.test(value)) return Number(value) * 1e3;
321
+ const date = Date.parse(value);
322
+ return Number.isNaN(date) ? null : Math.max(0, date - nowMs);
323
+ }
324
+ function errorFromResponse(response) {
325
+ const error = isRecord(response.body) ? response.body.error : void 0;
326
+ const code = isRecord(error) ? error.code : void 0;
327
+ const message = isRecord(error) ? error.message : void 0;
328
+ return new RelumeError({
329
+ status: response.status,
330
+ code: typeof code === "string" ? code : "unexpected_response",
331
+ message: typeof message === "string" ? message : `The Relume API answered ${response.status}.`,
332
+ requestId: response.requestId
333
+ });
334
+ }
335
+ var RedirectedError = class extends RelumeError {
336
+ };
337
+ async function requestJson(config, spec) {
338
+ for (let attempt = 0; ; attempt += 1) {
339
+ const canRetry = attempt < config.maxRetries;
340
+ let response;
341
+ try {
342
+ response = await apiRequest(config, spec.method, spec.path, {
343
+ body: spec.body,
344
+ query: spec.query
345
+ });
346
+ } catch (error) {
347
+ const transient = !(error instanceof RedirectedError);
348
+ if (!canRetry || spec.retry !== "safe" || !transient) throw error;
349
+ await config.sleep(backoffMs(attempt));
350
+ continue;
351
+ }
352
+ if (response.status >= 200 && response.status < 300) {
353
+ if (spec.isResult(response.body)) return response.body;
354
+ throw new RelumeError({
355
+ status: response.status,
356
+ code: "unexpected_response",
357
+ message: `${spec.method} ${spec.path} answered ${response.status} without the expected body.`,
358
+ requestId: response.requestId
359
+ });
360
+ }
361
+ const retryable = response.status === 429 || spec.retry === "safe" && response.status >= 500 && !NON_RETRYABLE_5XX.has(response.status);
362
+ const delay = response.retryAfterMs === null ? backoffMs(attempt) : response.status === 429 ? response.retryAfterMs : Math.min(response.retryAfterMs, RETRY_MAX_DELAY_MS);
363
+ if (!canRetry || !retryable || delay > MAX_RETRY_AFTER_MS) {
364
+ throw errorFromResponse(response);
365
+ }
366
+ await config.sleep(delay);
367
+ }
368
+ }
369
+ function backoffMs(attempt) {
370
+ const ceiling = Math.min(
371
+ RETRY_MAX_DELAY_MS,
372
+ RETRY_BASE_DELAY_MS * 2 ** attempt
373
+ );
374
+ return Math.round(Math.random() * ceiling);
375
+ }
376
+ function isSession(value) {
377
+ return isRecord(value) && typeof value.clientSecret === "string" && typeof value.expiresAt === "string";
378
+ }
379
+
380
+ // src/calls.ts
381
+ var CALL_TYPES = [
382
+ "user.get",
383
+ "credits.get",
384
+ "credits.charge",
385
+ "credits.reverse"
386
+ ];
387
+ var InvalidCallbackResultError = class extends Error {
388
+ constructor(message) {
389
+ super(message);
390
+ this.name = "InvalidCallbackResultError";
391
+ }
392
+ };
393
+ function optionalString(value, max) {
394
+ if (value === void 0 || value === null) {
395
+ return { ok: true, value: void 0 };
396
+ }
397
+ return isString(value, 1, max) ? { ok: true, value } : { ok: false };
398
+ }
399
+ function isCallType(value) {
400
+ return CALL_TYPES.some((type) => type === value);
401
+ }
402
+ function parseCall(body) {
403
+ if (!isRecord(body)) {
404
+ return {
405
+ ok: false,
406
+ code: "invalid_request",
407
+ message: "The body must be a JSON object."
408
+ };
409
+ }
410
+ const { type } = body;
411
+ if (!isCallType(type)) {
412
+ return {
413
+ ok: false,
414
+ code: "unsupported_call_type",
415
+ message: `Unsupported call type: ${JSON.stringify(type ?? null).slice(0, 64)}.`
416
+ };
417
+ }
418
+ const invalid = (field) => ({
419
+ ok: false,
420
+ code: "invalid_request",
421
+ message: `Invalid ${field} for ${type}.`,
422
+ type
423
+ });
424
+ const { userId } = body;
425
+ if (!isString(userId, 1, 128)) return invalid("userId");
426
+ switch (type) {
427
+ case "user.get":
428
+ case "credits.get":
429
+ return { ok: true, call: { type, userId } };
430
+ case "credits.charge": {
431
+ const { chargeId, partnerCredits, relumeCredits, action, description } = body;
432
+ if (!isString(chargeId, 1, 64)) return invalid("chargeId");
433
+ if (!isWhole(partnerCredits, 1)) return invalid("partnerCredits");
434
+ if (!isWhole(relumeCredits, 0)) return invalid("relumeCredits");
435
+ if (!isString(action, 1, 200)) return invalid("action");
436
+ if (!isString(description, 0, 200)) return invalid("description");
437
+ const siteId = optionalString(body.siteId, 200);
438
+ if (!siteId.ok) return invalid("siteId");
439
+ const pageId = optionalString(body.pageId, 200);
440
+ if (!pageId.ok) return invalid("pageId");
441
+ const charge = {
442
+ userId,
443
+ chargeId,
444
+ partnerCredits,
445
+ relumeCredits,
446
+ action,
447
+ description
448
+ };
449
+ if (siteId.value !== void 0) charge.siteId = siteId.value;
450
+ if (pageId.value !== void 0) charge.pageId = pageId.value;
451
+ return { ok: true, call: { type, charge } };
452
+ }
453
+ case "credits.reverse": {
454
+ const { reversalId, partnerCredits, relumeCredits, reason } = body;
455
+ if (!isString(reversalId, 1, 64)) return invalid("reversalId");
456
+ if (!isWhole(partnerCredits, 1)) return invalid("partnerCredits");
457
+ if (!isWhole(relumeCredits, 0)) return invalid("relumeCredits");
458
+ if (!isString(reason, 1, 200)) return invalid("reason");
459
+ const chargeId = optionalString(body.chargeId, 64);
460
+ if (!chargeId.ok) return invalid("chargeId");
461
+ const reversal = {
462
+ userId,
463
+ reversalId,
464
+ partnerCredits,
465
+ relumeCredits,
466
+ reason
467
+ };
468
+ if (chargeId.value !== void 0) reversal.chargeId = chargeId.value;
469
+ return { ok: true, call: { type, reversal } };
470
+ }
471
+ }
472
+ }
473
+ function balanceOrThrow(value, callback) {
474
+ if (!isWhole(value, 0)) {
475
+ throw new InvalidCallbackResultError(
476
+ `${callback} must return a balance that is a whole number, zero or more; got ${JSON.stringify(value)}.`
477
+ );
478
+ }
479
+ return value;
480
+ }
481
+ function checkUserProfile(value) {
482
+ if (value === null || value === void 0) return null;
483
+ if (!isRecord(value)) {
484
+ throw new InvalidCallbackResultError(
485
+ "onGetUser must return { name?, email?, avatarUrl? } or null."
486
+ );
487
+ }
488
+ const profile = {};
489
+ const { name, email, avatarUrl } = value;
490
+ if (!absent(name)) {
491
+ if (!isString(name, 1, 200)) {
492
+ throw new InvalidCallbackResultError(
493
+ "onGetUser: name must be a string of at most 200 characters."
494
+ );
495
+ }
496
+ profile.name = name;
497
+ }
498
+ if (!absent(email)) {
499
+ if (!isEmail(email)) {
500
+ throw new InvalidCallbackResultError(
501
+ "onGetUser: email must be a valid email address."
502
+ );
503
+ }
504
+ profile.email = email;
505
+ }
506
+ if (!absent(avatarUrl)) {
507
+ if (!isHttpsUrl(avatarUrl)) {
508
+ throw new InvalidCallbackResultError(
509
+ "onGetUser: avatarUrl must be an https URL."
510
+ );
511
+ }
512
+ profile.avatarUrl = avatarUrl;
513
+ }
514
+ return profile;
515
+ }
516
+ function checkCredits(value) {
517
+ if (!isRecord(value)) {
518
+ throw new InvalidCallbackResultError(
519
+ "onGetCredits must return { balance }."
520
+ );
521
+ }
522
+ return { balance: balanceOrThrow(value.balance, "onGetCredits") };
523
+ }
524
+ function optionalBalance(value, callback) {
525
+ return absent(value.balance) ? {} : { balance: balanceOrThrow(value.balance, callback) };
526
+ }
527
+ function checkChargeResult(value) {
528
+ if (!isRecord(value) || typeof value.ok !== "boolean") {
529
+ throw new InvalidCallbackResultError(
530
+ "onChargeCredits must return { ok: boolean, balance? }."
531
+ );
532
+ }
533
+ return { ok: value.ok, ...optionalBalance(value, "onChargeCredits") };
534
+ }
535
+ function checkReversalResult(value) {
536
+ if (value === void 0 || value === null) return {};
537
+ if (!isRecord(value)) {
538
+ throw new InvalidCallbackResultError(
539
+ "onReverseCharge must return { balance? } or nothing."
540
+ );
541
+ }
542
+ return optionalBalance(value, "onReverseCharge");
543
+ }
544
+
545
+ // src/pages.ts
546
+ function isPage(value) {
547
+ return isRecord(value) && Array.isArray(value.data) && typeof value.nextCursor === "string" && typeof value.hasMore === "boolean";
548
+ }
549
+ var PagedList = class {
550
+ #fetchPage;
551
+ #after;
552
+ #nextCursor;
553
+ constructor(fetchPage, after) {
554
+ this.#fetchPage = fetchPage;
555
+ this.#after = after;
556
+ this.#nextCursor = after ?? null;
557
+ }
558
+ /**
559
+ * Where to resume: the `nextCursor` of the last page whose items have all
560
+ * been handed out, or the starting `after` (null when there was none)
561
+ * before that. Once iteration ends, store it and pass it as `after` later
562
+ * to get only what is new (on the ledger; site cursors don't poll).
563
+ */
564
+ get nextCursor() {
565
+ return this.#nextCursor;
566
+ }
567
+ /** Every page in turn. The cursor advances when you ask for the next one. */
568
+ async *pages() {
569
+ let after = this.#after;
570
+ for (; ; ) {
571
+ const page = await this.#fetchPage(after);
572
+ yield page;
573
+ this.#nextCursor = page.nextCursor;
574
+ if (!page.hasMore) return;
575
+ if (page.nextCursor === after) {
576
+ throw new RelumeError({
577
+ status: 200,
578
+ code: "unexpected_response",
579
+ message: "The Relume API returned hasMore with the same cursor."
580
+ });
581
+ }
582
+ after = page.nextCursor;
583
+ }
584
+ }
585
+ async *[Symbol.asyncIterator]() {
586
+ for await (const page of this.pages()) yield* page.data;
587
+ }
588
+ /** Every item in one array. Mind the size of what you're listing. */
589
+ async toArray() {
590
+ const items = [];
591
+ for await (const item of this) items.push(item);
592
+ return items;
593
+ }
594
+ };
595
+
596
+ // src/resources.ts
597
+ function hasStrings(...keys) {
598
+ return (value) => isRecord(value) && keys.every((key) => typeof value[key] === "string");
599
+ }
600
+ var isUser = hasStrings("userId", "status");
601
+ var isSite = hasStrings("siteId", "userId");
602
+ var isCreatedSite = hasStrings("siteId");
603
+ var isExport = hasStrings("exportId", "status");
604
+ var isLedgerEntryDetail = hasStrings(
605
+ "id",
606
+ "type",
607
+ "status"
608
+ );
609
+ function isPriceList(value) {
610
+ return isRecord(value) && Array.isArray(value.data);
611
+ }
612
+ function segment(name, value) {
613
+ if (typeof value !== "string" || value === "") {
614
+ throw new TypeError(`${name} must be a non-empty string.`);
615
+ }
616
+ return encodeURIComponent(value);
617
+ }
618
+ function partnerApi(config) {
619
+ const get = (path, isResult, query) => requestJson(config, {
620
+ method: "GET",
621
+ path,
622
+ query,
623
+ retry: "safe",
624
+ isResult
625
+ });
626
+ const list = (path, { after, ...query }) => new PagedList(
627
+ (cursor) => get(path, (body) => isPage(body), {
628
+ ...query,
629
+ after: cursor
630
+ }),
631
+ after
632
+ );
633
+ return {
634
+ sessions: {
635
+ create: async ({ userId }) => requestJson(config, {
636
+ method: "POST",
637
+ path: "/sessions",
638
+ body: { userId },
639
+ // Safe to retry: Relume provisions a new user once (later sessions
640
+ // wait for or find the account), so a retry at most mints another
641
+ // client secret.
642
+ retry: "safe",
643
+ isResult: isSession
644
+ })
645
+ },
646
+ users: {
647
+ get: async (userId) => get(`/users/${segment("userId", userId)}`, isUser),
648
+ deactivate: async (userId) => requestJson(config, {
649
+ method: "POST",
650
+ path: `/users/${segment("userId", userId)}/deactivate`,
651
+ retry: "safe",
652
+ isResult: isUser
653
+ }),
654
+ reactivate: async (userId) => requestJson(config, {
655
+ method: "POST",
656
+ path: `/users/${segment("userId", userId)}/reactivate`,
657
+ retry: "safe",
658
+ isResult: isUser
659
+ })
660
+ },
661
+ sites: {
662
+ list: (userId, params = {}) => list(`/users/${segment("userId", userId)}/sites`, { ...params }),
663
+ create: async (userId, input = {}) => requestJson(config, {
664
+ method: "POST",
665
+ path: `/users/${segment("userId", userId)}/sites`,
666
+ body: input.name === void 0 ? {} : { name: input.name },
667
+ retry: "unsafe",
668
+ isResult: isCreatedSite
669
+ }),
670
+ get: async (siteId) => get(`/sites/${segment("siteId", siteId)}`, isSite)
671
+ },
672
+ exports: {
673
+ get: async (siteId, exportId) => get(
674
+ `/sites/${segment("siteId", siteId)}/exports/${segment("exportId", exportId)}`,
675
+ isExport
676
+ )
677
+ },
678
+ ledger: {
679
+ list: (params = {}) => list("/ledger", { ...params }),
680
+ get: async (id) => get(`/ledger/${segment("id", id)}`, isLedgerEntryDetail)
681
+ },
682
+ usage: {
683
+ get: ({ groupBy, ...params }) => list("/usage", {
684
+ ...params,
685
+ groupBy: groupBy?.length ? groupBy.join(",") : void 0
686
+ })
687
+ },
688
+ prices: {
689
+ list: async () => (await get("/prices", isPriceList)).data
690
+ }
691
+ };
692
+ }
693
+
694
+ // src/client.ts
695
+ var CALLBACK_FOR = {
696
+ "user.get": "onGetUser",
697
+ "credits.get": "onGetCredits",
698
+ "credits.charge": "onChargeCredits",
699
+ "credits.reverse": "onReverseCharge"
700
+ };
701
+ var INTERNAL_ERROR = () => errorResponse(
702
+ 500,
703
+ "internal_error",
704
+ "The partner failed to handle the call."
705
+ );
706
+ function defaultOnHandlerError(error, info) {
707
+ const where = info.route === "calls" ? `${info.type ?? "call"}` : "GET /relume/session";
708
+ const requestId = info.requestId ? ` (Relume-Request-Id ${info.requestId})` : "";
709
+ console.error(`[relume] ${where} failed${requestId}:`, error);
710
+ }
711
+ var Relume = class _Relume {
712
+ // The partner API. Each method throws RelumeError when the API refuses.
713
+ sessions;
714
+ users;
715
+ sites;
716
+ exports;
717
+ ledger;
718
+ usage;
719
+ prices;
720
+ /**
721
+ * A fetch-style handler for Next.js route handlers, Hono, Cloudflare
722
+ * Workers, Bun and Deno. Answers `GET …/session` and `POST …/calls`.
723
+ */
724
+ fetchHandler;
725
+ #secretKey;
726
+ #api;
727
+ #callbacks = {};
728
+ #onHandlerError;
729
+ #now;
730
+ static init(options) {
731
+ return new _Relume(options);
732
+ }
733
+ constructor(options) {
734
+ const secretKey = typeof options?.secretKey === "string" ? options.secretKey.trim() : "";
735
+ if (secretKey === "") {
736
+ throw new TypeError(
737
+ "Relume.init: secretKey is required (sk_test_\u2026 or sk_live_\u2026). Is the environment variable set?"
738
+ );
739
+ }
740
+ if (secretKey.startsWith("pk_")) {
741
+ throw new TypeError(
742
+ "Relume.init: that is a publishable key. Pass the secret key (sk_\u2026); the publishable key goes to the embed."
743
+ );
744
+ }
745
+ const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
746
+ if (!Number.isSafeInteger(maxRetries) || maxRetries < 0) {
747
+ throw new TypeError(
748
+ "Relume.init: maxRetries must be a whole number, 0 or more."
749
+ );
750
+ }
751
+ this.#secretKey = secretKey;
752
+ this.#now = options.now ?? Date.now;
753
+ this.#api = {
754
+ secretKey,
755
+ baseUrl: checkApiBaseUrl(options.apiBaseUrl ?? DEFAULT_API_BASE_URL),
756
+ timeoutMs: options.apiTimeoutMs ?? DEFAULT_API_TIMEOUT_MS,
757
+ fetch: options.fetch ?? ((...args) => fetch(...args)),
758
+ maxRetries,
759
+ sleep: options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
760
+ now: this.#now
761
+ };
762
+ this.#onHandlerError = options.onHandlerError ?? defaultOnHandlerError;
763
+ const api = partnerApi(this.#api);
764
+ this.sessions = api.sessions;
765
+ this.users = api.users;
766
+ this.sites = api.sites;
767
+ this.exports = api.exports;
768
+ this.ledger = api.ledger;
769
+ this.usage = api.usage;
770
+ this.prices = api.prices;
771
+ this.fetchHandler = fetchHandler(this, MAX_BODY_BYTES);
772
+ }
773
+ /** Who is signed in, for the browser's `GET /relume/session`. */
774
+ getUserIdFromRequest(callback) {
775
+ this.#callbacks.getUserIdFromRequest = assertFunction(
776
+ callback,
777
+ "getUserIdFromRequest"
778
+ );
779
+ return this;
780
+ }
781
+ /** Answers `user.get`: the user's profile, or null if you don't know them. */
782
+ onGetUser(callback) {
783
+ this.#callbacks.onGetUser = assertFunction(callback, "onGetUser");
784
+ return this;
785
+ }
786
+ /** Answers `credits.get`: `{ balance }` in your own credits. */
787
+ onGetCredits(callback) {
788
+ this.#callbacks.onGetCredits = assertFunction(callback, "onGetCredits");
789
+ return this;
790
+ }
791
+ /**
792
+ * Answers `credits.charge`. Debit `partnerCredits` (never `relumeCredits`),
793
+ * at most once per `chargeId`, and return `{ ok, balance }`.
794
+ */
795
+ onChargeCredits(callback) {
796
+ this.#callbacks.onChargeCredits = assertFunction(
797
+ callback,
798
+ "onChargeCredits"
799
+ );
800
+ return this;
801
+ }
802
+ /**
803
+ * Answers `credits.reverse`. Credit back `partnerCredits` (never
804
+ * `relumeCredits`), at most once per `reversalId`. If `chargeId` names a
805
+ * debit you never made, change nothing and return normally.
806
+ */
807
+ onReverseCharge(callback) {
808
+ this.#callbacks.onReverseCharge = assertFunction(
809
+ callback,
810
+ "onReverseCharge"
811
+ );
812
+ return this;
813
+ }
814
+ /**
815
+ * Express (or Connect, or plain `node:http`) middleware. Mount it before
816
+ * any body parser: `app.use('/relume', relume.express())`.
817
+ */
818
+ express() {
819
+ return expressHandler(
820
+ this,
821
+ MAX_BODY_BYTES,
822
+ (error, route) => this.#onHandlerError(error, { route })
823
+ );
824
+ }
825
+ /**
826
+ * `POST /relume/calls`, framework-agnostic: verifies the signature and the
827
+ * 5-minute window, routes by `type`, runs your callback and validates what
828
+ * it returns. Pass the raw body exactly as received.
829
+ */
830
+ async handleCall(input) {
831
+ const requestId = input.requestId ?? null;
832
+ const verified = await verifySignature({
833
+ secret: this.#secretKey,
834
+ header: input.signatureHeader,
835
+ rawBody: input.rawBody,
836
+ nowSeconds: Math.floor(this.#now() / 1e3)
837
+ });
838
+ if (!verified.valid) {
839
+ const message = verified.reason === "timestamp_out_of_tolerance" ? "The signature timestamp is more than 5 minutes from this server\u2019s clock." : verified.reason === "malformed_header" ? "Missing or malformed Relume-Signature header." : "The signature doesn't match this server's secret key.";
840
+ return errorResponse(401, "invalid_signature", message);
841
+ }
842
+ let body;
843
+ try {
844
+ body = typeof input.rawBody === "string" ? JSON.parse(input.rawBody) : JSON.parse(new TextDecoder().decode(input.rawBody));
845
+ } catch {
846
+ return errorResponse(
847
+ 400,
848
+ "invalid_request",
849
+ "The body is not valid JSON."
850
+ );
851
+ }
852
+ const parsed = parseCall(body);
853
+ if (!parsed.ok) return errorResponse(400, parsed.code, parsed.message);
854
+ const { call } = parsed;
855
+ const context = { type: call.type, requestId };
856
+ const callbackName = CALLBACK_FOR[call.type];
857
+ if (!this.#callbacks[callbackName]) {
858
+ this.#onHandlerError(
859
+ new Error(
860
+ `No ${callbackName} callback is registered, so ${call.type} was answered 400.`
861
+ ),
862
+ { route: "calls", type: call.type, requestId }
863
+ );
864
+ return errorResponse(
865
+ 400,
866
+ "unsupported_call_type",
867
+ `This partner doesn't handle ${call.type}.`
868
+ );
869
+ }
870
+ try {
871
+ return await this.#runCallback(call, context);
872
+ } catch (error) {
873
+ this.#onHandlerError(error, {
874
+ route: "calls",
875
+ type: call.type,
876
+ requestId
877
+ });
878
+ return INTERNAL_ERROR();
879
+ }
880
+ }
881
+ async #runCallback(call, context) {
882
+ const callbacks = this.#callbacks;
883
+ switch (call.type) {
884
+ case "user.get": {
885
+ const profile = checkUserProfile(
886
+ await callbacks.onGetUser(call.userId, context)
887
+ );
888
+ return profile ? jsonResponse(200, profile) : errorResponse(404, "user_not_found", "Unknown user.");
889
+ }
890
+ case "credits.get":
891
+ return jsonResponse(
892
+ 200,
893
+ checkCredits(await callbacks.onGetCredits(call.userId, context))
894
+ );
895
+ case "credits.charge": {
896
+ const { ok, ...rest } = checkChargeResult(
897
+ await callbacks.onChargeCredits(call.charge, context)
898
+ );
899
+ return ok ? jsonResponse(200, rest) : jsonResponse(402, {
900
+ ...rest,
901
+ error: {
902
+ code: "insufficient_credits",
903
+ message: "The user can't afford this action."
904
+ }
905
+ });
906
+ }
907
+ case "credits.reverse":
908
+ return jsonResponse(
909
+ 200,
910
+ checkReversalResult(
911
+ await callbacks.onReverseCharge(call.reversal, context)
912
+ )
913
+ );
914
+ }
915
+ }
916
+ /**
917
+ * `GET /relume/session`, framework-agnostic: refuses a request from
918
+ * another site or without the embed's `Relume-Embed` header, works out the
919
+ * signed-in user with `getUserIdFromRequest`, calls `POST /sessions` and
920
+ * returns the client secret, or passes the API's error through unchanged.
921
+ * `request` must carry its headers: the fetch Request, the Node request,
922
+ * or an object with `headers`.
923
+ */
924
+ async handleSession(request) {
925
+ const refused = refuseSessionRequest(request);
926
+ if (refused) {
927
+ if (refused.error) {
928
+ this.#onHandlerError(refused.error, { route: "session" });
929
+ }
930
+ return refused.response;
931
+ }
932
+ const resolve = this.#callbacks.getUserIdFromRequest;
933
+ if (!resolve) {
934
+ this.#onHandlerError(
935
+ new Error("No getUserIdFromRequest callback is registered."),
936
+ { route: "session" }
937
+ );
938
+ return INTERNAL_ERROR();
939
+ }
940
+ let userId;
941
+ try {
942
+ const resolved = await resolve(request);
943
+ if (resolved === null || resolved === void 0 || resolved === "") {
944
+ return errorResponse(401, "not_signed_in", "Nobody is signed in.");
945
+ }
946
+ if (typeof resolved === "number" && Number.isSafeInteger(resolved)) {
947
+ userId = String(resolved);
948
+ } else if (typeof resolved === "string" && resolved.length >= 1 && resolved.length <= 128) {
949
+ userId = resolved;
950
+ } else {
951
+ throw new TypeError(
952
+ "getUserIdFromRequest must return a string of 1 to 128 characters, a whole number, or null."
953
+ );
954
+ }
955
+ } catch (error) {
956
+ this.#onHandlerError(error, { route: "session" });
957
+ return INTERNAL_ERROR();
958
+ }
959
+ let response;
960
+ try {
961
+ response = await apiRequest(this.#api, "POST", "/sessions", {
962
+ body: { userId }
963
+ });
964
+ } catch (error) {
965
+ this.#onHandlerError(error, { route: "session" });
966
+ const status = error instanceof RelumeError && error.code === "timeout" ? 504 : 502;
967
+ return errorResponse(status, "session_failed", "Couldn't reach Relume.");
968
+ }
969
+ if (response.status >= 200 && response.status < 300) {
970
+ if (isSession(response.body)) {
971
+ return jsonResponse(200, {
972
+ clientSecret: response.body.clientSecret,
973
+ expiresAt: response.body.expiresAt
974
+ });
975
+ }
976
+ this.#onHandlerError(errorFromResponse(response), {
977
+ route: "session",
978
+ requestId: response.requestId
979
+ });
980
+ return errorResponse(
981
+ 502,
982
+ "session_failed",
983
+ "Relume answered without a session."
984
+ );
985
+ }
986
+ const hasError = typeof response.body === "object" && response.body !== null && "error" in response.body;
987
+ const body = hasError ? response.body : {
988
+ error: {
989
+ code: "session_failed",
990
+ message: `Relume answered ${response.status}.`
991
+ }
992
+ };
993
+ if (response.status === 401) {
994
+ this.#onHandlerError(errorFromResponse(response), {
995
+ route: "session",
996
+ requestId: response.requestId
997
+ });
998
+ }
999
+ return jsonResponse(response.status, body);
1000
+ }
1001
+ };
1002
+ var LOOPBACK = /^(localhost|127(\.\d{1,3}){3}|\[::1\])$/;
1003
+ function checkApiBaseUrl(value) {
1004
+ let url;
1005
+ try {
1006
+ url = new URL(value);
1007
+ } catch {
1008
+ throw new TypeError(`Relume.init: apiBaseUrl isn't a URL: ${value}`);
1009
+ }
1010
+ const local = url.protocol === "http:" && LOOPBACK.test(url.hostname);
1011
+ if (url.protocol !== "https:" && !local) {
1012
+ throw new TypeError(
1013
+ "Relume.init: apiBaseUrl must be an https URL (http only for localhost): every request carries your secret key."
1014
+ );
1015
+ }
1016
+ return value.replace(/\/+$/, "");
1017
+ }
1018
+ function assertFunction(callback, name) {
1019
+ if (typeof callback !== "function") {
1020
+ throw new TypeError(`relume.${name} expects a function.`);
1021
+ }
1022
+ return callback;
1023
+ }
1024
+ export {
1025
+ DEFAULT_API_BASE_URL,
1026
+ EMBED_HEADER,
1027
+ EMBED_HEADER_VALUE,
1028
+ InvalidCallbackResultError,
1029
+ MAX_SIGNATURES,
1030
+ PagedList,
1031
+ REQUEST_ID_HEADER,
1032
+ Relume,
1033
+ RelumeError,
1034
+ SIGNATURE_HEADER,
1035
+ SIGNATURE_TOLERANCE_SECONDS,
1036
+ computeSignature,
1037
+ parseSignatureHeader,
1038
+ signCall,
1039
+ verifySignature
1040
+ };
1041
+ //# sourceMappingURL=index.js.map