@relume_io/partner-node 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Relume Tech PTY LTD
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -95,6 +95,7 @@ trust where the builder is embedded.
95
95
  | Bad or missing signature, or stale `t` | `401 invalid_signature`; your callback isn't called |
96
96
  | Unknown `type`, or no callback registered | `400 unsupported_call_type` |
97
97
  | Body that isn't valid for its `type` | `400 invalid_request` |
98
+ | `environment` isn't your secret key's | `400 environment_mismatch`; your callback isn't called |
98
99
  | `onGetUser` returns `null` | `404 user_not_found` |
99
100
  | `onChargeCredits` returns `{ ok: false }` | `402 insufficient_credits`, with `balance` |
100
101
  | A callback throws, or returns a bad shape | `500 internal_error`, and `onHandlerError` gets the error |
@@ -107,8 +108,12 @@ Every response is JSON with `Cache-Control: no-store`.
107
108
 
108
109
  ## The callbacks
109
110
 
110
- Each callback gets a second argument, `{ type, requestId }`. Quote the
111
- `requestId` to Relume support.
111
+ Each callback gets a second argument, `{ type, environment, requestId }`.
112
+ Quote the `requestId` to Relume support. `environment` is always your secret
113
+ key's (`'test'` for `sk_test_…`, `'live'` for `sk_live_…`): every call Relume
114
+ signs says which environment it is for, and one for the other environment is
115
+ refused before a callback runs, so a test call can never move a live wallet.
116
+ Still give test and live their own endpoint URLs, each with its own client.
112
117
 
113
118
  ### `onChargeCredits(charge) → { ok, balance? }`
114
119
 
@@ -192,7 +197,7 @@ body, the call is answered `500` and `onHandlerError` explains why.
192
197
 
