@relume_io/partner-node 1.0.0 → 2.0.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/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
@@ -60,10 +60,10 @@ Then, in Relume's partner settings, set your endpoint URL to
60
60
 
61
61
  ## What it mounts
62
62
 
63
- | Route | Called by | Does |
64
- | --------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------- |
65
- | `GET /relume/session` | `@relume_io/partner-embed` | Resolves the user with `getUserIdFromRequest`, calls `POST /sessions`, returns `{ clientSecret, expiresAt }`. |
66
- | `POST /relume/calls` | Relume | Verifies `Relume-Signature` and the 5-minute window, routes by `type`, runs your callback, validates what it returns. |
63
+ | Route | Called by | Does |
64
+ | --------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
65
+ | `GET /relume/session` | `@relume_io/partner-embed` | Refuses another site, resolves the user with `getUserIdFromRequest`, calls `POST /sessions`, returns `{ clientSecret, expiresAt }`. |
66
+ | `POST /relume/calls` | Relume | Verifies `Relume-Signature` and the 5-minute window, routes by `type`, runs your callback, validates what it returns. |
67
67
 
68
68
  `relume.express()` answers exactly `/session` and `/calls` under its mount
69
69
  path (`app.use('/relume', …)`), or `/relume/session` and `/relume/calls` when
@@ -75,20 +75,33 @@ CORS preflights (`OPTIONS`) are left to your app: `express()` passes them on
75
75
  to the next middleware, and `fetchHandler` answers `204` with no CORS headers.
76
76
 
77
77
  If your backend is on another origin from your page, pass `sessionUrl` to
78
- `Relume.init` in the embed and allow your page's origin, with credentials, in
79
- that route's CORS response. Use an exact allowlist: never reflect the request
80
- `Origin` and never use `*`.
81
-
82
- | Situation | Answer |
83
- | ----------------------------------------- | ------------------------------------------------------------ |
84
- | Bad or missing signature, or stale `t` | `401 invalid_signature`; your callback isn't called |
85
- | Unknown `type`, or no callback registered | `400 unsupported_call_type` |
86
- | Body that isn't valid for its `type` | `400 invalid_request` |
87
- | `onGetUser` returns `null` | `404 user_not_found` |
88
- | `onChargeCredits` returns `{ ok: false }` | `402 insufficient_credits`, with `balance` |
89
- | A callback throws, or returns a bad shape | `500 internal_error`, and `onHandlerError` gets the error |
90
- | Nobody signed in (session route) | `401 not_signed_in` |
91
- | `POST /sessions` refused (session route) | Relume's status and `{ error }`, e.g. `403 user_deactivated` |
78
+ `Relume.init` in the embed and allow your page's origin, with credentials and
79
+ the `Relume-Embed` header, in that route's CORS response. Use an exact
80
+ allowlist: never reflect the request `Origin` and never use `*`.
81
+
82
+ Any site can make a signed-in user's browser call the session route, so it
83
+ refuses two things before it looks at the user: a request with
84
+ `Sec-Fetch-Site: cross-site`, which only another site's page sends, and one
85
+ without the `Relume-Embed: 1` header `@relume_io/partner-embed` sends. The
86
+ header makes a cross-origin request need a CORS preflight, so a link or form
87
+ on another site can't get one. Neither helps against a script on your own
88
+ page: any script there can ask the route for a session for the signed-in
89
+ user, or answer the builder's session request with a client secret for
90
+ another of your users, which opens the builder as them. Load only scripts you
91
+ trust where the builder is embedded.
92
+
93
+ | Situation | Answer |
94
+ | -------------------------------------------- | ------------------------------------------------------------ |
95
+ | Bad or missing signature, or stale `t` | `401 invalid_signature`; your callback isn't called |
96
+ | Unknown `type`, or no callback registered | `400 unsupported_call_type` |
97
+ | Body that isn't valid for its `type` | `400 invalid_request` |
98
+ | `onGetUser` returns `null` | `404 user_not_found` |
99
+ | `onChargeCredits` returns `{ ok: false }` | `402 insufficient_credits`, with `balance` |
100
+ | A callback throws, or returns a bad shape | `500 internal_error`, and `onHandlerError` gets the error |
101
+ | No `Relume-Embed: 1` header (session route) | `400 embed_header_missing`, and `onHandlerError` says why |
102
+ | `Sec-Fetch-Site: cross-site` (session route) | `403 cross_site_request` |
103
+ | Nobody signed in (session route) | `401 not_signed_in` |
104
+ | `POST /sessions` refused (session route) | Relume's status and `{ error }`, e.g. `403 user_deactivated` |
92
105
 
