@rekey.dev/node 1.1.2

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 ReliPay
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 ADDED
@@ -0,0 +1,235 @@
1
+ # `@rekey.dev/node`
2
+
3
+ The server SDK for [Rekey](https://relipay.dev) — auth, billing, usage, credits, licenses, and teams for your application, from any server-side TypeScript runtime (Node, Bun, Deno, Express, Fastify, Nest, Hono).
4
+
5
+ > **For AI coding agents:** start at [AGENTS.md](./AGENTS.md) — it has the do-this-first rules.
6
+
7
+ ```bash
8
+ npm i @rekey.dev/node
9
+ # or: pnpm add @rekey.dev/node / yarn add @rekey.dev/node
10
+ ```
11
+
12
+ ## Setup
13
+
14
+ One client instance per Application, constructed with that Application's **secret key**:
15
+
16
+ | Key | Format | Where to get it |
17
+ | --- | --- | --- |
18
+ | Secret key | `rp_live_…` (production) or `rp_test_…` (sandbox) | Panel → Application → API Keys |
19
+ | API URL | `https://api.relipay.dev` or `http://localhost:3030` | Your Rekey deployment |
20
+
21
+ Convention: read both from the environment — never hardcode.
22
+
23
+ ```bash
24
+ RELIPAY_URL=https://api.relipay.dev
25
+ RELIPAY_SECRET=rp_live_… # the Application secret key
26
+ ```
27
+
28
+ > **Never ship the secret key to the browser.** It authenticates as the whole
29
+ > Application — anyone holding it can act on every end-user. Browser code must
30
+ > use the Application's **public** key (`rp_pub_…`) via [`@rekey.dev/react`](../sdk-react)
31
+ > or [`@rekey.dev/nextjs`](../sdk-nextjs) instead. The flow is always:
32
+ > browser → your backend → Rekey.
33
+
34
+ ## Quickstart
35
+
36
+ ```ts
37
+ import { Rekey } from '@rekey.dev/node';
38
+
39
+ // Module-level singleton — don't construct one per request.
40
+ const rekey = new Rekey({
41
+ apiUrl: process.env.RELIPAY_URL!,
42
+ secretKey: process.env.RELIPAY_SECRET!,
43
+ });
44
+
45
+ // 1. Smoke test: verifies your credentials and returns the Application.
46
+ const me = await rekey.applications.me();
47
+ console.log(`Connected to "${me.name}" (${me.slug})`);
48
+
49
+ // 2. Create an end-user. Returns the user + a session token pair.
50
+ const { endUser, accessToken } = await rekey.auth.signUp({
51
+ email: 'alice@example.com',
52
+ password: 'correct-horse-battery-staple',
53
+ });
54
+
55
+ // 3. Gate features on the user's resolved entitlements (server-side).
56
+ const { features, creditBalance } = await rekey.billing.getEntitlements(accessToken);
57
+ if (features.advanced_reporting) renderReportingTab();
58
+ ```
59
+
60
+ ### The two-credential model
61
+
62
+ Per-user calls require **two** credentials together:
63
+
64
+ 1. The **Application secret key** — proves *which Application*. Set once at construction; sent as `Authorization: Bearer …` automatically.
65
+ 2. The **user JWT** (returned by `signUp` / `signIn`) — proves *which end-user*. You pass it as the first argument to per-user methods; the SDK puts it in `X-Rekey-User-Token`.
66
+
67
+ A JWT issued by Application A presented through Application B's secret key is refused with `USER_TOKEN_WRONG_APPLICATION` (401), by design.
68
+
69
+ ## Core API
70
+
71
+ Everything hangs off namespaces on the client. `amount` fields are always integers in the smallest currency unit (cents/paise/sen) — never floats.
72
+
73
+ ### `rekey.applications`
74
+ | Method | Description |
75
+ | --- | --- |
76
+ | `me()` | Verify credentials + fetch the calling Application (your smoke test). |
77
+
78
+ ### `rekey.auth`
79
+ | Method | Description |
80
+ | --- | --- |
81
+ | `signUp({ email, password, metadata? })` | Create an end-user; returns user + token pair. |
82
+ | `signIn({ email, password })` | Authenticate. Returns a `SignInOutcome` — **branch on `mfaRequired`** before reading `accessToken`. |
83
+ | `mfaVerify({ mfaChallengeToken, code })` | Exchange an MFA challenge for a real session. |
84
+ | `getCurrentUser(accessToken)` | Resolve the end-user behind a token (+ `activeOrganizationId`). |
85
+ | `refresh(refreshToken)` | Rotate the token pair. Single-use — store the new refresh immediately. |
86
+ | `signOut(refreshToken)` / `signOutEverywhere(accessToken)` | Revoke one / all refresh tokens. |
87
+ | `requestPasswordReset({ email, resetUrl? })` / `resetPassword({ token, newPassword })` | Reset flow. **Branch on `emailSent`** — see [Email-sending methods](#email-sending-methods-branch-on-emailsent). |
88
+ | `changePassword(accessToken, { currentPassword, newPassword })` | Authenticated change; kills other sessions. |
89
+ | `requestMagicLink({ email, signInUrl? })` / `verifyMagicLink(...)` | Passwordless sign-in. Same `emailSent` contract as password reset. |
90
+ | `sendVerificationEmail(...)` / `verifyEmail(...)` | Email verification. Same `emailSent` contract (no `delivered` field — the caller is already authenticated). |
91
+ | `listSessions(accessToken)` / `revokeSession(...)` | Active-session management. |
92
+ | `mfaStatus` / `mfaSetup` / `confirmMfaSetup` / `mfaChallenge` / `disableMfa` | TOTP enrollment + step-up. |
93
+ | `startPasskeyAuthentication` / `verifyPasskeyAuthentication` / `startPasskeyRegistration` / `verifyPasskeyRegistration` / `listPasskeys` / `deletePasskey` | WebAuthn / passkeys. |
94
+ | `startOAuth` / `completeOAuth` / `listOAuthIdentities` / `startOAuthLink` / `completeOAuthLink` / `unlinkOAuth` | Social sign-in + account linking. |
95
+
96
+ #### Email-sending methods: branch on `emailSent`
97
+
98
+ `requestPasswordReset` and `requestMagicLink` return a different shape depending on whether the Application has an email transport configured (BYO Resend creds, or `RESEND_DEFAULT_*` on the deployment). They never throw for an unknown email — enumeration-safe by design.
99
+
100
+ | Case | `delivered` | `emailSent` | token (`resetToken` / `magicLinkToken`) | Your job |
101
+ | --- | --- | --- | --- | --- |
102
+ | Transport configured, user exists | `true` | `true` | `null` | Nothing — Rekey sent the email. |
103
+ | No transport (or send failed), user exists | `true` | `false` | the raw token | **You** email the link via your own provider. |
104
+ | Unknown email | `false` | `false` | `null` | Nothing. Render the same neutral UI — never reveal. |
105
+
106
+ ```ts
107
+ const r = await rekey.auth.requestPasswordReset({
108
+ email,
109
+ resetUrl: 'https://yourapp.com/reset?token={token}', // {token} substituted into the email
110
+ });
111
+ if (!r.emailSent && r.resetToken) {
112
+ await mailer.send({ to: email, text: `Reset: https://yourapp.com/reset?token=${encodeURIComponent(r.resetToken)}` });
113
+ }
114
+ // Always show "if that address exists, we sent a link" — even when delivered === false.
115
+ ```
116
+
117
+ `sendVerificationEmail` follows the same contract with `{ emailSent, verificationToken }` (no `delivered` — the caller is already authenticated, so there's nothing to hide).
118
+
119
+ ### `rekey.billing`
120
+ | Method | Description |
121
+ | --- | --- |
122
+ | `getPlans()` | List the Application's active plans (public — render pricing pages). |
123
+ | `getSubscription(accessToken)` | The user's active subscription, or `null`. |
124
+ | `createCheckout(accessToken, { planSlug, successUrl, cancelUrl, couponCode?, organizationId? })` | Start hosted checkout; returns the redirect URL + a PENDING subscription. Activation happens via the provider webhook. |
125
+ | `validateCoupon(accessToken, { code, planSlug })` | Price-check a coupon without applying it. |
126
+ | `getProviders(country?)` | The geo-routed list of enabled billing providers. |
127
+ | `getEntitlements(accessToken, { organizationId? })` | Resolve feature flags + limits + live credit balance. **Gate your app on this.** Cache it with a ~5-min TTL / stale-while-revalidate and bust on checkout success — see ["Caching entitlements" in docs/billing.md](../../docs/billing.md#caching-entitlements). |
128
+
129
+ ### `rekey.organizations` (teams)
130
+ | Method | Description |
131
+ | --- | --- |
132
+ | `create(accessToken, { name, slug })` | Create an org; caller becomes OWNER. |
133
+ | `listMine(accessToken, page?)` | Orgs the user belongs to (paginated — see Gotchas). |
134
+ | `get` / `update` / `listMembers(…, page?)` | Read / update org + list members (paginated). |
135
+ | `invite` / `revokeInvitation` / `acceptInvitation` | Invitation flow (raw token surfaced once). |
136
+ | `setMemberRole` / `removeMember` / `leave` | Membership management (last-OWNER guarded). |
137
+ | `switch(accessToken, orgId)` / `clearActive(accessToken)` | Set / clear the session's active org. **Returns a fresh token pair — store both.** |
138
+
139
+ ### `rekey.usage`
140
+ | Method | Description |
141
+ | --- | --- |
142
+ | `record({ meterSlug, quantity, endUserId? \| organizationId? })` | Record a metering event (quantity may be negative). |
143
+ | `aggregate({ meterSlug, from?, to?, endUserId?, organizationId? })` | Sum a meter over a window / subject. |
144
+
145
+ ### `rekey.credits` (prepaid / pay-as-you-go)
146
+ | Method | Description |
147
+ | --- | --- |
148
+ | `getBalance(subject)` | Spendable balance for `{ endUserId }` or `{ organizationId }`. |
149
+ | `consume(subject & { amount, idempotencyKey? })` | Idempotent drawdown. Throws `CREDITS_INSUFFICIENT` (402) when too low. |
150
+ | `listLedger(subject, limit?, offset?)` | Ledger entries, newest first (default 50, max 200). |
151
+
152
+ #### `idempotencyKey` scoping & retry semantics
153
+
154
+ The key is unique per **Application** — `(applicationId, idempotencyKey)` on the append-only ledger — **not** per subject. Two consumes with the same key but different `endUserId`s collide: the second silently replays the first entry and **does not charge the second user**. So always embed the subject and the operation in the key:
155
+
156
+ ```ts
157
+ // Recommended format: `${subjectId}:${operationId}` — stable per logical operation.
158
+ const result = await rekey.credits.consume({
159
+ endUserId: user.id,
160
+ amount: 1,
161
+ idempotencyKey: `${user.id}:enrich-lead:${leadId}`, // ≤ 200 chars
162
+ });
163
+ if (!result.applied) {
164
+ // Replay: this exact operation was already charged. `result.balance` and
165
+ // `result.entryId` are from the ORIGINAL entry — safe to treat as success.
166
+ }
167
+ ```
168
+
169
+ Retry semantics: a repeat with the same key (timeout retry, queue redelivery, double-click) is a no-op that returns the original result with `applied: false` — never a double charge and never an error. Keys never expire (the ledger is append-only for the life of the subject), so derive them from the operation, not from a timestamp or a random UUID minted per attempt — a fresh UUID per retry defeats the whole mechanism.
170
+
171
+ ### `rekey.licenses`
172
+ | Method | Description |
173
+ | --- | --- |
174
+ | `verify({ key, machineFingerprint, label? })` | Verify a license key + record an activation. Always 200 — branch on `result.ok`. |
175
+
176
+ ### `rekey.mcp` (bring-your-own MCP server)
177
+ | Method | Description |
178
+ | --- | --- |
179
+ | `introspect(token)` | Validate an inbound Rekey-issued MCP access token (RFC 7662). |
180
+ | `metadata()` | Fetch this Application's OAuth authorization-server metadata (RFC 8414). |
181
+
182
+ ### Top-level exports
183
+ | Export | Description |
184
+ | --- | --- |
185
+ | `verifyWebhookSignature({ header, payload, secret, toleranceSeconds? })` | Verify the HMAC on a webhook **Rekey sends to your app** (user-lifecycle + billing events) against the **raw body bytes** + the `X-Rekey-Signature` header. Not for Stripe/PayPal webhooks — those go to Rekey, never to you (see [docs/billing.md](../../docs/billing.md)). |
186
+ | `verifyAccessToken(token, { jwksUrl \| jwks })` | Verify an end-user access token **offline** (no API round-trip) against your deployment's `GET /.well-known/jwks.json`. RS256 only — the Application must opt in via `authConfig.tokenAlg: "RS256"`; default HS256 tokens still need `auth.getCurrentUser`. Fetches + caches the JWKS for 5 minutes, checks `kid`/signature/`exp`/`typ`, and returns the claims (`sub`, `applicationId`, `oid?`, …). Check `claims.applicationId` against your own app id. See [docs/jwks.md](../../docs/jwks.md). |
187
+ | `WEBHOOK_EVENTS` / `KNOWN_WEBHOOK_EVENTS` / `isKnownWebhookEvent` | The full outbound-event registry — `{ name, description }` pairs (and just the names) for the 13 events Rekey can send: `user.created/updated/deleted`, `session.revoked`, `mfa.enabled/disabled`, `password.changed`, `email.verified`, `subscription.activated/canceled/past_due`, `payment.succeeded/failed`. Mirrors the API exactly; use it for event pickers / autocompleting an endpoint's `events` array. |
188
+ | `WebhookEventType` / `WebhookEventEnvelope<TData>` | Types for the event-name union and the delivery envelope (`{ eventId, occurredAt, type, applicationId, data }`). Dedupe on `eventId` — retries reuse it. |
189
+ | `RelipayError` | The canonical error class — `instanceof`-consistent across SDK packages. |
190
+
191
+ ### Pagination
192
+
193
+ All list pagination is `{ limit, offset }`. Per-endpoint windows (server-enforced — passing a larger `limit` is clamped or rejected):
194
+
195
+ | Method | Default `limit` | Max `limit` |
196
+ | --- | --- | --- |
197
+ | `organizations.listMine(accessToken, page?)` | 50 | 100 |
198
+ | `organizations.listMembers(accessToken, orgId, page?)` | 50 | 100 |
199
+ | `credits.listLedger(subject, limit?, offset?)` | 50 | 200 |
200
+ | `auth.listSessions` / `listPasskeys` / `listOAuthIdentities` | — (returns the full set; these are small, bounded lists) | — |
201
+ | `billing.getPlans()` | — (all active plans) | — |
202
+
203
+ ## Errors
204
+
205
+ Every failure is a `RelipayError` with `code`, `message`, and usually a concrete `fix` (plus optional `docs`, `statusCode`, `requestId`). **Read `error.fix` first.**
206
+
207
+ ```ts
208
+ import { RelipayError } from '@rekey.dev/node';
209
+
210
+ try {
211
+ await rekey.billing.createCheckout(accessToken, { /* … */ });
212
+ } catch (err) {
213
+ if (err instanceof RelipayError) console.error(err.code, err.fix);
214
+ throw err;
215
+ }
216
+ ```
217
+
218
+ ## Gotchas
219
+
220
+ - **Entitlements are resolved server-side.** Never gate features from client state — always read `rekey.billing.getEntitlements(...)` on the server.
221
+ - **`billingSubject: 'org'` needs an `organizationId`.** When the Application bills per-team (Panel → Application → Billing → Subject), an individual can't hold a subscription — pass `organizationId` (a team the user owns/admins) to `createCheckout`. Omitting it throws `BILLING_ORGANIZATION_REQUIRED`. Read the live config via `rekey.applications.me()` (`billingConfig.billingSubject`) and drive your UI from it.
222
+ - **Checkout is async.** `createCheckout` returns a *PENDING* subscription + a redirect URL; the subscription flips to ACTIVE only when the **provider's webhook to Rekey** lands (Stripe/PayPal → Rekey — configured by the operator in the panel; your code never receives or verifies it). To react to activation, re-fetch `getSubscription` / `getEntitlements` when the user returns to your `successUrl`. `verifyWebhookSignature` is for the *other* direction — webhooks Rekey sends to your app (user-lifecycle events); see [docs/billing.md](../../docs/billing.md).
223
+ - **Switching active org returns new tokens.** `organizations.switch` / `clearActive` return a fresh `{ accessToken, refreshToken }` pair — persist both, or later reads use the stale org view.
224
+ - **Pagination is `{ limit, offset }`.** Defaults to 50 everywhere; max is 100 for org lists and 200 for the credit ledger — see the [Pagination](#pagination) table.
225
+ - **Retrying a timed-out mutation? Send an `Idempotency-Key` header.** High-value mutating routes (checkout, subscription cancel, credits consume, and the operator create/mint/issue/grant endpoints) accept the header (max 200 chars, scoped to your Application): a retry with the same key replays the first response (`Idempotency-Replayed: true`) instead of executing twice; the same key with a *different* body is a `409 IDEMPOTENCY_KEY_REUSED`. Keys live 24 h; 5xx responses are never cached, so retries after server errors really re-execute. The body-level `idempotencyKey` on `credits.consume` still works — it dedupes the ledger entry itself. See [docs/concepts.md → Idempotent requests](../../docs/concepts.md#idempotent-requests).
226
+ - **One client per Application.** Construct a module-level singleton; don't `new Rekey()` per request. Never log the secret key.
227
+
228
+ ## Links
229
+
230
+ - Docs: [/docs](https://relipay.dev/docs) · [SDK guide](https://relipay.dev/docs/sdk) · [API reference](https://relipay.dev/docs/api) · [agent prompt](https://relipay.dev/docs/prompt)
231
+ - Examples: [`examples/qr-saas`](../../examples/qr-saas) (end-to-end server integration) · [`examples/nextjs-saas`](../../examples/nextjs-saas)
232
+
233
+ ## License
234
+
235
+ MIT