@mesub/node 0.0.0-stage → 0.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 +21 -0
- package/README.md +840 -2
- package/dist/chunk-43Q4PNF7.cjs +480 -0
- package/dist/chunk-43Q4PNF7.cjs.map +1 -0
- package/dist/chunk-4AAPR37Y.js +120 -0
- package/dist/chunk-4AAPR37Y.js.map +1 -0
- package/dist/chunk-4JSJOX35.cjs +2040 -0
- package/dist/chunk-4JSJOX35.cjs.map +1 -0
- package/dist/chunk-66VVEXMU.cjs +340 -0
- package/dist/chunk-66VVEXMU.cjs.map +1 -0
- package/dist/chunk-J2CE4QWV.js +340 -0
- package/dist/chunk-J2CE4QWV.js.map +1 -0
- package/dist/chunk-JJWHMB2X.js +480 -0
- package/dist/chunk-JJWHMB2X.js.map +1 -0
- package/dist/chunk-NOS4FWWQ.js +2040 -0
- package/dist/chunk-NOS4FWWQ.js.map +1 -0
- package/dist/chunk-SJRQDWTI.cjs +120 -0
- package/dist/chunk-SJRQDWTI.cjs.map +1 -0
- package/dist/client-BEuDegEY.d.cts +765 -0
- package/dist/client-CXQLkSAJ.d.ts +765 -0
- package/dist/errors-BN4ZWUfM.d.ts +112 -0
- package/dist/errors-DcLfunS-.d.cts +112 -0
- package/dist/express.cjs +129 -0
- package/dist/express.cjs.map +1 -0
- package/dist/express.d.cts +57 -0
- package/dist/express.d.ts +57 -0
- package/dist/express.js +129 -0
- package/dist/express.js.map +1 -0
- package/dist/guard-CaXiHMVF.d.ts +51 -0
- package/dist/guard-D4AB5HtN.d.cts +51 -0
- package/dist/index.cjs +39 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +44 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +39 -0
- package/dist/index.js.map +1 -0
- package/dist/nest.cjs +62 -0
- package/dist/nest.cjs.map +1 -0
- package/dist/nest.d.cts +57 -0
- package/dist/nest.d.ts +57 -0
- package/dist/nest.js +62 -0
- package/dist/nest.js.map +1 -0
- package/dist/next.cjs +113 -0
- package/dist/next.cjs.map +1 -0
- package/dist/next.d.cts +63 -0
- package/dist/next.d.ts +63 -0
- package/dist/next.js +113 -0
- package/dist/next.js.map +1 -0
- package/dist/routes-B7wN4sby.d.ts +130 -0
- package/dist/routes-CMzB52P6.d.cts +130 -0
- package/dist/situations.cjs +15 -0
- package/dist/situations.cjs.map +1 -0
- package/dist/situations.d.cts +607 -0
- package/dist/situations.d.ts +607 -0
- package/dist/situations.js +15 -0
- package/dist/situations.js.map +1 -0
- package/dist/subscriptions-B6Lin6i_.d.cts +775 -0
- package/dist/subscriptions-B6Lin6i_.d.ts +775 -0
- package/dist/testing.cjs +1488 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +331 -0
- package/dist/testing.d.ts +331 -0
- package/dist/testing.js +1488 -0
- package/dist/testing.js.map +1 -0
- package/package.json +158 -4
package/README.md
CHANGED
|
@@ -1,3 +1,841 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mesub/node
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Server-side SDK for [Mesub](https://mesub.io), recurring payments on Solana.
|
|
4
|
+
|
|
5
|
+
Mesub runs the billing: it holds no funds, pulls each period on the merchant's
|
|
6
|
+
behalf, retries, and keeps the record of every attempt. This package is the
|
|
7
|
+
part that lives on your servers. It answers **does this customer have access
|
|
8
|
+
to this plan?** on every request, and opens subscriptions for your front to
|
|
9
|
+
have the wallet sign.
|
|
10
|
+
|
|
11
|
+
**[Read the docs](https://docs.mesub.io)** for the walkthroughs. This page is
|
|
12
|
+
the short version and the reference of the options.
|
|
13
|
+
|
|
14
|
+
[](https://www.npmjs.com/package/@mesub/node)
|
|
15
|
+
|
|
16
|
+
> **Status: early, 0.x.** The API may still change between minor versions:
|
|
17
|
+
> `npm update` takes patches (0.1.x), a new minor (0.2.0) takes
|
|
18
|
+
> `npm install @mesub/node@latest`. See the
|
|
19
|
+
> [board](https://github.com/orgs/Mesub-io/projects/4) for what is next.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm install @mesub/node
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Node 22 or later. No runtime dependency. Every release is built by GitHub
|
|
28
|
+
Actions from a tagged commit and published with a provenance statement:
|
|
29
|
+
`npm audit signatures` verifies it.
|
|
30
|
+
|
|
31
|
+
## How it fits
|
|
32
|
+
|
|
33
|
+
Your subscribers get no Mesub account. You keep your own login and your own
|
|
34
|
+
UI; your server talks to Mesub with the API key, through this package, and
|
|
35
|
+
your pages only have the wallet sign. See
|
|
36
|
+
[How it works](https://docs.mesub.io/docs/how-it-works).
|
|
37
|
+
|
|
38
|
+
| Where | What |
|
|
39
|
+
| -------------------- | -------------------------------------------------------------------------------- |
|
|
40
|
+
| Your server's `.env` | `MESUB_API_KEY=SUB_...`, the API key, from your dashboard. Read by this package. |
|
|
41
|
+
| Your server's `.env` | With webhooks: `MESUB_WEBHOOK_SECRET=whsec_...`, the endpoint's signing secret. |
|
|
42
|
+
| Your pages | Nothing. No key ever goes to the browser. |
|
|
43
|
+
|
|
44
|
+
## Gate a route
|
|
45
|
+
|
|
46
|
+
Your own login says who the user is; Mesub says whether they have access.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { Mesub } from '@mesub/node';
|
|
50
|
+
|
|
51
|
+
const mesub = new Mesub(); // reads MESUB_API_KEY
|
|
52
|
+
|
|
53
|
+
// Your login sets req.user: declare it once. With Passport, add these fields to Express.User.
|
|
54
|
+
declare global {
|
|
55
|
+
namespace Express {
|
|
56
|
+
interface Request {
|
|
57
|
+
user?: { id: string; email?: string };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
app.get('/api/analytics', yourLogin, async (req, res) => {
|
|
63
|
+
const user = req.user!; // yourLogin answers 401 when nobody is signed in
|
|
64
|
+
if (!(await mesub.hasAccess({ external_id: user.id }, 'pro'))) {
|
|
65
|
+
res.status(402).json({ error: 'The Pro plan is needed.' });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
res.json(buildAnalytics(user));
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Called like this, `hasAccess` has no time budget: it waits out the client's
|
|
73
|
+
timeout and retries, and a 429's `Retry-After` twice, so a rate limit can hold
|
|
74
|
+
the route for about two minutes. The guards do the same in one line, within
|
|
75
|
+
`guardTimeout` (2 s by default), and answer the refusals themselves (401
|
|
76
|
+
nobody signed in, 402 no access, 503 Mesub could not answer about a customer it
|
|
77
|
+
never saw):
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
// Express
|
|
81
|
+
import type { Request } from 'express';
|
|
82
|
+
import { requirePlan } from '@mesub/node/express';
|
|
83
|
+
|
|
84
|
+
const customer = (req: Request) => (req.user ? { external_id: req.user.id } : null);
|
|
85
|
+
app.get('/api/analytics', yourLogin, requirePlan('pro', { customer }), handler);
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
// Next, app/api/analytics/route.ts
|
|
90
|
+
import { withMesub } from '@mesub/node/next';
|
|
91
|
+
|
|
92
|
+
export const GET = withMesub(async (request, mesub) => Response.json(await analytics(mesub)), {
|
|
93
|
+
plan: 'pro',
|
|
94
|
+
customer: async (request) => {
|
|
95
|
+
const session = await yourSession(request);
|
|
96
|
+
return session ? { external_id: session.userId } : null;
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
// Nest
|
|
103
|
+
import { Controller, Get, UseGuards } from '@nestjs/common';
|
|
104
|
+
import { MesubAccess, type MesubRequest, RequirePlan } from '@mesub/node/nest';
|
|
105
|
+
|
|
106
|
+
interface AuthedRequest extends MesubRequest {
|
|
107
|
+
user?: { id: string };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@Controller('analytics')
|
|
111
|
+
@UseGuards(
|
|
112
|
+
YourAuthGuard,
|
|
113
|
+
RequirePlan<AuthedRequest>('pro', {
|
|
114
|
+
customer: (req) => (req.user ? { external_id: req.user.id } : null),
|
|
115
|
+
}),
|
|
116
|
+
)
|
|
117
|
+
export class AnalyticsController {
|
|
118
|
+
@Get()
|
|
119
|
+
list(@MesubAccess() mesub: MesubAccess) {
|
|
120
|
+
return buildAnalytics(mesub.customer);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
> **`customer` must come from a session you verified**, never from the request
|
|
126
|
+
> itself. A guard reading `req.query.wallet` lets in anyone who types a
|
|
127
|
+
> subscriber's address.
|
|
128
|
+
|
|
129
|
+
A customer is named by exactly one of `external_id` (your own id for them),
|
|
130
|
+
`wallet` or `email`. A guard takes one plan, a list of up to three, or a
|
|
131
|
+
function of the request. `access` returns the whole answer for a screen, and
|
|
132
|
+
`accessList` every plan a customer has anything on.
|
|
133
|
+
|
|
134
|
+
A guard reads `access`, never `status`. Access runs past `access_until`
|
|
135
|
+
while the renewal goes through, a couple of minutes when nothing is in its
|
|
136
|
+
way; never past it on a blocked subscription, nor past a plan's end. On a plan
|
|
137
|
+
with an end date (`ends_at` on the plan) `access_until` is never later than
|
|
138
|
+
that end: nobody has access past it, the last period being charged in full
|
|
139
|
+
all the same. In that last period the
|
|
140
|
+
answer has no `next_charge_at`, and a late one no `next_retry_at`, since no
|
|
141
|
+
pull is left to run. For a few minutes past the end, `status` can still read
|
|
142
|
+
`active` or `unpaid` with `access: false`, until Mesub ends the subscription
|
|
143
|
+
(`ended`, `end_reason: 'plan_ended'`).
|
|
144
|
+
|
|
145
|
+
A charge can also fail for a reason that is not your customer's. The answer
|
|
146
|
+
then carries `blocked_since` and `blocked_reason`, both null otherwise:
|
|
147
|
+
`mesub` for a cause on Mesub's side, or your plan's own setup by name,
|
|
148
|
+
`puller_not_allowed`, `receiver_not_allowed`, `receiver_account_missing`,
|
|
149
|
+
`receiver_account_frozen` or `mint_gone`. `status` and `payment_status` do not
|
|
150
|
+
move for it and Mesub keeps trying; `access` ends at `access_until` if nothing
|
|
151
|
+
is collected by then, and comes back with the first charge that goes through,
|
|
152
|
+
nothing to sign. On `mint_gone` no charge can go through again, so access does
|
|
153
|
+
not come back.
|
|
154
|
+
|
|
155
|
+
Everything else, the answer's fields, the errors, who to ask about:
|
|
156
|
+
[Check access](https://docs.mesub.io/docs/access). What each status means:
|
|
157
|
+
[Lifecycle](https://docs.mesub.io/docs/lifecycle).
|
|
158
|
+
|
|
159
|
+
## When Mesub does not answer
|
|
160
|
+
|
|
161
|
+
`hasAccess` and the guards do not lock out a paying subscriber for an outage,
|
|
162
|
+
nor let a stranger in: they serve the last answer they knew for that customer
|
|
163
|
+
and plan, for up to 24 hours (`maxStaleMs`), and `false` or a 503 for one they
|
|
164
|
+
never saw. An answer with no charge or retry ahead (cancelled, parked, or in
|
|
165
|
+
a plan's last period) stops at its `access_until`; one with a renewal ahead
|
|
166
|
+
keeps the fallback, as it was likely paid while Mesub was down. A blocked one
|
|
167
|
+
(`blocked_since`) stops at its `access_until` whatever is ahead, as Mesub
|
|
168
|
+
does: its retries say nothing of a payment.
|
|
169
|
+
`access` throws instead. A guard never holds a request longer than
|
|
170
|
+
`guardTimeout`, 2 s by default, a cache store that hangs included: a read not
|
|
171
|
+
answered by then is a miss, and no answer waits for a write. Its 503 carries
|
|
172
|
+
the `Retry-After` Mesub sent, 30 s when it sent none. A bad key or an unknown
|
|
173
|
+
plan always throws: a broken integration is never read as "not subscribed".
|
|
174
|
+
|
|
175
|
+
## Show your plans
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
const plans = await mesub.plans.list(); // sorted by slug
|
|
179
|
+
const pro = await mesub.plans.retrieve('pro');
|
|
180
|
+
// pro.amount_display "9.99", pro.symbol "USDC", pro.period_hours 720, pro.available
|
|
181
|
+
// pro.token { symbol: "USDC", name: "USD Coin", logo_url, verified: true }, pro.amount_usd "9.99"
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`available` is false on a plan that is ending and when your project is full:
|
|
185
|
+
show "Subscribe" only when it is true. `ends_at` is when a plan ends, null
|
|
186
|
+
for one with no end. Nothing is cached.
|
|
187
|
+
|
|
188
|
+
`symbol` is set only for a mint Mesub vouches for (USDC, USDT, PYUSD).
|
|
189
|
+
`token` is what Mesub resolved for any mint: its symbol, name and logo, and
|
|
190
|
+
`verified`, true for a vouched mint or one Jupiter marks verified. A symbol is
|
|
191
|
+
free text on chain and a fake "USDC" is a known scam: show an unverified token
|
|
192
|
+
as unverified and keep `mint` visible. `amount_usd` is the plan's amount in
|
|
193
|
+
dollars at the latest price Mesub knows, a decimal string to show beside the
|
|
194
|
+
amount, null when it knows none. An attempt's `amount_usd` is priced when it
|
|
195
|
+
was attempted, null until then.
|
|
196
|
+
|
|
197
|
+
## Subscribe from your server
|
|
198
|
+
|
|
199
|
+
Three steps, with the wallet in the middle. The API key never leaves your
|
|
200
|
+
server.
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
// 1. On your server: Mesub reserves it and builds what the wallet signs.
|
|
204
|
+
const { subscription, transaction, terms, costs } = await mesub.subscriptions.create({
|
|
205
|
+
plan: 'pro',
|
|
206
|
+
wallet, // the wallet that signs and pays
|
|
207
|
+
external_id: user.id, // optional: your own id, what you gate routes by
|
|
208
|
+
email, // optional: where their notices go
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// 2. In your pages: the wallet signs terms.message, then the transaction,
|
|
212
|
+
// without sending it, before terms.expires_at.
|
|
213
|
+
|
|
214
|
+
// 3. On your server: Mesub co-signs, sends, and waits for the chain.
|
|
215
|
+
const { subscription: now, reason } = await mesub.subscriptions.submit(subscription.id, {
|
|
216
|
+
transaction: signedTransaction,
|
|
217
|
+
terms_signature,
|
|
218
|
+
});
|
|
219
|
+
if (now.access) {
|
|
220
|
+
// it landed: grant the plan
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`submit` waits for the chain: up to 130 s by default, which `{ timeout, budget }`
|
|
225
|
+
lower for a serverless function. When no answer says what became of it, it
|
|
226
|
+
throws a `MesubSubmitError`: read the subscription back with `retrieve` before
|
|
227
|
+
creating a new one, since the wallet may have paid. A 503 `unavailable` (too
|
|
228
|
+
many submits waiting on your project) says nothing was signed or sent: `submit`
|
|
229
|
+
sends the same request again after its `Retry-After`, and past its replays
|
|
230
|
+
throws it as is, `retryable`, with `retryAfter`.
|
|
231
|
+
|
|
232
|
+
A transaction lives about half a minute, the terms five:
|
|
233
|
+
`transaction_expires_at` says when it should run out, an estimate that errs
|
|
234
|
+
early. Have the terms signed first, then ask for the transaction when the
|
|
235
|
+
wallet is ready, and after a submit refused with `transaction_expired`:
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
const fresh = await mesub.subscriptions.transaction(subscription.id);
|
|
239
|
+
// fresh.terms.message is the text the wallet signed: its signature is still good.
|
|
240
|
+
// Another text is new terms: have the wallet sign them too.
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
It answers what `create` answers for the same subscription, reserves nothing,
|
|
244
|
+
and is refused with `not_awaiting_signature` once the subscription is signed
|
|
245
|
+
or expired. Always sign the transaction of the last call.
|
|
246
|
+
|
|
247
|
+
A `submit` or a confirm that settled nothing answers the subscription with a
|
|
248
|
+
`reason`, a sentence for a person, and a `reason_code` for your code
|
|
249
|
+
(`transaction_expired`, `nothing_landed`, ...): branch on `reason_code`, and
|
|
250
|
+
keep a default branch, since Mesub adds codes.
|
|
251
|
+
|
|
252
|
+
The signing code, what `create` answers, the refusals:
|
|
253
|
+
[Subscribe from your server](https://docs.mesub.io/docs/subscribe).
|
|
254
|
+
|
|
255
|
+
## Cancel, resume, close, pay now, and payments
|
|
256
|
+
|
|
257
|
+
Each action is two calls from your server with the wallet in between: your
|
|
258
|
+
server builds a transaction, the subscription's own wallet signs and sends it,
|
|
259
|
+
your server confirms with the signature.
|
|
260
|
+
|
|
261
|
+
| To | Build | Confirm |
|
|
262
|
+
| --------------------------------------------- | ------------ | ---------------------------------- |
|
|
263
|
+
| stop the renewals of a running one | `cancel(id)` | `confirmCancel(id, { signature })` |
|
|
264
|
+
| take a cancellation back before its end | `resume(id)` | `confirmResume(id, { signature })` |
|
|
265
|
+
| close one that is over, and get its rent back | `close(id)` | `confirmClose(id, { signature })` |
|
|
266
|
+
|
|
267
|
+
Whether `close` can work now is on every subscription and access answer:
|
|
268
|
+
`closable`, and `closable_from`, the date it can when not yet (null when no
|
|
269
|
+
date is known: a running one is cancelled first). A subscription stopped
|
|
270
|
+
because the wallet closed its approval for good (`stop_reason`
|
|
271
|
+
`authority_closed`) can be closed at once, and cannot come back. An access
|
|
272
|
+
answer names its subscription in `subscription_id`, so you can act on it
|
|
273
|
+
without listing first.
|
|
274
|
+
|
|
275
|
+
A late payment is paid now in one call, with nothing to sign: `retry(id)`,
|
|
276
|
+
shown to your customer as "Pay now". Mesub answers the subscription, still
|
|
277
|
+
`unpaid`: the outcome comes with the pull, as the `subscription.renewed` or
|
|
278
|
+
`subscription.payment_failed` webhook. It refuses with an `apiCode` when paying
|
|
279
|
+
cannot succeed, so no retry is spent for nothing: `insufficient_balance` (top
|
|
280
|
+
up first), `retry_cannot_succeed` (the wallet no longer approves it, its token
|
|
281
|
+
account is frozen, or its authority is closed), `retry_too_soon` (`retryAfter`
|
|
282
|
+
says when). Two refusals are `retryable`: `subscription_changed` (two retries
|
|
283
|
+
met, or a pull moved it on) and, on Free, `pull_running` (a pull runs now).
|
|
284
|
+
Read the subscription back before you ask again. A 503 (`unavailable`, the
|
|
285
|
+
charge could not be queued, or `network_unavailable`) changed nothing, and
|
|
286
|
+
`retryAfter` says when to ask again. Sent once, never retried.
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
await mesub.subscriptions.retrieve(id);
|
|
290
|
+
await mesub.subscriptions.list({ external_id: user.id }); // { data, has_more }
|
|
291
|
+
const { data, paid } = await mesub.subscriptions.attempts(id); // its charges, and the total paid
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
`listAll` and `allAttempts` walk every page. The steps, the refusals and what
|
|
295
|
+
an attempt carries: [Manage from your server](https://docs.mesub.io/docs/manage).
|
|
296
|
+
|
|
297
|
+
## Show a subscription's situation
|
|
298
|
+
|
|
299
|
+
`explain` turns an access answer or a subscription into the words Mesub uses
|
|
300
|
+
everywhere: one sentence for the subscriber, one for you, the access line,
|
|
301
|
+
and what each of you can do now. Show these rather than your own: a late
|
|
302
|
+
payment the wallet cannot fix by adding funds reads differently from one it
|
|
303
|
+
can.
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
import { explain } from '@mesub/node'; // or '@mesub/node/situations' in a page
|
|
307
|
+
|
|
308
|
+
const answer = await mesub.access({ external_id: user.id }, 'pro');
|
|
309
|
+
const { key, subscriber, access, actions } = explain(answer, {
|
|
310
|
+
names: { plan: 'Pro', merchant: 'Acme', amount: '9.99 USDC', token: 'USDC' },
|
|
311
|
+
});
|
|
312
|
+
// key "unpaid_approval_revoked"
|
|
313
|
+
// subscriber "Mesub could not take your payment of 9.99 USDC: your wallet no longer approves it (...)"
|
|
314
|
+
// access "No access"
|
|
315
|
+
// actions [{ by: 'subscriber', action: 'cancel', sdk: 'subscriptions.cancel', confirm: 'Cancel Pro? ...' }]
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
`names` fills what neither answer carries; a placeholder left without a value
|
|
319
|
+
stays as `{amount}` and is listed in `missing`. Dates are written as Mesub
|
|
320
|
+
serves them, in ISO 8601, unless you pass `formatDate`. A status or a reason
|
|
321
|
+
newer than this release gives `key: 'unknown'` and the access line, never an
|
|
322
|
+
error. A blocked subscription has its own keys, `blocked_mesub` and one per
|
|
323
|
+
cause of your setup (`blocked_puller_not_allowed`, ...): the subscriber reads
|
|
324
|
+
that nothing is theirs to do, you read what to fix.
|
|
325
|
+
|
|
326
|
+
`close` is offered only while Mesub says the subscription is `closable`, and
|
|
327
|
+
the sentence names `closable_from` when it can be closed later. A
|
|
328
|
+
subscription stopped for good (`stop_reason` `authority_closed`) reads
|
|
329
|
+
`stopped_authority_closed`: no comeback, only the close.
|
|
330
|
+
`@mesub/node/situations` imports nothing of Node, so your pages can use
|
|
331
|
+
it too; `SITUATIONS` is the whole table.
|
|
332
|
+
|
|
333
|
+
## Routes for the React widget
|
|
334
|
+
|
|
335
|
+
[`@mesub/react`](https://github.com/Mesub-io/react-sdk) is optional. It never
|
|
336
|
+
talks to Mesub: it calls your server, on routes this package mounts in one
|
|
337
|
+
line.
|
|
338
|
+
|
|
339
|
+
`@mesub/react` 0.1.x works with the routes of `@mesub/node` 0.1.x: upgrade
|
|
340
|
+
both to the same minor together.
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
// Express, after your own login. Under Nest: the same app.use in main.ts.
|
|
344
|
+
import { mesubRoutes } from '@mesub/node/express';
|
|
345
|
+
|
|
346
|
+
app.use(
|
|
347
|
+
'/api/mesub',
|
|
348
|
+
yourLogin,
|
|
349
|
+
mesubRoutes({
|
|
350
|
+
customer: (req) => (req.user ? { external_id: req.user.id } : null),
|
|
351
|
+
email: (req) => req.user?.email,
|
|
352
|
+
}),
|
|
353
|
+
);
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
```ts
|
|
357
|
+
// Next, app/api/mesub/[...mesub]/route.ts
|
|
358
|
+
import { mesubRouteHandlers } from '@mesub/node/next';
|
|
359
|
+
|
|
360
|
+
export const { GET, POST } = mesubRouteHandlers({
|
|
361
|
+
customer: async (request) => {
|
|
362
|
+
const session = await yourSession(request);
|
|
363
|
+
return session ? { external_id: session.userId } : null;
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Every subscription created is tied to the customer your function returns,
|
|
369
|
+
whatever the browser sends; one that is not theirs answers 404. `plans:
|
|
370
|
+
['pro', 'team']` keeps the widget to those plans. The browser never gets your
|
|
371
|
+
API key, nor the email and the id you gave Mesub.
|
|
372
|
+
|
|
373
|
+
| Route | What it does |
|
|
374
|
+
| ------------------------------------------------------------ | ------------------------------------------- |
|
|
375
|
+
| `GET /plans/:slug` | The plan to show. Public. |
|
|
376
|
+
| `GET /subscriptions` | The customer's subscriptions, 500 at most. |
|
|
377
|
+
| `GET /subscriptions/:id` | One of them, with its payments. |
|
|
378
|
+
| `POST /subscriptions` | Prepares one: terms and a transaction. |
|
|
379
|
+
| `POST /subscriptions/:id/transaction` | A fresh transaction for it, and its terms. |
|
|
380
|
+
| `POST /subscriptions/:id/submit` | Sends what the wallet signed. |
|
|
381
|
+
| `POST /subscriptions/:id/retry` | Pay now: queues the missed charge. |
|
|
382
|
+
| `POST /subscriptions/:id/cancel`, `/resume`, `/close` | The transaction the wallet signs and sends. |
|
|
383
|
+
| `POST /subscriptions/:id/cancel/confirm`, and the two others | Confirms it with its signature. |
|
|
384
|
+
|
|
385
|
+
`GET /plans/:slug` is public, so the routes answer it from your project's
|
|
386
|
+
plan list, kept in memory for 60 seconds: one call to Mesub a minute at most,
|
|
387
|
+
whatever the slugs asked, and a slug you do not have costs none. A change to a
|
|
388
|
+
plan shows there within a minute. `mesub.plans.list` and `mesub.plans.retrieve`
|
|
389
|
+
themselves are never cached.
|
|
390
|
+
|
|
391
|
+
A refusal is `{ error: { code, message } }`. A 401 always means nobody is
|
|
392
|
+
signed in on your site. Mesub refusing your API key (missing, wrong or
|
|
393
|
+
rotated) is thrown to your framework, which logs it and answers 500, never
|
|
394
|
+
handed to the browser as a 401. The widget's side:
|
|
395
|
+
[React widget](https://docs.mesub.io/docs/react).
|
|
396
|
+
|
|
397
|
+
Under Express, `requirePlan` and `mesubRoutes` hand such an error to
|
|
398
|
+
`next(err)`, and Express's own handler answers an error's `status`. So one
|
|
399
|
+
Mesub answered with a 4xx (a refused key, an unknown plan) reaches your error
|
|
400
|
+
handler as a `MesubError` with the same `code` and `apiCode` and a `status`
|
|
401
|
+
of 500, the original, with Mesub's status, as its `cause`: with no error
|
|
402
|
+
handler of yours, Express answers 500.
|
|
403
|
+
|
|
404
|
+
## Webhooks
|
|
405
|
+
|
|
406
|
+
Mesub posts an event to your endpoint when a subscription changes, signed the
|
|
407
|
+
[Standard Webhooks](https://www.standardwebhooks.com) way. Verify the raw body,
|
|
408
|
+
never one a JSON parser read and wrote again:
|
|
409
|
+
|
|
410
|
+
```ts
|
|
411
|
+
import { MesubError } from '@mesub/node';
|
|
412
|
+
|
|
413
|
+
app.post('/webhooks/mesub', express.raw({ type: 'application/json' }), async (req, res) => {
|
|
414
|
+
let event;
|
|
415
|
+
try {
|
|
416
|
+
event = await mesub.webhooks.verify(req.body, req.headers);
|
|
417
|
+
} catch (error) {
|
|
418
|
+
if (!(error instanceof MesubError)) throw error;
|
|
419
|
+
// Not Mesub's, or not now: refuse it.
|
|
420
|
+
if (error.code === 'invalid_webhook') return res.status(400).end();
|
|
421
|
+
// Mesub signed it, but this release cannot read it: update @mesub/node.
|
|
422
|
+
console.error(error);
|
|
423
|
+
return res.status(500).end();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (event.test) return res.status(200).end(); // sent from the dashboard
|
|
427
|
+
if (await alreadyHandled(event.delivery_id)) return res.status(200).end();
|
|
428
|
+
// event.type, event.data (the subscription), event.detail
|
|
429
|
+
res.status(200).end();
|
|
430
|
+
});
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
In a Next route handler: `await verifyWebhook(await request.text(),
|
|
434
|
+
request.headers)`.
|
|
435
|
+
|
|
436
|
+
Answer 400 only for `invalid_webhook`. An `unexpected` error is a body Mesub
|
|
437
|
+
signed that this release cannot read: log it, and answer a 5xx. Mesub then
|
|
438
|
+
retries the same body for 3 days, so the event still gets through once you
|
|
439
|
+
update `@mesub/node`; a 2xx would lose it. After 3 days of failures, Mesub
|
|
440
|
+
disables the endpoint.
|
|
441
|
+
|
|
442
|
+
To rotate the signing secret, regenerate it on the endpoint's dashboard page
|
|
443
|
+
with an overlap (24 hours, rather than stopping it now): the old secret keeps signing beside the new
|
|
444
|
+
one until the overlap ends. Every delivery in that window carries two
|
|
445
|
+
signatures in `webhook-signature`, space separated, the new secret's first,
|
|
446
|
+
and `verify` accepts it when any one matches. `verify` takes one secret, so
|
|
447
|
+
there is nothing to hold twice: the old secret keeps verifying until you
|
|
448
|
+
deploy the new one as `MESUB_WEBHOOK_SECRET` (or `webhookSecret`), and the new
|
|
449
|
+
one verifies from then on. Deploy it before the overlap ends and no delivery
|
|
450
|
+
fails. Regenerated with no overlap, the old secret stops at once: deliveries
|
|
451
|
+
fail verification until the new secret is deployed, and Mesub retries each
|
|
452
|
+
for about 3 days.
|
|
453
|
+
|
|
454
|
+
`event.delivery_id` is the `webhook-id` header: one per event and per
|
|
455
|
+
endpoint, the same on every retry and resend. Drop a delivery you already
|
|
456
|
+
handled by it. `event.id` is the event's own, the same for every endpoint:
|
|
457
|
+
deduplicate on it across endpoints, and read the event again with
|
|
458
|
+
`events.retrieve(event.id)`. Deliveries can arrive out of order: `event.sequence`
|
|
459
|
+
numbers one subscription's events 1, 2, 3..., two events of one change
|
|
460
|
+
included. Apply them by `sequence`, and skip one lower than the last you
|
|
461
|
+
applied for that subscription. An event without `sequence` (written before
|
|
462
|
+
Mesub numbered events, or one it could not number) cannot be placed: read the
|
|
463
|
+
subscription now with `subscriptions.retrieve` or `access` rather than
|
|
464
|
+
applying its `data`.
|
|
465
|
+
|
|
466
|
+
`event.data` is the subscription as it stood right after the change, however
|
|
467
|
+
late the delivery; `event.detail` says what the event is about. A body Mesub
|
|
468
|
+
wrote before this shape, and retries as it was sent, is handed back in the
|
|
469
|
+
same shape: its detail at the top, and `id`, `network` and `api_version` null.
|
|
470
|
+
|
|
471
|
+
A body sent from the dashboard carries `test: true`, on a made-up subscription
|
|
472
|
+
(`sub_test`, `user_123`), whatever its `type`. Change nothing for it: `verify`
|
|
473
|
+
keeps the access cache as it is.
|
|
474
|
+
|
|
475
|
+
A cancellation reaching its end reads `ended` from its `access_until` on. Once
|
|
476
|
+
the access events are on, its `subscription.access_revoked` comes at the
|
|
477
|
+
expiry, then `subscription.ended` with `end_reason: 'cancelled'` (in `data`
|
|
478
|
+
and `detail`) and `detail.changed_at` (the expiry), usually within half an hour. Closing it later
|
|
479
|
+
sends `subscription.ended` again, `end_reason: 'closed'`: one subscription can
|
|
480
|
+
end twice, so key what you do on `end_reason`, not on the event alone.
|
|
481
|
+
|
|
482
|
+
`subscription.created` carries `detail.first_payment`, `{ amount, mint,
|
|
483
|
+
signature }` of the first payment, or null when Mesub recorded none (a period
|
|
484
|
+
paid only in part, which the next charge collects); absent on older events.
|
|
485
|
+
|
|
486
|
+
`subscription.paused` and `subscription.unpaused` (with the access events)
|
|
487
|
+
say a seat was parked because the project holds more subscribers than its tier
|
|
488
|
+
allows, and brought back: `data.paused` true or false, the status unchanged.
|
|
489
|
+
A parked seat keeps access to the end of its paid period; its
|
|
490
|
+
`subscription.access_revoked` comes then, and an `access_granted` follows an
|
|
491
|
+
unpause when access had ended.
|
|
492
|
+
|
|
493
|
+
A `subscription.renewed` can come late, on
|
|
494
|
+
a subscription that is no longer `active`. The events, their bodies and how to
|
|
495
|
+
test: [Webhooks](https://docs.mesub.io/docs/webhooks).
|
|
496
|
+
|
|
497
|
+
`subscription.renewal_upcoming` comes once per period, in its last quarter and
|
|
498
|
+
three days before the charge at most. Its detail says whether the wallet, as
|
|
499
|
+
Mesub just read it, can pay: a reading, not a promise.
|
|
500
|
+
|
|
501
|
+
```ts
|
|
502
|
+
if (event.type === 'subscription.renewal_upcoming' && !event.detail.can_pay) {
|
|
503
|
+
// renewal_issue: 'balance' (add funds) or 'authority' (the approval is gone)
|
|
504
|
+
await remind(event.data.external_id, event.detail.renewal_issue);
|
|
505
|
+
}
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
It changes no access, so `verify` leaves the access cache as it is for it.
|
|
509
|
+
|
|
510
|
+
### Drive access from the access events
|
|
511
|
+
|
|
512
|
+
`subscription.access_granted` and `subscription.access_revoked` are sent
|
|
513
|
+
exactly when the access a subscription grants changes, whatever made it
|
|
514
|
+
change, a date passing included: the first access, a payment that gives it
|
|
515
|
+
back, the end of a cancellation, of a paid period or of the plan. One event
|
|
516
|
+
per change, never two; a renewal paid on time, or a resume while the period
|
|
517
|
+
runs, sends neither. Keep your customer's access from these two alone; the
|
|
518
|
+
other events say why.
|
|
519
|
+
|
|
520
|
+
**They are off until Mesub turns them on**: Mesub is not sending them yet. Until then,
|
|
521
|
+
`hasAccess` (or the guards) stays the way to know, and the other events say
|
|
522
|
+
what changed.
|
|
523
|
+
|
|
524
|
+
```ts
|
|
525
|
+
switch (event.type) {
|
|
526
|
+
case 'subscription.access_granted':
|
|
527
|
+
case 'subscription.access_revoked': {
|
|
528
|
+
const { id, access, external_id } = event.data;
|
|
529
|
+
if (event.sequence === undefined) {
|
|
530
|
+
// Cannot be placed: read the state now.
|
|
531
|
+
await setAccess(id, external_id, (await mesub.subscriptions.retrieve(id)).access);
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
// Apply only what is newer than the last event applied for it.
|
|
535
|
+
if (event.sequence <= ((await lastSequence(id)) ?? 0)) break;
|
|
536
|
+
await setAccess(id, external_id, access, event.sequence);
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
// The other events say why: payment_failed, stopped, cancelled...
|
|
540
|
+
}
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
`data.access` is true on `access_granted`, false on `access_revoked`.
|
|
544
|
+
`event.detail.changed_at` is when access changed: a date that passed with
|
|
545
|
+
nothing else happening is that date to the millisecond, even when the event
|
|
546
|
+
was written a few seconds later, its `created_at`. `detail.blocked_reason` says
|
|
547
|
+
whether charges were held when access changed (`data.blocked_reason`'s words,
|
|
548
|
+
null when not). `detail.access_until` is, on `access_revoked`, the date access
|
|
549
|
+
ended (null for a stop or a close), and on `access_granted`, for a held
|
|
550
|
+
subscription, the date access ends unless a charge lands (null otherwise).
|
|
551
|
+
Both are absent on events written before Mesub added them. `verify` drops the
|
|
552
|
+
access answers cached for the customer on both.
|
|
553
|
+
|
|
554
|
+
### Catch up on missed events
|
|
555
|
+
|
|
556
|
+
Mesub keeps the last 30 days of events, each the very body its webhooks
|
|
557
|
+
carried. Read them after an endpoint was down or turned off:
|
|
558
|
+
|
|
559
|
+
```ts
|
|
560
|
+
const { data, has_more, available_since } = await mesub.events.list({
|
|
561
|
+
type: 'subscription.payment_failed', // optional
|
|
562
|
+
subscription: id, // optional
|
|
563
|
+
limit: 100, // 1 to 100, 25 by default
|
|
564
|
+
});
|
|
565
|
+
for (const event of data) await handle(event); // the same shape as a webhook's
|
|
566
|
+
|
|
567
|
+
const one = await mesub.events.retrieve(eventId); // the id a webhook body carried
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
Newest first, and within one millisecond by `sequence`, highest first; page
|
|
571
|
+
with `starting_after` set to the last id. Events before
|
|
572
|
+
`available_since` are not served. An event can appear a moment after its
|
|
573
|
+
`created_at`, so when polling, page on until `created_at` is a minute older
|
|
574
|
+
than the newest you held, and skip the ids you have seen.
|
|
575
|
+
|
|
576
|
+
## Errors
|
|
577
|
+
|
|
578
|
+
Every failure is a `MesubError` with a `status` (the HTTP status, or `null`)
|
|
579
|
+
and a stable `code` to branch on:
|
|
580
|
+
|
|
581
|
+
| `code` | Meaning |
|
|
582
|
+
| ----------------- | ----------------------------------------------------------------------- |
|
|
583
|
+
| `invalid_request` | 400, e.g. a wallet that is not an address |
|
|
584
|
+
| `unauthorized` | 401, an API key Mesub never issued |
|
|
585
|
+
| `forbidden` | 403, e.g. signed terms that expired |
|
|
586
|
+
| `plan_not_found` | 404, no plan of yours under that slug |
|
|
587
|
+
| `not_found` | any other 404 Mesub answered, e.g. an unknown subscription id |
|
|
588
|
+
| `conflict` | 409, e.g. a wallet already subscribed |
|
|
589
|
+
| `rate_limited` | 429, after the retries, or a quota on create (`pending_cap_reached`) |
|
|
590
|
+
| `unavailable` | 5xx, a timeout or a network error, after the retries |
|
|
591
|
+
| `invalid_webhook` | a webhook that fails verification: signature, headers or timestamp |
|
|
592
|
+
| `unexpected` | any other status, or an answer that is not Mesub's: is `baseUrl` right? |
|
|
593
|
+
|
|
594
|
+
It also carries `apiCode`, Mesub's own finer code (`already_subscribed`,
|
|
595
|
+
`close_too_early`, ...), `retryable`, `retryAfter` in milliseconds, the
|
|
596
|
+
parsed `body`, and `requestId`, the id Mesub gave the request
|
|
597
|
+
(`Mesub-Request-Id`): log it, and quote it to Mesub support. New codes are added: keep a default branch. Every code:
|
|
598
|
+
[Errors](https://docs.mesub.io/reference/errors).
|
|
599
|
+
|
|
600
|
+
`pending_cap_reached` is a quota, not a rate: too many subscriptions wait for
|
|
601
|
+
a signature. `create` is never retried for it, and `retryAfter` says when the
|
|
602
|
+
oldest stops counting, up to an hour. Do not hold a request that long.
|
|
603
|
+
|
|
604
|
+
Reads time out after 5 s and are retried twice. `create`, `transaction` and
|
|
605
|
+
the six manage calls are sent once and never retried.
|
|
606
|
+
|
|
607
|
+
## Test your integration
|
|
608
|
+
|
|
609
|
+
`@mesub/node/testing` is a fake Mesub for your own tests: no network, no Mesub
|
|
610
|
+
account, and nothing of it in your production bundle.
|
|
611
|
+
|
|
612
|
+
```ts
|
|
613
|
+
import { FakeMesub } from '@mesub/node/testing';
|
|
614
|
+
|
|
615
|
+
const fake = new FakeMesub();
|
|
616
|
+
const mesub = fake.client(); // a real Mesub, wired to the fake
|
|
617
|
+
|
|
618
|
+
fake.grant({ external_id: 'user_42' }, 'pro');
|
|
619
|
+
await mesub.hasAccess({ external_id: 'user_42' }, 'pro'); // true
|
|
620
|
+
|
|
621
|
+
fake.grantLastPeriod({ external_id: 'user_7' }, 'pro', endsAt); // access until the plan ends
|
|
622
|
+
fake.endPlan('pro'); // the end passed: what is held on it ends with plan_ended
|
|
623
|
+
|
|
624
|
+
fake.grant({ external_id: 'user_9' }, 'pro', {
|
|
625
|
+
blocked_since: new Date().toISOString(), // charges blocked, access still on
|
|
626
|
+
blocked_reason: 'puller_not_allowed',
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
// A plan is in verified USDC unless told: here an unverified token, no symbol, no price.
|
|
630
|
+
new FakeMesub({ plans: ['pro', { slug: 'bonk', mint, token: { name: 'Bonk' } }] });
|
|
631
|
+
|
|
632
|
+
fake.fail('outage'); // every call answers 503, until fail(null)
|
|
633
|
+
fake.fail({ status: 503, code: 'unavailable' }); // Retry-After as Mesub: 2 s on submit, 20 s on retry
|
|
634
|
+
const { body, headers } = await fake.webhook('subscription.renewed'); // signed
|
|
635
|
+
fake.addEvent('subscription.payment_failed', { subscription: { id } }); // on /v1/events
|
|
636
|
+
fake.addEvent('subscription.access_revoked', { subscription: { id }, sequence: 5 });
|
|
637
|
+
fake.park(id); // a seat over the tier's cap: paused, no next charge; unpark(id) brings it back
|
|
638
|
+
fake.reset(); // between tests
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
Every event the fake signs or serves carries the next `sequence` of its
|
|
642
|
+
subscription (`sequence: null` leaves it out). The access events are off by
|
|
643
|
+
default, as on Mesub: `new FakeMesub({ accessEvents: true })` raises
|
|
644
|
+
`subscription.access_granted` after the `subscription.created` of a submit,
|
|
645
|
+
one `sequence` later, an access event after any other change of the fake
|
|
646
|
+
that flips access, and `subscription.paused` and `subscription.unpaused` on
|
|
647
|
+
`park` and `unpark`. `addEvent` and `webhook` make either type whatever this says.
|
|
648
|
+
|
|
649
|
+
As on Mesub, a client keeps a yes up to five minutes: a change shows to a new
|
|
650
|
+
`fake.client()`, or on the next call with `new FakeMesub({ revalidateAfter: 0 })`.
|
|
651
|
+
|
|
652
|
+
Hand the fake's client to the guards and the routes, which otherwise build
|
|
653
|
+
their own from `MESUB_API_KEY`:
|
|
654
|
+
|
|
655
|
+
```ts
|
|
656
|
+
const mesub = fake.client();
|
|
657
|
+
app.get('/api/analytics', yourLogin, requirePlan('pro', { client: mesub, customer }), handler);
|
|
658
|
+
app.use('/api/mesub', yourLogin, mesubRoutes({ client: mesub, customer }));
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
Pay now refuses as Mesub does, from what the fake holds: a parked seat, a row
|
|
662
|
+
not late, a plan past its end, and the wait between two retries, which
|
|
663
|
+
depends on `tier` (`dev` by default; `free` also caps a missed period at
|
|
664
|
+
three and closes at `retry_deadline`). A taken retry pulls nothing: the row
|
|
665
|
+
stays `unpaid`.
|
|
666
|
+
|
|
667
|
+
```ts
|
|
668
|
+
const fake = new FakeMesub({ revalidateAfter: 0 });
|
|
669
|
+
const late = fake.addSubscription({ wallet, plan: 'pro', status: 'unpaid' }); // late, retry tomorrow
|
|
670
|
+
await fake.client().subscriptions.retry(late.id); // 202, still unpaid
|
|
671
|
+
await fake.client().subscriptions.retry(late.id); // retry_too_soon, retryAfter 20 minutes
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
`fail()` answers every call, not only the one under test: a route that reads
|
|
675
|
+
the subscription before it pays now gets the error on that read. What only
|
|
676
|
+
the chain decides (`insufficient_balance`, `retry_cannot_succeed`) is tested
|
|
677
|
+
on the SDK call alone, or on a route that makes that one call.
|
|
678
|
+
|
|
679
|
+
Guards, webhooks, subscribing and managing against it:
|
|
680
|
+
[Test your integration](https://docs.mesub.io/docs/testing).
|
|
681
|
+
|
|
682
|
+
## Options
|
|
683
|
+
|
|
684
|
+
```ts
|
|
685
|
+
new Mesub({
|
|
686
|
+
apiKey, // default: process.env.MESUB_API_KEY
|
|
687
|
+
baseUrl, // default: https://api.mesub.io, may carry a path
|
|
688
|
+
headers, // extra headers on every call, e.g. for a proxy
|
|
689
|
+
timeout, // per attempt, ms, default 5000
|
|
690
|
+
maxRetries, // default 2
|
|
691
|
+
fetch, // a custom fetch, e.g. bound to your own agent
|
|
692
|
+
cache, // where answers are kept, default: 10,000 entries in memory
|
|
693
|
+
maxStaleMs, // how long a stale answer serves the outage fallback, default 24 h
|
|
694
|
+
guardTimeout, // the guards' budget for the access check, ms, default 2000
|
|
695
|
+
webhookSecret, // the endpoint's whsec_ secret, default: process.env.MESUB_WEBHOOK_SECRET
|
|
696
|
+
});
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
They are checked once, by `new Mesub()`, which throws a `TypeError` naming
|
|
700
|
+
the option: a `baseUrl` that is not https (plain http only to `localhost` or
|
|
701
|
+
`127.0.0.1`: every call carries the key), a timeout that is not a positive
|
|
702
|
+
number of milliseconds, or `maxRetries` or `maxStaleMs` below 0. On an edge runtime
|
|
703
|
+
without `process.env` (Cloudflare Workers), pass `apiKey` yourself.
|
|
704
|
+
|
|
705
|
+
Behind a proxy, `baseUrl` may carry a path: every call goes under it
|
|
706
|
+
(`https://proxy.example.com/mesub/v1/access`). Add whatever the proxy asks
|
|
707
|
+
for, such as a Cloudflare Access service token:
|
|
708
|
+
|
|
709
|
+
```ts
|
|
710
|
+
import { Mesub } from '@mesub/node';
|
|
711
|
+
|
|
712
|
+
new Mesub({
|
|
713
|
+
baseUrl: 'https://proxy.example.com/mesub',
|
|
714
|
+
headers: {
|
|
715
|
+
'CF-Access-Client-Id': process.env.CF_ACCESS_CLIENT_ID!,
|
|
716
|
+
'CF-Access-Client-Secret': process.env.CF_ACCESS_CLIENT_SECRET!,
|
|
717
|
+
},
|
|
718
|
+
});
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
`headers` cannot replace the SDK's own (`Authorization`, `User-Agent`,
|
|
722
|
+
`Accept`, `Content-Type`, `Mesub-Version`).
|
|
723
|
+
|
|
724
|
+
The memory cache is per process and emptied on restart. A store is three
|
|
725
|
+
methods, so Redis is a few lines, and keeps the outage fallback across
|
|
726
|
+
restarts and servers:
|
|
727
|
+
|
|
728
|
+
```ts
|
|
729
|
+
import { type AccessAnswer, type AccessList, type CacheStore, Mesub } from '@mesub/node';
|
|
730
|
+
|
|
731
|
+
const redisStore: CacheStore<AccessAnswer | AccessList> = {
|
|
732
|
+
get: async (key) => JSON.parse((await redis.get(key)) ?? 'null') ?? undefined,
|
|
733
|
+
set: async (key, entry, ttlMs) => {
|
|
734
|
+
await redis.set(key, JSON.stringify(entry), 'PX', ttlMs);
|
|
735
|
+
},
|
|
736
|
+
delete: async (key) => {
|
|
737
|
+
await redis.del(key);
|
|
738
|
+
},
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
const mesub = new Mesub({ cache: redisStore });
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
`delete` is what drops a cached no once a subscription lands. It is optional:
|
|
745
|
+
a store without it gets that answer rewritten as stale instead, which also
|
|
746
|
+
makes the next call ask Mesub.
|
|
747
|
+
|
|
748
|
+
Keys read `mesub:access:key-<hash>:<plan>:<kind>:<id>`, and
|
|
749
|
+
`mesub:access-list:key-<hash>:<kind>:<id>` for `accessList`, so several
|
|
750
|
+
projects can share one Redis. `<hash>` is the start of your API key's SHA-256,
|
|
751
|
+
never the key itself: it needs no call to Mesub, so a server restarted during
|
|
752
|
+
an outage still reads what was cached before. `<kind>` is `wallet`,
|
|
753
|
+
`external_id` or `email`; `<id>` is the wallet itself, or for an external id or
|
|
754
|
+
an email an HMAC-SHA256 under your API key, so neither is ever stored in
|
|
755
|
+
clear. Rotating the API key starts a fresh cache: each customer costs one call
|
|
756
|
+
to Mesub, and the old keys expire on their own TTL. So do keys written before
|
|
757
|
+
the `<kind>` segment, which are no longer read.
|
|
758
|
+
|
|
759
|
+
### API version
|
|
760
|
+
|
|
761
|
+
Every call sends `Mesub-Version: 2026-10-02`, the version of the API this
|
|
762
|
+
release was written against, exported as `API_VERSION`. It is pinned per
|
|
763
|
+
release and cannot be set, so Mesub can change an answer for newer releases
|
|
764
|
+
without breaking one already installed: upgrading the package is what moves
|
|
765
|
+
you to a newer version. Mesub reads it. A version Mesub no longer serves is
|
|
766
|
+
refused with a 400 (`invalid_request`, `apiCode` `unsupported_version`), and
|
|
767
|
+
every call throws, guards included. Mesub keeps a version while releases that
|
|
768
|
+
send it are in use.
|
|
769
|
+
|
|
770
|
+
## Requirements
|
|
771
|
+
|
|
772
|
+
Node 22 or later. Express, Next and `@nestjs/common` are optional peer
|
|
773
|
+
dependencies: install the one you use. There is no runtime dependency.
|
|
774
|
+
|
|
775
|
+
What CI runs the tests against: Node 22 and 24; Express 5, and Express 4 on
|
|
776
|
+
Node 22; Nest 12. `@mesub/node/next` imports nothing from Next, only the Web
|
|
777
|
+
`Request` and `Response`, so no Next version is installed to test it.
|
|
778
|
+
|
|
779
|
+
## Development
|
|
780
|
+
|
|
781
|
+
```sh
|
|
782
|
+
pnpm install
|
|
783
|
+
pnpm test # unit tests, against a fake Mesub
|
|
784
|
+
pnpm test:coverage # the same, with v8 coverage of src/ in coverage/index.html
|
|
785
|
+
pnpm typecheck
|
|
786
|
+
pnpm lint
|
|
787
|
+
pnpm build # dist/, ESM and CJS, with declaration files
|
|
788
|
+
pnpm check:exports # every entry resolves through import and require, shares one MesubError,
|
|
789
|
+
# and the fake Mesub stays in @mesub/node/testing
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
The pre-commit hook lints and checks the format of the staged files; the
|
|
793
|
+
pre-push hook runs typecheck, test, build and check:exports. CI runs all of
|
|
794
|
+
it on Node 22 and 24, measures coverage on Node 24, fails under 90%, and
|
|
795
|
+
keeps the report as the `coverage` artifact of the run.
|
|
796
|
+
|
|
797
|
+
### Releasing
|
|
798
|
+
|
|
799
|
+
A version tag on main publishes, once a reviewer approves: see
|
|
800
|
+
[RELEASING.md](./RELEASING.md) for the one-time setup and the checklist, and
|
|
801
|
+
[CHANGELOG.md](./CHANGELOG.md) for what changed.
|
|
802
|
+
|
|
803
|
+
### Contract test
|
|
804
|
+
|
|
805
|
+
`pnpm test` runs against a fake Mesub. The contract test runs the SDK against
|
|
806
|
+
the real back instead, to catch the back changing a field, a status or an
|
|
807
|
+
error code under the SDK. With the back running locally (`pnpm start:dev` in
|
|
808
|
+
Mesub-io/backend):
|
|
809
|
+
|
|
810
|
+
```sh
|
|
811
|
+
# in the backend: write a merchant, a key, its plans and their subscribers
|
|
812
|
+
pnpm -s contract:fixture > /tmp/mesub-contract.env
|
|
813
|
+
|
|
814
|
+
# here
|
|
815
|
+
env $(cat /tmp/mesub-contract.env) pnpm test:contract
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
Without `MESUB_CONTRACT_URL` it is skipped, which is why `pnpm test` never
|
|
819
|
+
needs a back.
|
|
820
|
+
|
|
821
|
+
`pnpm test` also holds what the fake serves to the back's own contract,
|
|
822
|
+
`openapi/v1.json`, copied to `test/fixtures/openapi-v1.json` from the commit
|
|
823
|
+
named in `test/openapi.spec.ts`. When the back's contract moves, copy it
|
|
824
|
+
again and move the commit.
|
|
825
|
+
|
|
826
|
+
The Contract workflow runs it on every push, every morning and by hand (with a
|
|
827
|
+
back branch to try): it checks out Mesub-io/backend, starts Postgres and Redis,
|
|
828
|
+
migrates, seeds the fixture, starts the API and runs `pnpm test:contract`.
|
|
829
|
+
The back is private, so it needs one secret, set once by an admin:
|
|
830
|
+
|
|
831
|
+
1. `ssh-keygen -t ed25519 -N '' -C node-sdk-contract -f contract_key`
|
|
832
|
+
2. Mesub-io/backend, Settings, Deploy keys: add `contract_key.pub`, read-only.
|
|
833
|
+
3. Mesub-io/node-sdk, Settings, Secrets and variables, Actions: a repository
|
|
834
|
+
secret `BACKEND_DEPLOY_KEY` holding `contract_key`. Then delete both files.
|
|
835
|
+
|
|
836
|
+
Without it the job is skipped with a warning. Whoever can push a branch here
|
|
837
|
+
can read the back's code through that key.
|
|
838
|
+
|
|
839
|
+
## License
|
|
840
|
+
|
|
841
|
+
[MIT](./LICENSE)
|