93
106
  Every response is JSON with `Cache-Control: no-store`.
94
107
 
@@ -135,7 +148,8 @@ A whole number of your own credits, zero or more.
135
148
  Read the user from your own session. Never take a user id from the query
136
149
  string or body. Gets the Express/Node request for `express()`, or the
137
150
  `Request` for `fetchHandler`. Pass that type to `Relume.init<…>` to type
138
- `req`; it is `unknown` otherwise.
151
+ `req`; it must have `headers` (`SessionRequest`), which every framework's
152
+ request does, and is `{ headers: unknown }` otherwise.
139
153
 
140
154
  ## Other frameworks
141
155
 
@@ -164,6 +178,8 @@ export default { fetch: (request: Request) => relume.fetchHandler(request) };
164
178
 
165
179
  **Anything else:** call `relume.handleCall({ rawBody, signatureHeader, requestId })`
166
180
  and `relume.handleSession(request)`. Each returns `{ status, headers, body }`.
181
+ `handleSession` reads the request's headers, so pass it the fetch `Request`,
182
+ the Node request, or an object with `headers`.
167
183
 
168
184
  ### Raw bodies
169
185
 
@@ -177,7 +193,7 @@ body, the call is answered `500` and `onHandlerError` explains why.
177
193
  ```ts
178
194
  Relume.init({
179
195
  secretKey: 'sk_test_…', // required; test or live
180
- apiBaseUrl: 'https://…', // defaults to production
196
+ apiBaseUrl: 'https://…', // defaults to production; http only for localhost
181
197
  apiTimeoutMs: 15_000, // per partner API request attempt
182
198
  maxRetries: 2, // retries for 429s, 5xx answers and failed connections
183
199
  fetch, // custom fetch for partner API requests
@@ -186,7 +202,9 @@ Relume.init({
186
202
  ```
187
203
 
188
204
  `Relume.init` returns a new client every time, with no global state, so test
189
- and live clients can run side by side.
205
+ and live clients can run side by side. It throws for an `apiBaseUrl` that
206
+ isn't `https`, except `http://localhost`, `127.0.0.1` and `[::1]`: every request
207
+ carries your secret key.
190
208
 
191
209
  ## Calling the partner API
192
210
 
@@ -293,8 +311,23 @@ It calls your endpoint the way Relume does, and checks that it:
293
311
  - changes nothing when a charge it never debited is reversed, and debits
294
312
  nothing if that charge arrives afterwards (record a reversed `chargeId` as
295
313
  declined, see `onReverseCharge` above);
296
- - credits a reversal, and only once per `reversalId`.
297
-
314
+ - credits a reversal, and only once per `reversalId`;
315
+ - doesn't let another site read the session route: it asks for a session from
316
+ `https://evil.example`, preflight and `GET`, and fails if the CORS response
317
+ grants that origin;
318
+ - refuses a session request without the `Relume-Embed` header with `400`, and
319
+ one with `Sec-Fetch-Site: cross-site` with `403`, before looking for a
320
+ signed-in user.
321
+
322
+ It finds the session route next to a `--url` ending in `/calls`; pass
323
+ `--session-url` if yours is elsewhere. If a login answers that route before
324
+ the SDK does, give the check a test user's cookie so it gets past it: in
325
+ `RELUME_SESSION_HEADERS` (one `Name: value` a line), or with
326
+ `--session-header "Cookie: …"` (repeatable). Prefer the variable: a cookie on
327
+ the command line leaks like a key does.
328
+
329
+ Give it the key in `RELUME_SECRET_KEY`, not `--secret-key`: a key on the
330
+ command line lands in your shell history, CI logs and the process list.
298
331
  `--user` must be a user your wallet knows, holding at least 2 credits. The
299
332
  check charges them 2 and reverses everything it charged, and it prints a
300
333
  `NOTE` if the balance doesn't end where it started. Each check prints `PASS`,
@@ -331,3 +364,7 @@ npm run test:dist # builds, then loads the ESM and CJS output
331
364
 
