@relume_io/partner-node 2.0.1 → 2.2.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/README.md CHANGED
@@ -3,11 +3,10 @@
3
3
  The server SDK for Relume partners. It answers Relume's signed calls and
4
4
  serves builder sessions, so your backend never writes any cryptography.
5
5
 
6
- It implements the server side of the
7
- [Relume Partner Protocol v1](../../docs/partner-protocol/v1.md). Pair it with
8
- [`@relume_io/partner-embed`](../embed) on your page. The
9
- [partner docs](https://apis.relume.io/nova/docs/partners) have quickstarts for
10
- Express and Next.js, guides and the partner API reference.
6
+ Your page needs
7
+ [`@relume_io/partner-embed`](https://www.npmjs.com/package/@relume_io/partner-embed).
8
+ The [partner docs](https://apis.relume.io/nova/docs/partners) have
9
+ quickstarts, guides and the partner API reference.
11
10
 
12
11
  ## Install
13
12
 
@@ -16,7 +15,8 @@ npm install @relume_io/partner-node
16
15
  ```
17
16
 
18
17
  Node 20 or later, or any runtime with `fetch` and Web Crypto (Cloudflare
19
- Workers, Bun, Deno, Next.js edge). No runtime dependencies.
18
+ Workers, Bun, Deno, Next.js edge). No runtime dependencies. Install the same
19
+ version as `@relume_io/partner-embed`.
20
20
 
21
21
  ## Quickstart (Express)
22
22
 
@@ -55,102 +55,93 @@ app.use('/relume', relume.express()); // before express.json()
55
55
  app.use(express.json());
56
56
  ```
57
57
 
58
- Then, in Relume's partner settings, set your endpoint URL to
58
+ ## Endpoint URL
59
+
60
+ On the Partners page in Relume, set each environment's endpoint URL to
59
61
  `https://<your backend>/relume/calls`.
60
62
 
61
- ## What it mounts
62
-
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
-
68
- `relume.express()` answers exactly `/session` and `/calls` under its mount
69
- path (`app.use('/relume', …)`), or `/relume/session` and `/relume/calls` when
70
- mounted at the root, and passes everything else on. `relume.fetchHandler`
71
- answers any path ending in `/session` or `/calls`, because your framework has
72
- already routed the request to it.
73
-
74
- CORS preflights (`OPTIONS`) are left to your app: `express()` passes them on
75
- to the next middleware, and `fetchHandler` answers `204` with no CORS headers.
76
-
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 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` |
105
-
106
- Every response is JSON with `Cache-Control: no-store`.
107
-
108
- ## The callbacks
109
-
110
- Each callback gets a second argument, `{ type, requestId }`. Quote the
111
- `requestId` to Relume support.
63
+ - Relume's servers call it, so it must be reachable from the internet. On
64
+ `localhost`, use a tunnel such as `cloudflared tunnel --url http://localhost:3000`
65
+ or `ngrok http 3000`.
66
+ - Give test and live their own URLs, each served with that environment's
67
+ secret key. Live URLs must be `https`.
68
+ - The builder can't open for a new user until the endpoint answers: Relume
69
+ calls `user.get` before their first session.
70
+
71
+ ## Routes
72
+
73
+ | Route | Called by | Does |
74
+ | --------------------- | ------------- | ---------------------------------------------------------------------------------------------------------- |
75
+ | `GET /relume/session` | the embed SDK | Finds the user with `getUserIdFromRequest`, calls `POST /sessions`, returns `{ clientSecret, expiresAt }`. |
76
+ | `POST /relume/calls` | Relume | Verifies `Relume-Signature`, runs your callback for the call's `type`, checks what it returns. |
77
+
78
+ `relume.express()` answers `/session` and `/calls` under its mount path
79
+ (`/relume/session` and `/relume/calls` when mounted at the root), and passes
80
+ everything else on. `relume.fetchHandler` answers any path ending in
81
+ `/session` or `/calls`.
82
+
83
+ | Situation | Answer |
84
+ | ----------------------------------------------------------- | ------------------------------------------------------------ |
85
+ | Bad or missing signature, or a timestamp over 5 minutes off | `401 invalid_signature`; your callback isn't called |
86
+ | Unknown `type`, or no callback registered | `400 unsupported_call_type` |
87
+ | A body that isn't valid for its `type` | `400 invalid_request` |
88
+ | `environment` isn't your secret key's | `400 environment_mismatch`; your callback isn't called |
89
+ | `onGetUser` returns `null` | `404 user_not_found` |
90
+ | `onChargeCredits` returns `{ ok: false }` | `402 insufficient_credits`, with `balance` |
91
+ | A callback throws, or returns a bad shape | `500 internal_error`; `onHandlerError` gets the error |
92
+ | Session route: no `Relume-Embed: 1` header | `400 embed_header_missing` |
93
+ | Session route: `Sec-Fetch-Site: cross-site` | `403 cross_site_request` |
94
+ | Session route: nobody signed in | `401 not_signed_in` |
95
+ | Session route: Relume unreachable or timed out | `502 session_failed`, or `504` on a timeout |
96
+ | Session route: `POST /sessions` refused | Relume's status and `{ error }`, e.g. `403 user_deactivated` |
97
+
98
+ CORS preflights are left to your app: `express()` passes them to the next
99
+ middleware, and `fetchHandler` answers `204` with no CORS headers, so put your
100
+ CORS handling in front of it. If your page is on another origin from this
101
+ backend, pass `sessionUrl` to the embed SDK, and allow your page's exact
102
+ origin, with credentials and the `Relume-Embed` header, on the session route.
103
+ Never answer with `*` and never echo the request's `Origin`.
104
+
105
+ ## Callbacks
106
+
107
+ - **`getUserIdFromRequest(request) → userId | null`**: read the user from your
108
+ own session. Never take a user id from the query string or body. Pass your
109
+ framework's request type to `Relume.init<…>` to type `request`.
110
+
111
+ The four `on…` callbacks below answer Relume's calls. Each gets a second
112
+ argument, `{ type, environment, requestId }`. `environment` is always your
113
+ secret key's: `'test'` or `'live'`. Quote `requestId` to Relume support.
114
+
115
+ - **`onGetUser(userId) → { name?, email?, avatarUrl? } | null`**: `name` at
116
+ most 200 characters, `email` a valid address, `avatarUrl` an `https` URL.
117
+ `null` for a user you don't know.
118
+ - **`onGetCredits(userId) → { balance }`**: a whole number of your own
119
+ credits, zero or more.
112
120
 
113
121
  ### `onChargeCredits(charge) → { ok, balance? }`
114
122
 
115
- - **Debit `partnerCredits` only.** `relumeCredits` is what Relume charges you
116
- for the action, for your records. Never debit it from the user's wallet.
123
+ - **Debit `partnerCredits` only.** `relumeCredits` is what Relume charges
124
+ you. Never debit it from the user.
117
125
  - **Idempotent on `chargeId`.** Relume retries a charge with the same
118
126
  `chargeId`. Debit at most once, and answer a repeat with the same result.
119
- - Return `ok: false` when the user can't afford it; the builder asks them to
120
- top up.
121
- - Throwing (or timing out) makes the outcome unknown. Relume then never does
122
- the work and always sends `credits.reverse` for that `chargeId`.
127
+ - Return `ok: false` when the user can't afford it. The builder then asks
128
+ them to top up.
129
+ - If you throw or time out, Relume doesn't do the work, and sends a reversal
130
+ for that `chargeId`.
131
+ - A build of several pages is one charge, with `pageIds` instead of `pageId`
132
+ and both prices covering every page. Debit it like any other.
123
133
 
124
134
  ### `onReverseCharge(reversal) → { balance? } | void`
125
135
 
126
- - **Credit back `partnerCredits` only**, at most once per `reversalId`.
127
- - If `chargeId` names a charge you never debited (one that timed out before
128
- reaching your wallet), credit nothing and return normally, and record that
129
- `chargeId` as declined: an attempt can still reach your wallet after the
130
- reversal (if your wallet stalled and caught up), and `onChargeCredits` must
131
- then return `ok: false` for it without debiting. Recording it as a declined
132
- charge lets your idempotency on `chargeId` do that.
136
+ - **Credit back the reversal's `partnerCredits` only**, at most once per
137
+ `reversalId`. A reversal can cover part of a charge.
138
+ - **If `chargeId` names a charge you never debited**, credit nothing, return
139
+ normally, and record that `chargeId` as declined. If the charge reaches you
140
+ later, `onChargeCredits` must return `ok: false` without debiting.
133
141
  - `chargeId` is absent for a staff refund. `reason` is `work_failed`,
134
142
  `charge_unknown` or `staff_refund`; accept new values.
135
143
  - Accept reversals for users you have since deactivated.
136
144
 
137
- ### `onGetUser(userId) → { name?, email?, avatarUrl? } | null`
138
-
139
- `name` at most 200 characters, `email` a valid address, `avatarUrl` an
140
- `https` URL. Return `null` for an unknown user.
141
-
142
- ### `onGetCredits(userId) → { balance }`
143
-
144
- A whole number of your own credits, zero or more.
145
-
146
- ### `getUserIdFromRequest(request) → userId | null`
147
-
148
- Read the user from your own session. Never take a user id from the query
149
- string or body. Gets the Express/Node request for `express()`, or the
150
- `Request` for `fetchHandler`. Pass that type to `Relume.init<…>` to type
151
- `req`; it must have `headers` (`SessionRequest`), which every framework's
152
- request does, and is `{ headers: unknown }` otherwise.
153
-
154
145
  ## Other frameworks
155
146
 
156
147
  `relume.fetchHandler` is a `(Request) => Promise<Response>`.
@@ -170,30 +161,36 @@ export const POST = relume.fetchHandler;
170
161
  app.all('/relume/*', (c) => relume.fetchHandler(c.req.raw));
171
162
  ```
172
163
 
173
- **Cloudflare Workers:**
164
+ **Cloudflare Workers:** a Worker reads its secrets from `env` on each request,
165
+ so create the client there (`Relume.init` is cheap and holds no connection):
174
166
 
175
167
  ```ts
176
- export default { fetch: (request: Request) => relume.fetchHandler(request) };
168
+ export default {
169
+ fetch(request: Request, env: Env) {
170
+ const relume = Relume.init<Request>({ secretKey: env.RELUME_SECRET_KEY });
171
+ // …register getUserIdFromRequest and the four callbacks…
172
+ return relume.fetchHandler(request);
173
+ },
174
+ };
177
175
  ```
178
176
 
179
177
  **Anything else:** call `relume.handleCall({ rawBody, signatureHeader, requestId })`
180
178
  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`.
183
-
184
- ### Raw bodies
179
+ Pass `handleSession` the fetch `Request`, the Node request, or an object with
180
+ `headers`.
185
181
 
186
- The signature covers the raw bytes, so a re-serialised body never verifies.
187
- Mount `relume.express()` before `express.json()`, or give it
188
- `express.raw({ type: 'application/json' })`. If a parser already consumed the
189
- body, the call is answered `500` and `onHandlerError` explains why.
182
+ **Raw bodies:** the signature covers the raw bytes, so a parsed body never
183
+ verifies. Mount `relume.express()` before `express.json()`, or give it
184
+ `express.raw({ type: 'application/json' })`. Add nothing that parses the
185
+ body in front of `fetchHandler`. If a parser got there first, the call is
186
+ answered `500` and `onHandlerError` says why.
190
187
 
191
188
  ## Options
192
189
 
193
190
  ```ts
194
191
  Relume.init({
195
- secretKey: 'sk_test_…', // required; test or live
196
- apiBaseUrl: 'https://…', // defaults to production; http only for localhost
192
+ secretKey: 'sk_test_…', // required; sk_test_… or sk_live_…
193
+ apiBaseUrl: 'https://…', // default: production's; http only for localhost
197
194
  apiTimeoutMs: 15_000, // per partner API request attempt
198
195
  maxRetries: 2, // retries for 429s, 5xx answers and failed connections
199
196
  fetch, // custom fetch for partner API requests
@@ -201,16 +198,13 @@ Relume.init({
201
198
  });
202
199
  ```
203
200
 
204
- `Relume.init` returns a new client every time, with no global state, so test
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.
201
+ `Relume.init` throws for a missing key, or one that isn't `sk_test_…` or
202
+ `sk_live_…`. It returns a new client every time, so test and live clients can
203
+ run side by side.
208
204
 
209
205
  ## Calling the partner API
210
206
 
211
- Every partner API endpoint has a typed method. Each throws a `RelumeError`
212
- when the API refuses, with `status`, `code` (safe to switch on) and
213
- `requestId` (quote it to Relume support).
207
+ Every partner API endpoint has a typed method.
214
208
 
215
209
  ```ts
216
210
  const { clientSecret } = await relume.sessions.create({ userId: '42' });
@@ -221,12 +215,26 @@ await relume.users.reactivate('42');
221
215
 
222
216
  const { siteId } = await relume.sites.create('42', { name: 'Bloom Florist' });
223
217
  const site = await relume.sites.get(siteId);
224
- const exported = await relume.exports.get(siteId, exportId); // not available yet: not_implemented
218
+ const exported = await relume.exports.get(siteId, exportId); // not available yet: 501 not_implemented
225
219
 
226
220
  const entry = await relume.ledger.get('ch_…'); // with partnerResponse
227
- const prices = await relume.prices.list();
221
+
222
+ const { data, creditUnit } = await relume.prices.get();
223
+ // data: [{ action, partnerCredits (null until Relume sets it), relumeCredits }]
224
+ // creditUnit: { singular: 'coin', plural: 'coins', iconUrl? }
228
225
  ```
229
226
 
227
+ - `prices.get()` returns your prices and `creditUnit`: what the builder calls
228
+ your credits, and the icon it shows beside amounts. `prices.list()` returns
229
+ the prices alone.
230
+ - A user who has never opened the builder has no Relume account, so
231
+ `sites.list` and `sites.create` answer `user_not_found`. Before creating a
232
+ site for them, call `sessions.create({ userId })`: it creates the account.
233
+ - `sites.create` isn't idempotent, so it retries only a `429`.
234
+
235
+ Each method throws a `RelumeError` when the API refuses, with `status`,
236
+ `code` (safe to switch on) and `requestId` (quote it to Relume support):
237
+
230
238
  ```ts
231
239
  try {
232
240
  await relume.users.reactivate('42');
@@ -239,18 +247,12 @@ try {
239
247
 
240
248
  ### Lists
241
249
 
242
- `sites.list`, `ledger.list` and `usage.get` follow the cursor for you, a page
243
- at a time as you iterate:
244
-
245
- ```ts
246
- for await (const site of relume.sites.list('42')) {
247
- console.log(site.name);
248
- }
249
- ```
250
+ `sites.list`, `ledger.list` and `usage.get` follow the cursor for you: iterate
251
+ items with `for await`, pages with `.pages()`, or collect them with
252
+ `.toArray()`.
250
253
 
251
254
  To reconcile your wallet, poll the ledger from the cursor you stored last
252
- time. Entries come in change order and appear again when their status
253
- changes, so upsert on `id`:
255
+ time. An entry appears again when its status changes, so upsert on `id`:
254
256
 
255
257
  ```ts
256
258
  const entries = relume.ledger.list({ after: await loadCursor() });
@@ -260,111 +262,54 @@ for await (const page of entries.pages()) {
260
262
  }
261
263
  ```
262
264
 
263
- `entries.nextCursor` is also there after a plain `for await`: the cursor of
264
- the last page you finished, so breaking out early never skips an entry.
265
- Site lists can't be polled this way (new sites sort to the front): list them
266
- from the beginning instead.
267
-
268
- ```ts
269
- const rows = await relume.usage
270
- .get({ from: '2026-10-01', to: '2026-11-01', groupBy: ['user'] })
271
- .toArray(); // [{ userId, partnerCredits: { net, … }, relumeCredits: { net, … } }]
272
- ```
265
+ After a plain `for await`, `entries.nextCursor` is the cursor of the last
266
+ page you finished, so breaking out early never skips an entry. Site lists
267
+ can't be polled this way, because new sites sort to the front: list them from
268
+ the beginning each time.
273
269
 
274
270
  ### Retries
275
271
 
276
- A `429` is retried after its `Retry-After` (unless that's over a minute).
277
- A `5xx` (other than `501` and `505`) or a failed connection is retried with
278
- exponential backoff, except on
279
- `sites.create`, which isn't idempotent: a retry could create a second site.
280
- A `Retry-After` on a `5xx` is honoured up to 8 seconds.
281
- `maxRetries` (default 2) sets how many retries follow the first attempt.
282
- The session route makes one attempt, without retries, because a browser is
283
- waiting on it.
284
-
285
- The request and response types (`User`, `Site`, `LedgerEntry`, `UsageRow`, …)
286
- are exported, and the Relume API is type-checked against the same
287
- definitions, so they match what the API sends.
272
+ A `429` is retried after its `Retry-After`, unless that's over a minute. A
273
+ `5xx` (except `501` and `505`) or a failed connection is retried with
274
+ exponential backoff. `maxRetries` (default 2) sets how many retries follow the
275
+ first attempt. The session route makes one attempt, because a browser is
276
+ waiting.
288
277
 
289
278
  ## Checking your endpoint
290
279
 
291
- Before going live, run the conformance checker against your test endpoint.
292
- It works on any endpoint, whether or not it uses this SDK:
280
+ Before going live, run the conformance checker against your test endpoint. It
281
+ works on any endpoint, whether or not it uses this SDK:
293
282
 
294
283
  ```sh
295
284
  RELUME_SECRET_KEY=sk_test_… npx @relume_io/partner-node check \
296
285
  --url https://partner.example/relume/calls --user 42
297
286
  ```
298
287
 
299
- It calls your endpoint the way Relume does, and checks that it:
300
-
301
- - accepts a correctly signed call, including a rotation header with two
302
- `v1=` values;
303
- - rejects another key's signature, a body changed after signing, a timestamp
304
- more than 5 minutes off either way, and a call with no signature, with
305
- `401`;
306
- - answers `user.get` and `credits.get` with bodies Relume accepts, and
307
- `user.get` with `404` for a user id it has never seen;
308
- - debits a charge, and only once when the same `chargeId` comes again, with
309
- the same answer as the first time;
310
- - answers `402` to a charge the user can't afford;
311
- - changes nothing when a charge it never debited is reversed, and debits
312
- nothing if that charge arrives afterwards (record a reversed `chargeId` as
313
- declined, see `onReverseCharge` above);
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.
331
- `--user` must be a user your wallet knows, holding at least 2 credits. The
332
- check charges them 2 and reverses everything it charged, and it prints a
333
- `NOTE` if the balance doesn't end where it started. Each check prints `PASS`,
334
- `FAIL` with the reason, or `SKIP` when an earlier failure means it can't run.
335
- It exits `0` only when every check passes, so you can run it in CI. A live key
336
- is refused unless you pass `--live`, because the check moves real credits.
337
-
338
- Inside this repo, `npm run check -- --url … --user …` runs it from source.
288
+ It calls your endpoint the way Relume does, and checks signatures,
289
+ environments, idempotency on `chargeId` and `reversalId`, the
290
+ unknown-charge rule and the session route's refusals. The
291
+ [partner docs](https://apis.relume.io/nova/docs/partners#tag/checking-your-endpoint)
292
+ list every check.
293
+
294
+ - `--user` must be a user your wallet knows, holding at least 2 credits. The
295
+ check charges them 2 and reverses everything it charged.
296
+ - Pass the key in `RELUME_SECRET_KEY`, not `--secret-key`: a key on the
297
+ command line lands in shell history and CI logs.
298
+ - It finds the session route next to a `--url` ending in `/calls`; otherwise
299
+ pass `--session-url`. If a login guards that route, give the check a test
300
+ user's cookie in `RELUME_SESSION_HEADERS` (one `Name: value` a line) or with
301
+ `--session-header "Cookie: …"` (repeatable). Prefer the variable: a cookie
302
+ on the command line leaks like a key does.
303
+ - It exits `0` only when every check passes, so you can run it in CI. A live
304
+ key is refused unless you pass `--live`, because the check moves real
305
+ credits.
339
306
 
340
307
  ## Signing helpers
341
308
 
342
309
  For your own tests, or another runtime: `verifySignature`, `signCall`,
343
310
  `parseSignatureHeader` and `computeSignature`. They pass the protocol's
344
- [test vectors](../../docs/partner-protocol/test-vectors.json).
345
-
346
- ## Examples
347
-
348
- [`examples/`](./examples) has the same partner on Express and on Hono, with
349
- an in-memory wallet that shows the idempotency rules:
350
-
351
- ```sh
352
- RELUME_SECRET_KEY=sk_test_… npm run example:express # :4600
353
- RELUME_SECRET_KEY=sk_test_… npm run example:fetch # :4601
354
- ```
355
-
356
- ## Developing
357
-
358
- ```sh
359
- npm ci
360
- npm run typecheck
361
- npm test # unit, vectors and both sample servers
362
- npm run test:dist # builds, then loads the ESM and CJS output
363
- ```
364
-
365
- Releases go out from CI on a `partner-sdk/v*` tag, together with
366
- `@relume_io/partner-embed`: see [Releasing](../README.md#releasing).
311
+ [test vectors](https://apis.relume.io/nova/docs/partners/test-vectors.json).
367
312
 
368
313
  ## License
369
314
 
370
- MIT. See [LICENSE](./LICENSE).
315
+ MIT
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)) {
@@ -390,9 +403,10 @@ async function checkEndpoint(options) {
390
403
  }
391
404
  }
392
405
  const found = { wrongKey: false, balance: null, charged: null, reversed: false };
393
- const read = JSON.stringify({ type: "credits.get", userId });
406
+ const read = JSON.stringify({ type: "credits.get", environment, userId });
394
407
  const tampered = JSON.stringify({
395
408
  type: "credits.get",
409
+ environment,
396
410
  userId,
397
411
  note: "changed after signing"
398
412
  });
@@ -420,6 +434,7 @@ async function checkEndpoint(options) {
420
434
  STALE,
421
435
  FUTURE,
422
436
  UNSIGNED,
437
+ OTHER_ENVIRONMENT,
423
438
  USER_GET,
424
439
  UNKNOWN_USER,
425
440
  CREDITS_GET,
@@ -454,8 +469,22 @@ async function checkEndpoint(options) {
454
469
  return `${said(answer)}; expected 401 invalid_signature`;
455
470
  });
456
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
+ });
457
483
  await run(USER_GET, async () => {
458
- 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
+ );
459
488
  if (answer.answered && answer.status === 404) {
460
489
  return `answered 404: it doesn't know user "${userId}". Pass a --user your wallet knows`;
461
490
  }
@@ -466,7 +495,7 @@ async function checkEndpoint(options) {
466
495
  });
467
496
  await run(UNKNOWN_USER, async () => {
468
497
  const answer = await signed(
469
- { type: "user.get", userId: randomId("u_check_unknown") },
498
+ { type: "user.get", environment, userId: randomId("u_check_unknown") },
470
499
  READ_TIMEOUT_MS
471
500
  );
472
501
  return answer.answered && answer.status === 404 ? null : `${said(answer)} for a user id your wallet has never seen; expected 404`;
@@ -506,6 +535,7 @@ async function checkEndpoint(options) {
506
535
  const answer = await signed(
507
536
  {
508
537
  type: "credits.reverse",
538
+ environment,
509
539
  userId,
510
540
  reversalId: randomId("rv_check"),
511
541
  ...left,
@@ -525,6 +555,7 @@ async function checkEndpoint(options) {
525
555
  const chargeId = randomId("ch_check");
526
556
  const charge = JSON.stringify({
527
557
  type: "credits.charge",
558
+ environment,
528
559
  userId,
529
560
  chargeId,
530
561
  partnerCredits: CHECK_CHARGE_CREDITS,
@@ -581,6 +612,7 @@ async function checkEndpoint(options) {
581
612
  const answer = await signed(
582
613
  {
583
614
  type: "credits.charge",
615
+ environment,
584
616
  userId,
585
617
  chargeId: declinedId,
586
618
  partnerCredits: amount,
@@ -611,6 +643,7 @@ async function checkEndpoint(options) {
611
643
  const answer = await signed(
612
644
  {
613
645
  type: "credits.reverse",
646
+ environment,
614
647
  userId,
615
648
  reversalId: randomId("rv_check"),
616
649
  partnerCredits: CHECK_CHARGE_CREDITS,
@@ -640,6 +673,7 @@ async function checkEndpoint(options) {
640
673
  const answer = await signed(
641
674
  {
642
675
  type: "credits.charge",
676
+ environment,
643
677
  userId,
644
678
  chargeId: lateId,
645
679
  partnerCredits: CHECK_CHARGE_CREDITS,
@@ -659,6 +693,7 @@ async function checkEndpoint(options) {
659
693
  }
660
694
  const reversal = JSON.stringify({
661
695
  type: "credits.reverse",
696
+ environment,
662
697
  userId,
663
698
  reversalId: randomId("rv_check"),
664
699
  partnerCredits: 1,
@@ -841,6 +876,11 @@ ${USAGE}`);
841
876
  "That is a publishable key. Pass the secret key (sk_test_\u2026)."
842
877
  );
843
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
+ }
844
884
  if (secretKey.startsWith("sk_live_") && !values.live) {
845
885
  return usageError(
846
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."