193
198
  ```ts
194
199
  Relume.init({
195
- secretKey: 'sk_test_…', // required; test or live
200
+ secretKey: 'sk_test_…', // required; sk_test_… or sk_live_…
196
201
  apiBaseUrl: 'https://…', // defaults to production; http only for localhost
197
202
  apiTimeoutMs: 15_000, // per partner API request attempt
198
203
  maxRetries: 2, // retries for 429s, 5xx answers and failed connections
@@ -303,6 +308,8 @@ It calls your endpoint the way Relume does, and checks that it:
303
308
  - rejects another key's signature, a body changed after signing, a timestamp
304
309
  more than 5 minutes off either way, and a call with no signature, with
305
310
  `401`;
311
+ - refuses a correctly signed call whose `environment` isn't the key's, with
312
+ `400`;
306
313
  - answers `user.get` and `credits.get` with bodies Relume accepts, and
307
314
  `user.get` with `404` for a user id it has never seen;
308
315
  - debits a charge, and only once when the same `chargeId` comes again, with
@@ -320,7 +327,11 @@ It calls your endpoint the way Relume does, and checks that it:
320
327
  signed-in user.
321
328
 
322
329
  It finds the session route next to a `--url` ending in `/calls`; pass
323
- `--session-url` if yours is elsewhere.
330
+ `--session-url` if yours is elsewhere. If a login answers that route before
331
+ the SDK does, give the check a test user's cookie so it gets past it: in
332
+ `RELUME_SESSION_HEADERS` (one `Name: value` a line), or with
333
+ `--session-header "Cookie: …"` (repeatable). Prefer the variable: a cookie on
334
+ the command line leaks like a key does.
324
335
 
325
336
  Give it the key in `RELUME_SECRET_KEY`, not `--secret-key`: a key on the
326
337
  command line lands in your shell history, CI logs and the process list.
@@ -360,3 +371,7 @@ npm run test:dist # builds, then loads the ESM and CJS output
360
371
 
361
372
  Releases go out from CI on a `partner-sdk/v*` tag, together with
362
373
  `@relume_io/partner-embed`: see [Releasing](../README.md#releasing).
374
+
375
+ ## License
376
+
377
+ MIT. See [LICENSE](./LICENSE).
package/dist/bin.cjs CHANGED
@@ -30,6 +30,13 @@ function isHttpsUrl(value) {
30
30
  }
31
31
  }
32
32
 
33
+ // src/calls.ts
34
+ function keyEnvironment(secretKey) {
35
+ if (secretKey.startsWith("sk_test_")) return "test";
36
+ if (secretKey.startsWith("sk_live_")) return "live";
37
+ return null;
38
+ }
39
+
33
40
  // src/session.ts
34
41
  var EMBED_HEADER = "Relume-Embed";
35
42
  var EMBED_HEADER_VALUE = "1";
@@ -116,6 +123,7 @@ var TAMPERED = "rejects a body changed after signing";
116
123
  var STALE = "rejects a timestamp more than 5 minutes old";
117
124
  var FUTURE = "rejects a timestamp more than 5 minutes ahead";
118
125
  var UNSIGNED = "rejects a call with no signature";
126
+ var OTHER_ENVIRONMENT = "refuses a call for the other environment";
119
127
  var USER_GET = "answers user.get with a valid profile";
120
128
  var UNKNOWN_USER = "answers user.get 404 for a user it doesn't know";
121
129
  var CREDITS_GET = "answers credits.get with a valid balance";
@@ -192,6 +200,11 @@ function networkReason(error) {
192
200
  }
193
201
  async function checkEndpoint(options) {
194
202
  const { url, secretKey, userId } = options;
203
+ const environment = keyEnvironment(secretKey);
204
+ if (environment === null) {
205
+ throw new TypeError("The secret key must start with sk_test_ or sk_live_.");
206
+ }
207
+ const otherEnvironment = environment === "test" ? "live" : "test";
195
208
  const fetchImpl = options.fetch ?? ((...args) => fetch(...args));
196
209
  const now = options.now ?? Date.now;
197
210
  const nowSeconds = () => Math.floor(now() / 1e3);
@@ -260,7 +273,7 @@ async function checkEndpoint(options) {
260
273
  }
261
274
  async function readBalance() {
262
275
  const answer = await signed(
263
- { type: "credits.get", userId },
276
+ { type: "credits.get", environment, userId },
264
277
  READ_TIMEOUT_MS
265
278
  );
266
279
  if (!isOk(answer)) {
@@ -282,6 +295,7 @@ async function checkEndpoint(options) {
282
295
  try {
283
296
  const response = await fetchImpl(sessionUrl2, {
284
297
  ...init,
298
+ headers: { ...options.sessionHeaders, ...init.headers },
285
299
  redirect: "manual",
286
300
  signal: AbortSignal.timeout(READ_TIMEOUT_MS)
287
301
  });
@@ -354,7 +368,7 @@ async function checkEndpoint(options) {
354
368
  });
355
369
  if (answer.answered && answer.status === status) return null;
356
370
  if (answer.answered && answer.status === 401) {
357
- return `${said(answer)}: it looked for a signed-in user first. Answer ${status} ${code} before reading the user`;
371
+ return options.sessionHeaders ? `${said(answer)} with the session headers: they didn't get past the login (an expired cookie?), or the route looked for a signed-in user first. Answer ${status} ${code} before reading the user` : `${said(answer)}: something asked for a signed-in user first. If that's a login in front of the route, pass --session-header with a test user's cookie so the check gets past it; otherwise answer ${status} ${code} before reading the user`;
358
372
  }
359
373
  return `${said(answer)}; expected ${status} ${code}`;
360
374
  }
@@ -389,9 +403,10 @@ async function checkEndpoint(options) {
389
403
  }
390
404
  }
391
405
  const found = { wrongKey: false, balance: null, charged: null, reversed: false };
392
- const read = JSON.stringify({ type: "credits.get", userId });
406
+ const read = JSON.stringify({ type: "credits.get", environment, userId });
393
407
  const tampered = JSON.stringify({
394
408
  type: "credits.get",
409
+ environment,
395
410
  userId,
396
411
  note: "changed after signing"
397
412
  });