332
365
  Releases go out from CI on a `partner-sdk/v*` tag, together with
333
366
  `@relume_io/partner-embed`: see [Releasing](../README.md#releasing).
367
+
368
+ ## License
369
+
370
+ MIT. See [LICENSE](./LICENSE).
package/dist/bin.cjs CHANGED
@@ -30,6 +30,10 @@ function isHttpsUrl(value) {
30
30
  }
31
31
  }
32
32
 
33
+ // src/session.ts
34
+ var EMBED_HEADER = "Relume-Embed";
35
+ var EMBED_HEADER_VALUE = "1";
36
+
33
37
  // src/signing.ts
34
38
  var SIGNATURE_HEADER = "Relume-Signature";
35
39
  var REQUEST_ID_HEADER = "Relume-Request-Id";
@@ -100,6 +104,11 @@ var READ_TIMEOUT_MS = 4e3;
100
104
  var CHARGE_TIMEOUT_MS = 4e3;
101
105
  var REVERSE_TIMEOUT_MS = 1e4;
102
106
  var SKEW_SECONDS = 600;
107
+ var FOREIGN_ORIGIN = "https://evil.example";
108
+ var SESSION_CORS = "doesn't let another site read the session route";
109
+ var SESSION_HEADER = "refuses a session request without the Relume-Embed header";
110
+ var SESSION_CROSS_SITE = "refuses a session request from another site";
111
+ var SESSION_CHECKS = [SESSION_CORS, SESSION_HEADER, SESSION_CROSS_SITE];
103
112
  var ACCEPT = "accepts a correctly signed call";
104
113
  var ROTATION = "accepts a key rotation header, with your key second";
105
114
  var OTHER_KEY = "rejects a signature made with another key";
@@ -166,6 +175,13 @@ function optionalBalanceProblem(body) {
166
175
  function bodyBalance(body) {
167
176
  return body !== MALFORMED && isWhole(body.balance, 0) ? body.balance : void 0;
168
177
  }
178
+ function sessionUrlFor(callsUrl) {
179
+ const url = new URL(callsUrl);
180
+ if (!/\/calls\/?$/.test(url.pathname)) return null;
181
+ url.pathname = url.pathname.replace(/calls\/?$/, "session");
182
+ url.search = "";
183
+ return url.href;
184
+ }
169
185
  function randomId(prefix) {
170
186
  return `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`;
171
187
  }
@@ -262,6 +278,117 @@ async function checkEndpoint(options) {
262
278
  }
263
279
  return { balance: answer.body.balance };
264
280
  }
