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