@@ -419,6 +434,7 @@ async function checkEndpoint(options) {
419
434
  STALE,
420
435
  FUTURE,
421
436
  UNSIGNED,
437
+ OTHER_ENVIRONMENT,
422
438
  USER_GET,
423
439
  UNKNOWN_USER,
424
440
  CREDITS_GET,
@@ -453,8 +469,22 @@ async function checkEndpoint(options) {
453
469
  return `${said(answer)}; expected 401 invalid_signature`;
454
470
  });
455
471
  }
472
+ await run(OTHER_ENVIRONMENT, async () => {
473
+ const answer = await signed(
474
+ { type: "credits.get", environment: otherEnvironment, userId },
475
+ READ_TIMEOUT_MS
476
+ );
477
+ if (answer.answered && answer.status === 400) return null;
478
+ if (isOk(answer)) {
479
+ return `${said(answer)} to a ${otherEnvironment} call signed with your ${environment} key: check the body's environment is your key's, and answer 400 environment_mismatch`;
480
+ }
481
+ return `${said(answer)}; expected 400 environment_mismatch`;
482
+ });
456
483
  await run(USER_GET, async () => {
457
- const answer = await signed({ type: "user.get", userId }, READ_TIMEOUT_MS);
484
+ const answer = await signed(
485
+ { type: "user.get", environment, userId },
486
+ READ_TIMEOUT_MS
487
+ );
458
488
  if (answer.answered && answer.status === 404) {
459
489
  return `answered 404: it doesn't know user "${userId}". Pass a --user your wallet knows`;
460
490
  }
@@ -465,7 +495,7 @@ async function checkEndpoint(options) {
465
495
  });
466
496
  await run(UNKNOWN_USER, async () => {
467
497
  const answer = await signed(
468
- { type: "user.get", userId: randomId("u_check_unknown") },
498
+ { type: "user.get", environment, userId: randomId("u_check_unknown") },
469
499
  READ_TIMEOUT_MS
470
500
  );
471
501
  return answer.answered && answer.status === 404 ? null : `${said(answer)} for a user id your wallet has never seen; expected 404`;
@@ -505,6 +535,7 @@ async function checkEndpoint(options) {
505
535
  const answer = await signed(
506
536
  {
507
537
  type: "credits.reverse",
538
+ environment,
508
539
  userId,
509
540
  reversalId: randomId("rv_check"),
510
541
  ...left,
@@ -524,6 +555,7 @@ async function checkEndpoint(options) {
524
555
  const chargeId = randomId("ch_check");
525
556
  const charge = JSON.stringify({
526
557
  type: "credits.charge",
558
+ environment,
527
559
  userId,
528
560
  chargeId,
529
561
  partnerCredits: CHECK_CHARGE_CREDITS,
@@ -580,6 +612,7 @@ async function checkEndpoint(options) {
580
612
  const answer = await signed(
581
613
  {
582
614
  type: "credits.charge",
615
+ environment,
583
616
  userId,
584
617
  chargeId: declinedId,
585
618
  partnerCredits: amount,
@@ -610,6 +643,7 @@ async function checkEndpoint(options) {
610
643
  const answer = await signed(
611
644
  {
612
645
  type: "credits.reverse",
646
+ environment,
613
647
  userId,
614
648
  reversalId: randomId("rv_check"),
615
649
  partnerCredits: CHECK_CHARGE_CREDITS,
@@ -639,6 +673,7 @@ async function checkEndpoint(options) {
639
673
  const answer = await signed(
640
674
  {
641
675
  type: "credits.charge",
676
+ environment,
642
677
  userId,
643
678
  chargeId: lateId,
644
679
  partnerCredits: CHECK_CHARGE_CREDITS,
@@ -658,6 +693,7 @@ async function checkEndpoint(options) {
658
693
  }
659
694
  const reversal = JSON.stringify({
660
695
  type: "credits.reverse",
696
+ environment,
661
697
  userId,
662
698
  reversalId: randomId("rv_check"),
663
699
  partnerCredits: 1,
@@ -724,6 +760,12 @@ reversals. It also checks that another site can't read your session route.
724
760
  charges that user and gives every credit back.
725
761
  --session-url The session route your page's embed calls. Defaults to
726
762
  /session next to a --url ending in /calls.
763
+ --session-header
764
+ A header for every request to the session route, as
765
+ "Name: value". Repeatable. Pass a test user's cookie when a
766
+ login answers that route before the SDK does. Prefer
767
+ $RELUME_SESSION_HEADERS, one header a line: a cookie is a
768
+ credential too.
727
769
  --secret-key Your test secret key. Prefer $RELUME_SECRET_KEY, the default:
728
770
  a key on the command line lands in your shell history, CI
729
771
  logs and the process list.
@@ -732,6 +774,9 @@ reversals. It also checks that another site can't read your session route.
732
774
 
733
775
  Exits 0 when every check passes, 1 when any fails or can't run, and 2 for
734
776
  bad arguments.`;
777
+ var HEADER_NAME = /^[!#$%&'*+.^`|~\w-]+$/;
778
+ var HEADER_VALUE = /^[\t\x20-\x7e\x80-\xff]*$/;
779
+ var PROBE_HEADERS = /^(relume-embed|origin|sec-fetch-.*|access-control-request-.*)$/i;
735
780
  var LABEL = {
736
781
  pass: "PASS",
737
782
  fail: "FAIL",
@@ -753,6 +798,7 @@ ${USAGE}`);
753
798
  url: { type: "string" },
754
799
  user: { type: "string" },
755
800
  "session-url": { type: "string" },
801
+ "session-header": { type: "string", multiple: true },
756
802
  "secret-key": { type: "string" },
757
803
  live: { type: "boolean" },
758
804
  help: { type: "boolean", short: "h" }
@@ -796,6 +842,27 @@ ${USAGE}`);
796
842
  }
797
843
  sessionUrl = parsed2.href;
798
844
  }
845
+ const headerLines = values["session-header"] ?? (io.env.RELUME_SESSION_HEADERS ?? "").split("\n").filter((line) => line.trim() !== "");
846
+ let sessionHeaders;
847
+ for (const header of headerLines) {
848
+ const colon = header.indexOf(":");
849
+ const name = header.slice(0, colon).trim();
850
+ const value = header.slice(colon + 1).trim();
851
+ if (colon < 0 || !HEADER_NAME.test(name)) {
852
+ return usageError('Each session header must be "Name: value".');
853
+ }
854
+ if (!HEADER_VALUE.test(value)) {
855
+ return usageError(
856
+ `The ${name} session header's value has characters a header can't carry.`
857
+ );
858
+ }
859
+ if (PROBE_HEADERS.test(name)) {
860
+ return usageError(
861
+ `A session header can't set ${name}: the check sets it to test the route.`
862
+ );
863
+ }
864
+ sessionHeaders = { ...sessionHeaders, [name]: value };
865
+ }
799
866
  const userId = values.user ?? "";
800
867
  if (userId.length < 1 || userId.length > 128) {
801
868
  return usageError("--user is required: a user id of 1 to 128 characters.");
@@ -809,6 +876,11 @@ ${USAGE}`);
809
876
  "That is a publishable key. Pass the secret key (sk_test_\u2026)."
810
877
  );
811
878
  }
879
+ if (keyEnvironment(secretKey) === null) {
880
+ return usageError(
881
+ "The secret key must start with sk_test_ (or sk_live_ with --live)."
882
+ );
883
+ }
812
884
  if (secretKey.startsWith("sk_live_") && !values.live) {
813
885
  return usageError(
814
886
  "That is a live key, and the check charges the user. Use your test key, or pass --live to run it against a live wallet."
@@ -821,6 +893,7 @@ ${USAGE}`);
821
893
  secretKey,
822
894
  userId,
823
895
  sessionUrl,
896
+ sessionHeaders,
824
897
  fetch: io.fetch,
825
898
  onResult: (result) => {
826
899
  io.log(`${LABEL[result.status]} ${result.name}`);