281
+ async function askSession(sessionUrl2, init) {
282
+ try {
283
+ const response = await fetchImpl(sessionUrl2, {
284
+ ...init,
285
+ headers: { ...options.sessionHeaders, ...init.headers },
286
+ redirect: "manual",
287
+ signal: AbortSignal.timeout(READ_TIMEOUT_MS)
288
+ });
289
+ const text = await response.text();
290
+ return {
291
+ answer: {
292
+ answered: true,
293
+ status: response.status,
294
+ ok: response.status >= 200 && response.status < 300,
295
+ body: parseBody(text)
296
+ },
297
+ allowOrigin: response.headers.get("access-control-allow-origin")?.trim() ?? null
298
+ };
299
+ } catch (error) {
300
+ const reason = error instanceof Error && error.name === "TimeoutError" ? `timed out after ${READ_TIMEOUT_MS / 1e3}s` : networkReason(error);
301
+ return { answer: { answered: false, reason }, allowOrigin: null };
302
+ }
303
+ }
304
+ const session = { answered: false };
305
+ async function sessionCorsProblem(sessionUrl2) {
306
+ const probes = [
307
+ [
308
+ "the preflight",
309
+ {
310
+ method: "OPTIONS",
311
+ headers: {
312
+ Origin: FOREIGN_ORIGIN,
313
+ "Access-Control-Request-Method": "GET",
314
+ "Access-Control-Request-Headers": EMBED_HEADER.toLowerCase()
315
+ }
316
+ }
317
+ ],
318
+ [
319
+ "GET",
320
+ {
321
+ method: "GET",
322
+ headers: {
323
+ Origin: FOREIGN_ORIGIN,
324
+ [EMBED_HEADER]: EMBED_HEADER_VALUE,
325
+ "Sec-Fetch-Site": "cross-site",
326
+ "Sec-Fetch-Mode": "cors"
327
+ }
328
+ }
329
+ ]
330
+ ];
331
+ let problem = null;
332
+ for (const [what, init] of probes) {
333
+ const { answer, allowOrigin } = await askSession(sessionUrl2, init);
334
+ if (!answer.answered) return `${what} to ${sessionUrl2} ${answer.reason}`;
335
+ if (allowOrigin === FOREIGN_ORIGIN) {
336
+ problem ??= `${what} to ${sessionUrl2} from ${FOREIGN_ORIGIN} answered Access-Control-Allow-Origin: ${FOREIGN_ORIGIN}, so a page on any site can read what this route answers for a signed-in user. Allow only your own page origins, never the request's Origin`;
337
+ }
338
+ if (init.method === "GET") {
339
+ session.answered = true;
340
+ }
341
+ }
342
+ return problem;
343
+ }
344
+ async function sessionRouteMissing(sessionUrl2) {
345
+ const { answer } = await askSession(sessionUrl2, {
346
+ method: "GET",
347
+ headers: { [EMBED_HEADER]: EMBED_HEADER_VALUE }
348
+ });
349
+ return answer.answered && answer.status === 404;
350
+ }
351
+ async function sessionRefusalProblem(sessionUrl2, headers, status, code) {
352
+ const { answer } = await askSession(sessionUrl2, {
353
+ method: "GET",
354
+ headers
355
+ });
356
+ if (answer.answered && answer.status === status) return null;
357
+ if (answer.answered && answer.status === 401) {
358
+ 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`;
359
+ }
360
+ return `${said(answer)}; expected ${status} ${code}`;
361
+ }
362
+ const sessionUrl = options.sessionUrl ?? sessionUrlFor(url);
363
+ const notFound = sessionUrl && await sessionRouteMissing(sessionUrl);
364
+ if (sessionUrl === null || notFound) {
365
+ skip(
366
+ SESSION_CHECKS,
367
+ sessionUrl === null ? "--url doesn't end in /calls; pass --session-url with the session route your page's embed calls" : `GET ${sessionUrl} answered 404; pass --session-url with the session route your page's embed calls`
368
+ );
369
+ } else {
370
+ await run(SESSION_CORS, () => sessionCorsProblem(sessionUrl));
371
+ if (!session.answered) {
372
+ skip(SESSION_CHECKS.slice(1), "needs the session route to answer");
373
+ } else {
374
+ await run(
375
+ SESSION_HEADER,
376
+ () => sessionRefusalProblem(sessionUrl, {}, 400, "embed_header_missing")
377
+ );
378
+ await run(
379
+ SESSION_CROSS_SITE,
380
+ () => sessionRefusalProblem(
381
+ sessionUrl,
382
+ {
383
+ [EMBED_HEADER]: EMBED_HEADER_VALUE,
384
+ "Sec-Fetch-Site": "cross-site"
385
+ },
386
+ 403,
387
+ "cross_site_request"
388
+ )
389
+ );
390
+ }
391
+ }
265
392
  const found = { wrongKey: false, balance: null, charged: null, reversed: false };
266
393
  const read = JSON.stringify({ type: "credits.get", userId });
