@relume_io/partner-node 0.0.0-stage → 1.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,333 @@
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` | 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, 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` |
92
+
93
+ Every response is JSON with `Cache-Control: no-store`.
94
+
95
+ ## The callbacks
96
+
97
+ Each callback gets a second argument, `{ type, requestId }`. Quote the
98
+ `requestId` to Relume support.
99
+
100
+ ### `onChargeCredits(charge) → { ok, balance? }`
101
+
102
+ - **Debit `partnerCredits` only.** `relumeCredits` is what Relume charges you
103
+ for the action, for your records. Never debit it from the user's wallet.
104
+ - **Idempotent on `chargeId`.** Relume retries a charge with the same
105
+ `chargeId`. Debit at most once, and answer a repeat with the same result.
106
+ - Return `ok: false` when the user can't afford it; the builder asks them to
107
+ top up.
108
+ - Throwing (or timing out) makes the outcome unknown. Relume then never does
109
+ the work and always sends `credits.reverse` for that `chargeId`.
110
+
111
+ ### `onReverseCharge(reversal) → { balance? } | void`
112
+
113
+ - **Credit back `partnerCredits` only**, at most once per `reversalId`.
114
+ - If `chargeId` names a charge you never debited (one that timed out before
115
+ reaching your wallet), credit nothing and return normally, and record that
116
+ `chargeId` as declined: an attempt can still reach your wallet after the
117
+ reversal (if your wallet stalled and caught up), and `onChargeCredits` must
118
+ then return `ok: false` for it without debiting. Recording it as a declined
119
+ charge lets your idempotency on `chargeId` do that.
120
+ - `chargeId` is absent for a staff refund. `reason` is `work_failed`,
121
+ `charge_unknown` or `staff_refund`; accept new values.
122
+ - Accept reversals for users you have since deactivated.
123
+
124
+ ### `onGetUser(userId) → { name?, email?, avatarUrl? } | null`
125
+
126
+ `name` at most 200 characters, `email` a valid address, `avatarUrl` an
127
+ `https` URL. Return `null` for an unknown user.
128
+
129
+ ### `onGetCredits(userId) → { balance }`
130
+
131
+ A whole number of your own credits, zero or more.
132
+
133
+ ### `getUserIdFromRequest(request) → userId | null`
134
+
135
+ Read the user from your own session. Never take a user id from the query
136
+ string or body. Gets the Express/Node request for `express()`, or the
137
+ `Request` for `fetchHandler`. Pass that type to `Relume.init<…>` to type
138
+ `req`; it is `unknown` otherwise.
139
+
140
+ ## Other frameworks
141
+
142
+ `relume.fetchHandler` is a `(Request) => Promise<Response>`.
143
+
144
+ **Next.js** (`app/relume/[route]/route.ts`):
145
+
146
+ ```ts
147
+ import { relume } from '@/lib/relume';
148
+
149
+ export const GET = relume.fetchHandler;
150
+ export const POST = relume.fetchHandler;
151
+ ```
152
+
153
+ **Hono:**
154
+
155
+ ```ts
156
+ app.all('/relume/*', (c) => relume.fetchHandler(c.req.raw));
157
+ ```
158
+
159
+ **Cloudflare Workers:**
160
+
161
+ ```ts
162
+ export default { fetch: (request: Request) => relume.fetchHandler(request) };
163
+ ```
164
+
165
+ **Anything else:** call `relume.handleCall({ rawBody, signatureHeader, requestId })`
166
+ and `relume.handleSession(request)`. Each returns `{ status, headers, body }`.
167
+
168
+ ### Raw bodies
169
+
170
+ The signature covers the raw bytes, so a re-serialised body never verifies.
171
+ Mount `relume.express()` before `express.json()`, or give it
172
+ `express.raw({ type: 'application/json' })`. If a parser already consumed the
173
+ body, the call is answered `500` and `onHandlerError` explains why.
174
+
175
+ ## Options
176
+
177
+ ```ts
178
+ Relume.init({
179
+ secretKey: 'sk_test_…', // required; test or live
180
+ apiBaseUrl: 'https://…', // defaults to production
181
+ apiTimeoutMs: 15_000, // per partner API request attempt
182
+ maxRetries: 2, // retries for 429s, 5xx answers and failed connections
183
+ fetch, // custom fetch for partner API requests
184
+ onHandlerError: (error, info) => logger.error({ error, ...info }),
185
+ });
186
+ ```
187
+
188
+ `Relume.init` returns a new client every time, with no global state, so test
189
+ and live clients can run side by side.
190
+
191
+ ## Calling the partner API
192
+
193
+ Every partner API endpoint has a typed method. Each throws a `RelumeError`
194
+ when the API refuses, with `status`, `code` (safe to switch on) and
195
+ `requestId` (quote it to Relume support).
196
+
197
+ ```ts
198
+ const { clientSecret } = await relume.sessions.create({ userId: '42' });
199
+
200
+ const user = await relume.users.get('42'); // { userId, status, siteCount, … }
201
+ await relume.users.deactivate('42');
202
+ await relume.users.reactivate('42');
203
+
204
+ const { siteId } = await relume.sites.create('42', { name: 'Bloom Florist' });
205
+ const site = await relume.sites.get(siteId);
206
+ const exported = await relume.exports.get(siteId, exportId); // not available yet: not_implemented
207
+
208
+ const entry = await relume.ledger.get('ch_…'); // with partnerResponse
209
+ const prices = await relume.prices.list();
210
+ ```
211
+
212
+ ```ts
213
+ try {
214
+ await relume.users.reactivate('42');
215
+ } catch (error) {
216
+ if (error instanceof RelumeError && error.code === 'user_suspended') {
217
+ // Only Relume staff can reactivate this user.
218
+ }
219
+ }
220
+ ```
221
+
222
+ ### Lists
223
+
224
+ `sites.list`, `ledger.list` and `usage.get` follow the cursor for you, a page
225
+ at a time as you iterate:
226
+
227
+ ```ts
228
+ for await (const site of relume.sites.list('42')) {
229
+ console.log(site.name);
230
+ }
231
+ ```
232
+
233
+ To reconcile your wallet, poll the ledger from the cursor you stored last
234
+ time. Entries come in change order and appear again when their status
235
+ changes, so upsert on `id`:
236
+
237
+ ```ts
238
+ const entries = relume.ledger.list({ after: await loadCursor() });
239
+ for await (const page of entries.pages()) {
240
+ await upsertEntries(page.data);
241
+ await saveCursor(page.nextCursor); // checkpoint after each page
242
+ }
243
+ ```
244
+
245
+ `entries.nextCursor` is also there after a plain `for await`: the cursor of
246
+ the last page you finished, so breaking out early never skips an entry.
247
+ Site lists can't be polled this way (new sites sort to the front): list them
248
+ from the beginning instead.
249
+
250
+ ```ts
251
+ const rows = await relume.usage
252
+ .get({ from: '2026-10-01', to: '2026-11-01', groupBy: ['user'] })
253
+ .toArray(); // [{ userId, partnerCredits: { net, … }, relumeCredits: { net, … } }]
254
+ ```
255
+
256
+ ### Retries
257
+
258
+ A `429` is retried after its `Retry-After` (unless that's over a minute).
259
+ A `5xx` (other than `501` and `505`) or a failed connection is retried with
260
+ exponential backoff, except on
261
+ `sites.create`, which isn't idempotent: a retry could create a second site.
262
+ A `Retry-After` on a `5xx` is honoured up to 8 seconds.
263
+ `maxRetries` (default 2) sets how many retries follow the first attempt.
264
+ The session route makes one attempt, without retries, because a browser is
265
+ waiting on it.
266
+
267
+ The request and response types (`User`, `Site`, `LedgerEntry`, `UsageRow`, …)
268
+ are exported, and the Relume API is type-checked against the same
269
+ definitions, so they match what the API sends.
270
+
271
+ ## Checking your endpoint
272
+
273
+ Before going live, run the conformance checker against your test endpoint.
274
+ It works on any endpoint, whether or not it uses this SDK:
275
+
276
+ ```sh
277
+ RELUME_SECRET_KEY=sk_test_… npx @relume_io/partner-node check \
278
+ --url https://partner.example/relume/calls --user 42
279
+ ```
280
+
281
+ It calls your endpoint the way Relume does, and checks that it:
282
+
283
+ - accepts a correctly signed call, including a rotation header with two
284
+ `v1=` values;
285
+ - rejects another key's signature, a body changed after signing, a timestamp
286
+ more than 5 minutes off either way, and a call with no signature, with
287
+ `401`;
288
+ - answers `user.get` and `credits.get` with bodies Relume accepts, and
289
+ `user.get` with `404` for a user id it has never seen;
290
+ - debits a charge, and only once when the same `chargeId` comes again, with
291
+ the same answer as the first time;
292
+ - answers `402` to a charge the user can't afford;
293
+ - changes nothing when a charge it never debited is reversed, and debits
294
+ nothing if that charge arrives afterwards (record a reversed `chargeId` as
295
+ declined, see `onReverseCharge` above);
296
+ - credits a reversal, and only once per `reversalId`.
297
+
298
+ `--user` must be a user your wallet knows, holding at least 2 credits. The
299
+ check charges them 2 and reverses everything it charged, and it prints a
300
+ `NOTE` if the balance doesn't end where it started. Each check prints `PASS`,
301
+ `FAIL` with the reason, or `SKIP` when an earlier failure means it can't run.
302
+ It exits `0` only when every check passes, so you can run it in CI. A live key
303
+ is refused unless you pass `--live`, because the check moves real credits.
304
+
305
+ Inside this repo, `npm run check -- --url … --user …` runs it from source.
306
+
307
+ ## Signing helpers
308
+
309
+ For your own tests, or another runtime: `verifySignature`, `signCall`,
310
+ `parseSignatureHeader` and `computeSignature`. They pass the protocol's
311
+ [test vectors](../../docs/partner-protocol/test-vectors.json).
312
+
313
+ ## Examples
314
+
315
+ [`examples/`](./examples) has the same partner on Express and on Hono, with
316
+ an in-memory wallet that shows the idempotency rules:
317
+
318
+ ```sh
319
+ RELUME_SECRET_KEY=sk_test_… npm run example:express # :4600
320
+ RELUME_SECRET_KEY=sk_test_… npm run example:fetch # :4601
321
+ ```
322
+
323
+ ## Developing
324
+
325
+ ```sh
326
+ npm ci
327
+ npm run typecheck
328
+ npm test # unit, vectors and both sample servers
329
+ npm run test:dist # builds, then loads the ESM and CJS output
330
+ ```
331
+
332
+ Releases go out from CI on a `partner-sdk/v*` tag, together with
333
+ `@relume_io/partner-embed`: see [Releasing](../README.md#releasing).