@relume_io/partner-node 0.0.0-stage → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,362 @@
1
- # Temporary Holding Version
1
+ # @relume_io/partner-node
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The server SDK for Relume partners. It answers Relume's signed calls and
4
+ serves builder sessions, so your backend never writes any cryptography.
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.
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ npm install @relume_io/partner-node
16
+ ```
17
+
18
+ Node 20 or later, or any runtime with `fetch` and Web Crypto (Cloudflare
19
+ Workers, Bun, Deno, Next.js edge). No runtime dependencies.
20
+
21
+ ## Quickstart (Express)
22
+
23
+ ```ts
24
+ import express from 'express';
25
+ import { Relume } from '@relume_io/partner-node';
26
+
27
+ const relume = Relume.init<express.Request>({
28
+ secretKey: process.env.RELUME_SECRET_KEY!,
29
+ });
30
+
31
+ // Who is signed in, for the builder's GET /relume/session
32
+ relume.getUserIdFromRequest((req) => req.session?.userId ?? null);
33
+
34
+ relume.onGetUser(async (userId) => {
35
+ const user = await db.users.find(userId);
36
+ return user ? { name: user.name, email: user.email } : null; // null → 404
37
+ });
38
+ relume.onGetCredits(async (userId) => ({
39
+ balance: await wallet.balance(userId),
40
+ }));
41
+ relume.onChargeCredits(
42
+ async ({ userId, chargeId, partnerCredits }) =>
43
+ wallet.debit(userId, partnerCredits, { ref: chargeId }), // → { ok, balance }
44
+ );
45
+ relume.onReverseCharge(
46
+ async ({ userId, reversalId, chargeId, partnerCredits }) =>
47
+ wallet.credit(userId, partnerCredits, {
48
+ ref: reversalId,
49
+ reverses: chargeId,
50
+ }),
51
+ );
52
+
53
+ const app = express();
54
+ app.use('/relume', relume.express()); // before express.json()
55
+ app.use(express.json());
56
+ ```
57
+
58
+ Then, in Relume's partner settings, set your endpoint URL to
59
+ `https://<your backend>/relume/calls`.
60
+
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.
112
+
113
+ ### `onChargeCredits(charge) → { ok, balance? }`
114
+
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.
117
+ - **Idempotent on `chargeId`.** Relume retries a charge with the same
118
+ `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`.
123
+
124
+ ### `onReverseCharge(reversal) → { balance? } | void`
125
+
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.
133
+ - `chargeId` is absent for a staff refund. `reason` is `work_failed`,
134
+ `charge_unknown` or `staff_refund`; accept new values.
135
+ - Accept reversals for users you have since deactivated.
136
+
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
+ ## Other frameworks
155
+
156
+ `relume.fetchHandler` is a `(Request) => Promise<Response>`.
157
+
158
+ **Next.js** (`app/relume/[route]/route.ts`):
159
+
160
+ ```ts
161
+ import { relume } from '@/lib/relume';
162
+
163
+ export const GET = relume.fetchHandler;
164
+ export const POST = relume.fetchHandler;
165
+ ```
166
+
167
+ **Hono:**
168
+
169
+ ```ts
170
+ app.all('/relume/*', (c) => relume.fetchHandler(c.req.raw));
171
+ ```
172
+
173
+ **Cloudflare Workers:**
174
+
175
+ ```ts
176
+ export default { fetch: (request: Request) => relume.fetchHandler(request) };
177
+ ```
178
+
179
+ **Anything else:** call `relume.handleCall({ rawBody, signatureHeader, requestId })`
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`.
183
+
184
+ ### Raw bodies
185
+
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.
190
+
191
+ ## Options
192
+
193
+ ```ts
194
+ Relume.init({
195
+ secretKey: 'sk_test_…', // required; test or live
196
+ apiBaseUrl: 'https://…', // defaults to production; http only for localhost
197
+ apiTimeoutMs: 15_000, // per partner API request attempt
198
+ maxRetries: 2, // retries for 429s, 5xx answers and failed connections
199
+ fetch, // custom fetch for partner API requests
200
+ onHandlerError: (error, info) => logger.error({ error, ...info }),
201
+ });
202
+ ```
203
+
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.
208
+
209
+ ## Calling the partner API
210
+
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).
214
+
215
+ ```ts
216
+ const { clientSecret } = await relume.sessions.create({ userId: '42' });
217
+
218
+ const user = await relume.users.get('42'); // { userId, status, siteCount, … }
219
+ await relume.users.deactivate('42');
220
+ await relume.users.reactivate('42');
221
+
222
+ const { siteId } = await relume.sites.create('42', { name: 'Bloom Florist' });
223
+ const site = await relume.sites.get(siteId);
224
+ const exported = await relume.exports.get(siteId, exportId); // not available yet: not_implemented
225
+
226
+ const entry = await relume.ledger.get('ch_…'); // with partnerResponse
227
+ const prices = await relume.prices.list();
228
+ ```
229
+
230
+ ```ts
231
+ try {
232
+ await relume.users.reactivate('42');
233
+ } catch (error) {
234
+ if (error instanceof RelumeError && error.code === 'user_suspended') {
235
+ // Only Relume staff can reactivate this user.
236
+ }
237
+ }
238
+ ```
239
+
240
+ ### Lists
241
+
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
+
251
+ 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`:
254
+
255
+ ```ts
256
+ const entries = relume.ledger.list({ after: await loadCursor() });
257
+ for await (const page of entries.pages()) {
258
+ await upsertEntries(page.data);
259
+ await saveCursor(page.nextCursor); // checkpoint after each page
260
+ }
261
+ ```
262
+
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
+ ```
273
+
274
+ ### Retries
275
+
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.
288
+
289
+ ## Checking your endpoint
290
+
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:
293
+
294
+ ```sh
295
+ RELUME_SECRET_KEY=sk_test_… npx @relume_io/partner-node check \
296
+ --url https://partner.example/relume/calls --user 42
297
+ ```
298
+
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.
324
+
325
+ Give it the key in `RELUME_SECRET_KEY`, not `--secret-key`: a key on the
326
+ command line lands in your shell history, CI logs and the process list.
327
+ `--user` must be a user your wallet knows, holding at least 2 credits. The
328
+ check charges them 2 and reverses everything it charged, and it prints a
329
+ `NOTE` if the balance doesn't end where it started. Each check prints `PASS`,
330
+ `FAIL` with the reason, or `SKIP` when an earlier failure means it can't run.
331
+ It exits `0` only when every check passes, so you can run it in CI. A live key
332
+ is refused unless you pass `--live`, because the check moves real credits.
333
+
334
+ Inside this repo, `npm run check -- --url … --user …` runs it from source.
335
+
336
+ ## Signing helpers
337
+
338
+ For your own tests, or another runtime: `verifySignature`, `signCall`,
339
+ `parseSignatureHeader` and `computeSignature`. They pass the protocol's
340
+ [test vectors](../../docs/partner-protocol/test-vectors.json).
341
+
342
+ ## Examples
343
+
344
+ [`examples/`](./examples) has the same partner on Express and on Hono, with
345
+ an in-memory wallet that shows the idempotency rules:
346
+
347
+ ```sh
348
+ RELUME_SECRET_KEY=sk_test_… npm run example:express # :4600
349
+ RELUME_SECRET_KEY=sk_test_… npm run example:fetch # :4601
350
+ ```
351
+
352
+ ## Developing
353
+
354
+ ```sh
355
+ npm ci
356
+ npm run typecheck
357
+ npm test # unit, vectors and both sample servers
358
+ npm run test:dist # builds, then loads the ESM and CJS output
359
+ ```
360
+
361
+ Releases go out from CI on a `partner-sdk/v*` tag, together with
362
+ `@relume_io/partner-embed`: see [Releasing](../README.md#releasing).