267
394
  const tampered = JSON.stringify({
@@ -587,22 +714,34 @@ async function checkEndpoint(options) {
587
714
  }
588
715
 
589
716
  // src/cli.ts
590
- var USAGE = `Usage: npx @relume_io/partner-node check --url <endpoint> --user <userId> [--secret-key <sk_test_\u2026>]
717
+ var USAGE = `Usage: RELUME_SECRET_KEY=sk_test_\u2026 npx @relume_io/partner-node check --url <endpoint> --user <userId>
591
718
 
592
719
  Checks your partner endpoint against the Relume partner protocol: signatures,
593
720
  user.get, credits.get, and the idempotency and decline rules for charges and
594
- reversals.
721
+ reversals. It also checks that another site can't read your session route.
595
722
 
596
- --url The endpoint Relume calls, e.g. https://partner.example/relume/calls
597
- --user A user your wallet knows, with at least ${CHECK_CHARGE_CREDITS} credits. The check
598
- charges that user and gives every credit back.
599
- --secret-key Your test secret key. Defaults to $RELUME_SECRET_KEY, which
600
- keeps the key out of your shell history and CI logs.
601
- --live Allow an sk_live_ key. The check then charges a real wallet.
602
- -h, --help Show this help.
723
+ --url The endpoint Relume calls, e.g. https://partner.example/relume/calls
724
+ --user A user your wallet knows, with at least ${CHECK_CHARGE_CREDITS} credits. The check
725
+ charges that user and gives every credit back.
726
+ --session-url The session route your page's embed calls. Defaults to
727
+ /session next to a --url ending in /calls.
728
+ --session-header
729
+ A header for every request to the session route, as
730
+ "Name: value". Repeatable. Pass a test user's cookie when a
731
+ login answers that route before the SDK does. Prefer
732
+ $RELUME_SESSION_HEADERS, one header a line: a cookie is a
733
+ credential too.
734
+ --secret-key Your test secret key. Prefer $RELUME_SECRET_KEY, the default:
735
+ a key on the command line lands in your shell history, CI
736
+ logs and the process list.
737
+ --live Allow an sk_live_ key. The check then charges a real wallet.
738
+ -h, --help Show this help.
603
739
 
604
740
  Exits 0 when every check passes, 1 when any fails or can't run, and 2 for
605
741
  bad arguments.`;
742
+ var HEADER_NAME = /^[!#$%&'*+.^`|~\w-]+$/;
743
+ var HEADER_VALUE = /^[\t\x20-\x7e\x80-\xff]*$/;
744
+ var PROBE_HEADERS = /^(relume-embed|origin|sec-fetch-.*|access-control-request-.*)$/i;
606
745
  var LABEL = {
607
746
  pass: "PASS",
608
747
  fail: "FAIL",
@@ -623,6 +762,8 @@ ${USAGE}`);
623
762
  options: {
624
763
  url: { type: "string" },
625
764
  user: { type: "string" },
765
+ "session-url": { type: "string" },
766
+ "session-header": { type: "string", multiple: true },
626
767
  "secret-key": { type: "string" },
627
768
  live: { type: "boolean" },
628
769
  help: { type: "boolean", short: "h" }
@@ -653,6 +794,40 @@ ${USAGE}`);
653
794
  if (endpoint.protocol !== "https:" && endpoint.protocol !== "http:") {
654
795
  return usageError("--url must be an http or https URL.");
655
796
  }
797
+ let sessionUrl;
798
+ if (values["session-url"] !== void 0) {
799
+ const value = values["session-url"].trim();
800
+ let parsed2 = null;
801
+ try {
802
+ parsed2 = new URL(value);
803
+ } catch {
804
+ }
805
+ if (parsed2?.protocol !== "https:" && parsed2?.protocol !== "http:") {
806
+ return usageError(`--session-url must be an http or https URL: ${value}`);
807
+ }
808
+ sessionUrl = parsed2.href;
809
+ }
810
+ const headerLines = values["session-header"] ?? (io.env.RELUME_SESSION_HEADERS ?? "").split("\n").filter((line) => line.trim() !== "");
811
+ let sessionHeaders;
812
+ for (const header of headerLines) {
813
+ const colon = header.indexOf(":");
814
+ const name = header.slice(0, colon).trim();
815
+ const value = header.slice(colon + 1).trim();
816
+ if (colon < 0 || !HEADER_NAME.test(name)) {
817
+ return usageError('Each session header must be "Name: value".');
818
+ }
819
+ if (!HEADER_VALUE.test(value)) {
820
+ return usageError(
821
+ `The ${name} session header's value has characters a header can't carry.`
822
+ );
823
+ }
824
+ if (PROBE_HEADERS.test(name)) {
825
+ return usageError(
826
+ `A session header can't set ${name}: the check sets it to test the route.`
827
+ );
828
+ }
829
+ sessionHeaders = { ...sessionHeaders, [name]: value };
830
+ }
656
831
  const userId = values.user ?? "";
657
832
  if (userId.length < 1 || userId.length > 128) {
658
833
  return usageError("--user is required: a user id of 1 to 128 characters.");
@@ -677,6 +852,8 @@ ${USAGE}`);
677
852
  url: endpoint.href,
678
853
  secretKey,
679
854
  userId,
855
+ sessionUrl,
856
+ sessionHeaders,
680
857
  fetch: io.fetch,
681
858
  onResult: (result) => {
682
859
  io.log(`${LABEL[result.status]} ${result.name}`);