@mesub/node 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +835 -2
- package/dist/chunk-43Q4PNF7.cjs +480 -0
- package/dist/chunk-43Q4PNF7.cjs.map +1 -0
- package/dist/chunk-ELK7IBLO.js +340 -0
- package/dist/chunk-ELK7IBLO.js.map +1 -0
- package/dist/chunk-JJWHMB2X.js +480 -0
- package/dist/chunk-JJWHMB2X.js.map +1 -0
- package/dist/chunk-KMPBFNJE.js +2038 -0
- package/dist/chunk-KMPBFNJE.js.map +1 -0
- package/dist/chunk-PCDZI7B4.cjs +340 -0
- package/dist/chunk-PCDZI7B4.cjs.map +1 -0
- package/dist/chunk-SGSYQJYT.cjs +2038 -0
- package/dist/chunk-SGSYQJYT.cjs.map +1 -0
- package/dist/chunk-VMKI4VVA.cjs +120 -0
- package/dist/chunk-VMKI4VVA.cjs.map +1 -0
- package/dist/chunk-XDLLXY7Y.js +120 -0
- package/dist/chunk-XDLLXY7Y.js.map +1 -0
- package/dist/client-BQuRYSdB.d.ts +759 -0
- package/dist/client-Dm1jamju.d.cts +759 -0
- package/dist/errors-CtPucyOh.d.cts +112 -0
- package/dist/errors-DqW3DMBS.d.ts +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-DKb6iU1z.d.ts +51 -0
- package/dist/guard-Dhy4fI6n.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-CLtZnUzl.d.ts +130 -0
- package/dist/routes-DWkRFOP0.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-D1hE7MUc.d.cts +768 -0
- package/dist/subscriptions-D1hE7MUc.d.ts +768 -0
- package/dist/testing.cjs +1486 -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 +1486 -0
- package/dist/testing.js.map +1 -0
- package/package.json +158 -4
|
@@ -0,0 +1,2038 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } var _class; var _class2; var _class3;// src/cache/access-cache.ts
|
|
2
|
+
var DEFAULT_MAX_STALE_MS = 24 * 60 * 60 * 1e3;
|
|
3
|
+
var AccessCache = class _AccessCache {
|
|
4
|
+
constructor(store, options = {}) {
|
|
5
|
+
this.store = store;
|
|
6
|
+
this.maxStaleMs = _nullishCoalesce(options.maxStaleMs, () => ( DEFAULT_MAX_STALE_MS));
|
|
7
|
+
this.now = _nullishCoalesce(options.now, () => ( Date.now));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `mesub:access:<scope>:<plan>:<who>`, prefixed so it can share a Redis
|
|
14
|
+
* with anything. The scope (the client passes a hash of its API key) keeps
|
|
15
|
+
* two projects sharing one store apart; without one, `mesub:access:<plan>:<who>`.
|
|
16
|
+
*
|
|
17
|
+
* `who` is whatever names the customer, the client's being
|
|
18
|
+
* `<kind>:<wallet or hash>`. A null plan is the list of every plan they
|
|
19
|
+
* have, under its own prefix, `mesub:access-list:`: no plan slug can reach it.
|
|
20
|
+
*/
|
|
21
|
+
static key(who, plan, scope) {
|
|
22
|
+
const prefix = plan === null ? "mesub:access-list" : "mesub:access";
|
|
23
|
+
const path = plan === null ? who : `${plan}:${who}`;
|
|
24
|
+
return scope === void 0 ? `${prefix}:${path}` : `${prefix}:${scope}:${path}`;
|
|
25
|
+
}
|
|
26
|
+
/** With a `deadline` (epoch ms), a store that has not answered by then is a miss. */
|
|
27
|
+
async read(who, plan, scope, deadline) {
|
|
28
|
+
let entry;
|
|
29
|
+
try {
|
|
30
|
+
const pending = this.store.get(_AccessCache.key(who, plan, scope));
|
|
31
|
+
entry = deadline === void 0 ? await pending : await this.within(pending, deadline);
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return void 0;
|
|
34
|
+
}
|
|
35
|
+
const now = this.now();
|
|
36
|
+
if (!entry || now > entry.keepUntil) return void 0;
|
|
37
|
+
return { value: entry.value, fresh: now < entry.freshUntil };
|
|
38
|
+
}
|
|
39
|
+
/** What `pending` gives by `deadline`, or undefined, as a miss, when it has not answered. */
|
|
40
|
+
async within(pending, deadline) {
|
|
41
|
+
let timer;
|
|
42
|
+
const late = new Promise((resolve) => {
|
|
43
|
+
timer = setTimeout(resolve, Math.max(0, deadline - this.now()), void 0);
|
|
44
|
+
});
|
|
45
|
+
try {
|
|
46
|
+
return await Promise.race([pending, late]);
|
|
47
|
+
} finally {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async write(who, plan, value, scope) {
|
|
52
|
+
if (!Number.isFinite(value.revalidate_after)) return;
|
|
53
|
+
const now = this.now();
|
|
54
|
+
const freshUntil = now + Math.max(0, value.revalidate_after) * 1e3;
|
|
55
|
+
const keepUntil = freshUntil + this.maxStaleMs;
|
|
56
|
+
try {
|
|
57
|
+
await this.store.set(
|
|
58
|
+
_AccessCache.key(who, plan, scope),
|
|
59
|
+
{ value, freshUntil, keepUntil },
|
|
60
|
+
keepUntil - now
|
|
61
|
+
);
|
|
62
|
+
} catch (e2) {
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Drops the cached answer when `outdated` says it no longer holds, so the
|
|
67
|
+
* next call asks Mesub. A store without `delete` gets it back as stale
|
|
68
|
+
* instead: never served as is again, only as the outage fallback.
|
|
69
|
+
*/
|
|
70
|
+
async forget(who, plan, scope, outdated) {
|
|
71
|
+
const key = _AccessCache.key(who, plan, scope);
|
|
72
|
+
try {
|
|
73
|
+
const entry = await this.store.get(key);
|
|
74
|
+
if (!entry || !outdated(entry.value)) return;
|
|
75
|
+
if (this.store.delete) {
|
|
76
|
+
await this.store.delete(key);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const now = this.now();
|
|
80
|
+
if (now < entry.keepUntil) {
|
|
81
|
+
await this.store.set(key, { ...entry, freshUntil: now }, entry.keepUntil - now);
|
|
82
|
+
}
|
|
83
|
+
} catch (e3) {
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// src/version.ts
|
|
89
|
+
var VERSION = "0.1.0";
|
|
90
|
+
var API_VERSION = "2026-10-02";
|
|
91
|
+
var API_VERSION_HEADER = "Mesub-Version";
|
|
92
|
+
|
|
93
|
+
// src/options.ts
|
|
94
|
+
var MAX_DELAY = 2147483647;
|
|
95
|
+
var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
96
|
+
function apiKeyOf(given) {
|
|
97
|
+
if (given !== void 0 && typeof given !== "string") {
|
|
98
|
+
throw new TypeError("apiKey must be a string.");
|
|
99
|
+
}
|
|
100
|
+
const apiKey = given || envKey();
|
|
101
|
+
if (!apiKey) {
|
|
102
|
+
throw new TypeError(
|
|
103
|
+
typeof process === "undefined" ? "Missing Mesub API key: there is no process.env here (an edge runtime such as Cloudflare Workers), so pass it: `new Mesub({ apiKey: env.MESUB_API_KEY })`." : "Missing Mesub API key: pass `new Mesub({ apiKey })` or set MESUB_API_KEY."
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return apiKey;
|
|
107
|
+
}
|
|
108
|
+
function envKey() {
|
|
109
|
+
return typeof process === "undefined" ? void 0 : _optionalChain([process, 'access', _ => _.env, 'optionalAccess', _2 => _2["MESUB_API_KEY"]]);
|
|
110
|
+
}
|
|
111
|
+
function baseUrlOf(given, fallback) {
|
|
112
|
+
if (given === void 0) return fallback;
|
|
113
|
+
if (typeof given !== "string") throw new TypeError("baseUrl must be a string.");
|
|
114
|
+
let url;
|
|
115
|
+
try {
|
|
116
|
+
url = new URL(given);
|
|
117
|
+
} catch (e4) {
|
|
118
|
+
throw new TypeError(`baseUrl must be an absolute URL, not ${JSON.stringify(given)}.`);
|
|
119
|
+
}
|
|
120
|
+
if (url.search !== "" || url.hash !== "" || given.includes("?") || given.includes("#")) {
|
|
121
|
+
throw new TypeError(
|
|
122
|
+
`baseUrl must have no query nor fragment, not ${JSON.stringify(given)}.`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const local = url.protocol === "http:" && LOOPBACK.has(url.hostname);
|
|
126
|
+
if (url.protocol !== "https:" && !local) {
|
|
127
|
+
throw new TypeError(
|
|
128
|
+
`baseUrl must be https (http only to localhost or 127.0.0.1), not ${url.protocol}//${url.host}: every call carries the API key.`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return given.replace(/\/+$/, "");
|
|
132
|
+
}
|
|
133
|
+
function numberOf(name, given, fallback, rule = {}) {
|
|
134
|
+
if (given === void 0) return fallback;
|
|
135
|
+
const ok = typeof given === "number" && Number.isFinite(given) && (rule.zero ? given >= 0 : given > 0) && (!rule.integer || Number.isInteger(given)) && (!rule.delay || given <= MAX_DELAY);
|
|
136
|
+
if (!ok) {
|
|
137
|
+
const what = rule.integer ? "an integer" : "a number of milliseconds";
|
|
138
|
+
const range = rule.zero ? "0 or more" : "above 0";
|
|
139
|
+
const cap = rule.delay ? `, ${MAX_DELAY} at most` : "";
|
|
140
|
+
const shown = typeof given === "string" ? JSON.stringify(given) : String(given);
|
|
141
|
+
throw new TypeError(`${name} must be ${what}, ${range}${cap}, not ${shown}.`);
|
|
142
|
+
}
|
|
143
|
+
return given;
|
|
144
|
+
}
|
|
145
|
+
var OWN_HEADERS = /* @__PURE__ */ new Set([
|
|
146
|
+
"authorization",
|
|
147
|
+
"user-agent",
|
|
148
|
+
"accept",
|
|
149
|
+
"content-type",
|
|
150
|
+
API_VERSION_HEADER.toLowerCase()
|
|
151
|
+
]);
|
|
152
|
+
function headersOf(given) {
|
|
153
|
+
if (given === void 0) return {};
|
|
154
|
+
if (typeof given !== "object" || given === null || Array.isArray(given)) {
|
|
155
|
+
throw new TypeError("headers must be an object of header names to string values.");
|
|
156
|
+
}
|
|
157
|
+
const headers = {};
|
|
158
|
+
for (const [name, value] of Object.entries(given)) {
|
|
159
|
+
if (typeof value !== "string") {
|
|
160
|
+
throw new TypeError(`headers.${name} must be a string.`);
|
|
161
|
+
}
|
|
162
|
+
if (OWN_HEADERS.has(name.toLowerCase())) {
|
|
163
|
+
throw new TypeError(`headers cannot set ${name}: the SDK sets it itself.`);
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
new Headers([[name, value]]);
|
|
167
|
+
} catch (e5) {
|
|
168
|
+
throw new TypeError(`headers.${name} is not a valid header name and value.`);
|
|
169
|
+
}
|
|
170
|
+
headers[name] = value;
|
|
171
|
+
}
|
|
172
|
+
return headers;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// src/cache/memory-store.ts
|
|
176
|
+
var DEFAULT_MAX_ENTRIES = 1e4;
|
|
177
|
+
var MemoryStore = (_class = class {
|
|
178
|
+
__init() {this.entries = /* @__PURE__ */ new Map()}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
constructor(options = {}) {;_class.prototype.__init.call(this);
|
|
182
|
+
this.maxEntries = numberOf("maxEntries", options.maxEntries, DEFAULT_MAX_ENTRIES, {
|
|
183
|
+
integer: true
|
|
184
|
+
});
|
|
185
|
+
this.now = _nullishCoalesce(options.now, () => ( Date.now));
|
|
186
|
+
}
|
|
187
|
+
/** How many entries are held, expired ones included until they are read. */
|
|
188
|
+
get size() {
|
|
189
|
+
return this.entries.size;
|
|
190
|
+
}
|
|
191
|
+
get(key) {
|
|
192
|
+
const result = this.entries.get(key);
|
|
193
|
+
if (!result) return void 0;
|
|
194
|
+
if (result.keepUntil < this.now()) {
|
|
195
|
+
this.entries.delete(key);
|
|
196
|
+
return void 0;
|
|
197
|
+
}
|
|
198
|
+
this.entries.delete(key);
|
|
199
|
+
this.entries.set(key, result);
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
set(key, entry, _ttlMs) {
|
|
203
|
+
this.entries.delete(key);
|
|
204
|
+
this.entries.set(key, entry);
|
|
205
|
+
while (this.entries.size > this.maxEntries) {
|
|
206
|
+
const oldest = this.entries.keys().next().value;
|
|
207
|
+
if (oldest === void 0) break;
|
|
208
|
+
this.entries.delete(oldest);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
delete(key) {
|
|
212
|
+
this.entries.delete(key);
|
|
213
|
+
}
|
|
214
|
+
}, _class);
|
|
215
|
+
|
|
216
|
+
// src/customer.ts
|
|
217
|
+
var CUSTOMER_KINDS = ["wallet", "external_id", "email"];
|
|
218
|
+
function customerOf(customer) {
|
|
219
|
+
if (typeof customer === "string") return { kind: "wallet", value: customer };
|
|
220
|
+
const named = CUSTOMER_KINDS.filter(
|
|
221
|
+
(kind2) => _optionalChain([customer, 'optionalAccess', _3 => _3[kind2]]) !== void 0
|
|
222
|
+
);
|
|
223
|
+
const kind = named[0];
|
|
224
|
+
const value = kind && customer[kind];
|
|
225
|
+
if (named.length !== 1 || !kind || typeof value !== "string") {
|
|
226
|
+
throw new TypeError("Ask about exactly one of wallet, external_id or email, as a string.");
|
|
227
|
+
}
|
|
228
|
+
if (kind === "email") return { kind, value: value.trim().toLowerCase() };
|
|
229
|
+
if (kind === "external_id") return { kind, value: value.trim() };
|
|
230
|
+
return { kind, value };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// src/errors.ts
|
|
234
|
+
var MesubError = (_class2 = class extends Error {
|
|
235
|
+
__init2() {this.name = "MesubError"}
|
|
236
|
+
/**
|
|
237
|
+
* The HTTP status, or `null` when no response came back (network error,
|
|
238
|
+
* timeout). On the copy an Express middleware hands to `next(err)` for a
|
|
239
|
+
* 4xx of Mesub's, 500: Mesub's own is on `cause`.
|
|
240
|
+
*/
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The code Mesub's error body names (`subscription_not_found`,
|
|
245
|
+
* `pending_cap_reached`, ...), finer than `code` and as stable: never
|
|
246
|
+
* renamed nor reused, though new ones get added. Null when no Mesub error
|
|
247
|
+
* came back: a network error, a timeout, a body that is not Mesub's.
|
|
248
|
+
*/
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Whether the same call, sent again unchanged, may succeed later without
|
|
252
|
+
* anybody doing anything: Mesub's own `retryable` when it sent one. Not
|
|
253
|
+
* "at once": the SDK has already retried what it retries.
|
|
254
|
+
*/
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The body Mesub answered, parsed when it is JSON, the text otherwise;
|
|
258
|
+
* undefined when no response came back.
|
|
259
|
+
*/
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* How long Mesub asked to wait before the same call is sent again, in
|
|
263
|
+
* milliseconds, from the response's `Retry-After`, as sent. It comes on
|
|
264
|
+
* a 429 (`rate_limited`, or `pending_cap_reached` on create: a quota,
|
|
265
|
+
* not a rate, and the wait may be most of an hour), on every 503 of a
|
|
266
|
+
* route that builds, sends or queues (`network_unavailable`, about 10 s;
|
|
267
|
+
* `unavailable` on `submit`, about 2 s, and on `retry`, about 20 s),
|
|
268
|
+
* and on two 409s:
|
|
269
|
+
* `retry_too_soon` on `retry`, and `comeback_period_rolling` on
|
|
270
|
+
* `create`, `transaction` and `submit`. Null when the response had none,
|
|
271
|
+
* or none came back.
|
|
272
|
+
*/
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The id Mesub gave the request (`req_` and 24 letters or digits), from
|
|
276
|
+
* the response's `Mesub-Request-Id` header, or the error body's
|
|
277
|
+
* `request_id` when a proxy dropped the header (Mesub-io/backend#472).
|
|
278
|
+
* Log it, and quote it to Mesub support: it finds the request in Mesub's
|
|
279
|
+
* logs. Null when no response came back, or one that is not Mesub's.
|
|
280
|
+
*/
|
|
281
|
+
|
|
282
|
+
constructor(message, options) {
|
|
283
|
+
super(message, options.cause === void 0 ? void 0 : { cause: options.cause });_class2.prototype.__init2.call(this);;
|
|
284
|
+
this.status = options.status;
|
|
285
|
+
this.code = options.code;
|
|
286
|
+
this.apiCode = _nullishCoalesce(options.apiCode, () => ( null));
|
|
287
|
+
this.retryable = _nullishCoalesce(options.retryable, () => ( (options.code === "unavailable" || options.code === "rate_limited")));
|
|
288
|
+
this.body = options.body;
|
|
289
|
+
this.retryAfter = _nullishCoalesce(options.retryAfter, () => ( null));
|
|
290
|
+
this.requestId = _nullishCoalesce(options.requestId, () => ( null));
|
|
291
|
+
}
|
|
292
|
+
}, _class2);
|
|
293
|
+
var MesubSubmitError = class extends MesubError {
|
|
294
|
+
/** The subscription as read back after the last send, or null if that read failed. */
|
|
295
|
+
|
|
296
|
+
/** How many times the submit was sent, the same request each time. */
|
|
297
|
+
|
|
298
|
+
constructor(message, options) {
|
|
299
|
+
super(message, options);
|
|
300
|
+
this.subscription = options.subscription;
|
|
301
|
+
this.sends = options.sends;
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
function codeFor(status, apiCode) {
|
|
305
|
+
if (status === 404 && apiCode === "plan_not_found") return "plan_not_found";
|
|
306
|
+
if (status === 400) return "invalid_request";
|
|
307
|
+
if (status === 401) return "unauthorized";
|
|
308
|
+
if (status === 403) return "forbidden";
|
|
309
|
+
if (status === 404) return "not_found";
|
|
310
|
+
if (status === 409) return "conflict";
|
|
311
|
+
if (status === 429) return "rate_limited";
|
|
312
|
+
if (status >= 500) return "unavailable";
|
|
313
|
+
return "unexpected";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// src/validate.ts
|
|
317
|
+
var STRING = { check: (value) => typeof value === "string", expected: "a string" };
|
|
318
|
+
var STRING_OR_NULL = {
|
|
319
|
+
check: (value) => value === null || typeof value === "string",
|
|
320
|
+
expected: "a string or null"
|
|
321
|
+
};
|
|
322
|
+
var OPTIONAL_STRING = { ...STRING, optional: true };
|
|
323
|
+
var COUNT = {
|
|
324
|
+
check: (value) => typeof value === "number" && Number.isInteger(value) && value >= 0,
|
|
325
|
+
expected: "a whole number"
|
|
326
|
+
};
|
|
327
|
+
var BOOLEAN = { check: (value) => typeof value === "boolean", expected: "a boolean" };
|
|
328
|
+
var OBJECT = { check: isObject, expected: "an object" };
|
|
329
|
+
var LIST_OF = { check: Array.isArray, expected: "a list" };
|
|
330
|
+
var DATE = { check: isDate, expected: "a date" };
|
|
331
|
+
var DATE_OR_NULL = {
|
|
332
|
+
check: (value) => value === null || isDate(value),
|
|
333
|
+
expected: "a date or null"
|
|
334
|
+
};
|
|
335
|
+
var RETRY_DEADLINE = { ...DATE_OR_NULL, absent: null };
|
|
336
|
+
var PAUSED = { ...BOOLEAN, absent: false };
|
|
337
|
+
var END_REASON = { ...STRING_OR_NULL, absent: null };
|
|
338
|
+
var BLOCKED_SINCE = { ...DATE_OR_NULL, absent: null };
|
|
339
|
+
var BLOCKED_REASON = { ...STRING_OR_NULL, absent: null };
|
|
340
|
+
var SUBSCRIPTION_ID = { ...STRING_OR_NULL, absent: null };
|
|
341
|
+
var STOP_REASON = { ...STRING_OR_NULL, absent: null };
|
|
342
|
+
var CLOSABLE = { ...BOOLEAN, absent: false };
|
|
343
|
+
var CLOSABLE_FROM = { ...DATE_OR_NULL, absent: null };
|
|
344
|
+
var TRANSACTION_EXPIRES_AT = { ...DATE_OR_NULL, absent: null };
|
|
345
|
+
var AMOUNT_USD = { ...STRING_OR_NULL, absent: null };
|
|
346
|
+
var TOKEN = {
|
|
347
|
+
...OBJECT,
|
|
348
|
+
absent: () => ({ symbol: null, name: null, logo_url: null, verified: false })
|
|
349
|
+
};
|
|
350
|
+
var RETRY_COUNT = {
|
|
351
|
+
check: (value) => value === null || COUNT.check(value),
|
|
352
|
+
expected: "a whole number or null",
|
|
353
|
+
absent: null
|
|
354
|
+
};
|
|
355
|
+
var SECONDS = {
|
|
356
|
+
check: (value) => typeof value === "number" && Number.isFinite(value),
|
|
357
|
+
expected: "a finite number of seconds"
|
|
358
|
+
};
|
|
359
|
+
var ANSWER = {
|
|
360
|
+
/** Null asked by external id or email, for a customer with nothing on that plan. */
|
|
361
|
+
wallet: STRING_OR_NULL,
|
|
362
|
+
plan: STRING,
|
|
363
|
+
subscription_id: SUBSCRIPTION_ID,
|
|
364
|
+
access: BOOLEAN,
|
|
365
|
+
status: STRING,
|
|
366
|
+
paused: PAUSED,
|
|
367
|
+
end_reason: END_REASON,
|
|
368
|
+
late_reason: STRING_OR_NULL,
|
|
369
|
+
stop_reason: STOP_REASON,
|
|
370
|
+
payment_status: STRING,
|
|
371
|
+
blocked_since: BLOCKED_SINCE,
|
|
372
|
+
blocked_reason: BLOCKED_REASON,
|
|
373
|
+
subscribed_since: DATE_OR_NULL,
|
|
374
|
+
first_subscribed_at: DATE_OR_NULL,
|
|
375
|
+
current_period_end: DATE_OR_NULL,
|
|
376
|
+
cancelled_at: DATE_OR_NULL,
|
|
377
|
+
access_until: DATE_OR_NULL,
|
|
378
|
+
next_charge_at: DATE_OR_NULL,
|
|
379
|
+
next_retry_at: DATE_OR_NULL,
|
|
380
|
+
retry_deadline: RETRY_DEADLINE,
|
|
381
|
+
closable: CLOSABLE,
|
|
382
|
+
closable_from: CLOSABLE_FROM,
|
|
383
|
+
revalidate_after: SECONDS
|
|
384
|
+
};
|
|
385
|
+
var ATTEMPT = {
|
|
386
|
+
outcome: STRING,
|
|
387
|
+
reason: STRING_OR_NULL,
|
|
388
|
+
amount: STRING,
|
|
389
|
+
attempted_at: DATE,
|
|
390
|
+
signature: STRING_OR_NULL
|
|
391
|
+
};
|
|
392
|
+
function accessAnswerFrom(body) {
|
|
393
|
+
const problem = answerProblem(body);
|
|
394
|
+
if (problem !== null) throw unreadable(problem, body);
|
|
395
|
+
return body;
|
|
396
|
+
}
|
|
397
|
+
function accessListFrom(body) {
|
|
398
|
+
const problem = _nullishCoalesce(problemWith(body, LIST), () => ( plansProblem(body)));
|
|
399
|
+
if (problem !== null) throw unreadable(problem, body);
|
|
400
|
+
return body;
|
|
401
|
+
}
|
|
402
|
+
var LIST = {
|
|
403
|
+
plans: LIST_OF,
|
|
404
|
+
revalidate_after: SECONDS
|
|
405
|
+
};
|
|
406
|
+
function answerProblem(body, label) {
|
|
407
|
+
return _nullishCoalesce(problemWith(body, ANSWER, label), () => ( attemptsProblem(body, label)));
|
|
408
|
+
}
|
|
409
|
+
function plansProblem(body) {
|
|
410
|
+
const { plans } = body;
|
|
411
|
+
for (const [index, answer] of plans.entries()) {
|
|
412
|
+
const problem = answerProblem(answer, `plans[${index}]`);
|
|
413
|
+
if (problem !== null) return problem;
|
|
414
|
+
}
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
var SUBSCRIPTION = {
|
|
418
|
+
id: STRING,
|
|
419
|
+
status: STRING,
|
|
420
|
+
paused: PAUSED,
|
|
421
|
+
end_reason: END_REASON,
|
|
422
|
+
late_reason: STRING_OR_NULL,
|
|
423
|
+
stop_reason: STOP_REASON,
|
|
424
|
+
access: BOOLEAN,
|
|
425
|
+
payment_status: STRING,
|
|
426
|
+
blocked_since: BLOCKED_SINCE,
|
|
427
|
+
blocked_reason: BLOCKED_REASON,
|
|
428
|
+
plan: STRING_OR_NULL,
|
|
429
|
+
wallet: STRING,
|
|
430
|
+
email: STRING_OR_NULL,
|
|
431
|
+
external_id: STRING_OR_NULL,
|
|
432
|
+
current_period_start: DATE_OR_NULL,
|
|
433
|
+
current_period_end: DATE_OR_NULL,
|
|
434
|
+
next_charge_at: DATE_OR_NULL,
|
|
435
|
+
next_retry_at: DATE_OR_NULL,
|
|
436
|
+
retry_deadline: RETRY_DEADLINE,
|
|
437
|
+
next_retry_number: RETRY_COUNT,
|
|
438
|
+
retries_allowed: RETRY_COUNT,
|
|
439
|
+
access_until: DATE_OR_NULL,
|
|
440
|
+
closable: CLOSABLE,
|
|
441
|
+
closable_from: CLOSABLE_FROM,
|
|
442
|
+
created_at: DATE,
|
|
443
|
+
confirmed_at: DATE_OR_NULL
|
|
444
|
+
};
|
|
445
|
+
var SUBSCRIPTION_LIST = {
|
|
446
|
+
data: LIST_OF,
|
|
447
|
+
has_more: BOOLEAN
|
|
448
|
+
};
|
|
449
|
+
var SUBSCRIPTION_ATTEMPT = {
|
|
450
|
+
id: STRING,
|
|
451
|
+
attempted_at: DATE,
|
|
452
|
+
outcome: STRING,
|
|
453
|
+
reason: STRING_OR_NULL,
|
|
454
|
+
amount: STRING,
|
|
455
|
+
amount_usd: AMOUNT_USD,
|
|
456
|
+
signature: STRING_OR_NULL,
|
|
457
|
+
retry: BOOLEAN,
|
|
458
|
+
retry_number: RETRY_COUNT,
|
|
459
|
+
retries_allowed: RETRY_COUNT,
|
|
460
|
+
period_start: DATE_OR_NULL
|
|
461
|
+
};
|
|
462
|
+
var SUBSCRIPTION_ATTEMPT_LIST = {
|
|
463
|
+
data: LIST_OF,
|
|
464
|
+
has_more: BOOLEAN,
|
|
465
|
+
paid: OBJECT
|
|
466
|
+
};
|
|
467
|
+
var BASE_UNITS = {
|
|
468
|
+
check: (value) => typeof value === "string" && /^\d+$/.test(value),
|
|
469
|
+
expected: "a whole number as a string"
|
|
470
|
+
};
|
|
471
|
+
var SUBSCRIBE_TRANSACTION = {
|
|
472
|
+
subscription: OBJECT,
|
|
473
|
+
transaction: STRING,
|
|
474
|
+
last_valid_block_height: STRING,
|
|
475
|
+
transaction_expires_at: TRANSACTION_EXPIRES_AT,
|
|
476
|
+
costs: OBJECT,
|
|
477
|
+
terms: OBJECT
|
|
478
|
+
};
|
|
479
|
+
var COSTS = {
|
|
480
|
+
rent: OBJECT,
|
|
481
|
+
fee: OBJECT,
|
|
482
|
+
total: BASE_UNITS
|
|
483
|
+
};
|
|
484
|
+
var RENT = {
|
|
485
|
+
subscription: BASE_UNITS,
|
|
486
|
+
// Null when the authority exists already: no rent for it.
|
|
487
|
+
authority: {
|
|
488
|
+
check: (value) => value === null || BASE_UNITS.check(value),
|
|
489
|
+
expected: "a whole number as a string or null"
|
|
490
|
+
},
|
|
491
|
+
total: BASE_UNITS
|
|
492
|
+
};
|
|
493
|
+
var FEE = {
|
|
494
|
+
signatures: COUNT,
|
|
495
|
+
per_signature: BASE_UNITS,
|
|
496
|
+
priority: BASE_UNITS,
|
|
497
|
+
total: BASE_UNITS
|
|
498
|
+
};
|
|
499
|
+
var PLAN = {
|
|
500
|
+
slug: STRING,
|
|
501
|
+
name: STRING,
|
|
502
|
+
description: STRING_OR_NULL,
|
|
503
|
+
project_name: STRING,
|
|
504
|
+
logo_url: STRING_OR_NULL,
|
|
505
|
+
amount: STRING,
|
|
506
|
+
amount_display: STRING,
|
|
507
|
+
amount_usd: AMOUNT_USD,
|
|
508
|
+
decimals: COUNT,
|
|
509
|
+
symbol: STRING_OR_NULL,
|
|
510
|
+
token: TOKEN,
|
|
511
|
+
mint: STRING,
|
|
512
|
+
period_hours: COUNT,
|
|
513
|
+
network: STRING,
|
|
514
|
+
// A string, not one of two: a status added later is handed back, not refused.
|
|
515
|
+
status: STRING,
|
|
516
|
+
available: BOOLEAN,
|
|
517
|
+
ends_at: DATE_OR_NULL
|
|
518
|
+
};
|
|
519
|
+
var PLAN_TOKEN = {
|
|
520
|
+
symbol: STRING_OR_NULL,
|
|
521
|
+
name: STRING_OR_NULL,
|
|
522
|
+
logo_url: STRING_OR_NULL,
|
|
523
|
+
verified: BOOLEAN
|
|
524
|
+
};
|
|
525
|
+
function planProblem(body, label) {
|
|
526
|
+
return _nullishCoalesce(problemWith(body, PLAN, label), () => ( problemWith(
|
|
527
|
+
body.token,
|
|
528
|
+
PLAN_TOKEN,
|
|
529
|
+
label === void 0 ? "token" : `${label}.token`
|
|
530
|
+
)));
|
|
531
|
+
}
|
|
532
|
+
function planFrom(body) {
|
|
533
|
+
const problem = planProblem(body);
|
|
534
|
+
if (problem !== null) throw unreadable(problem, body, "GET /v1/plans/:slug");
|
|
535
|
+
return body;
|
|
536
|
+
}
|
|
537
|
+
function planListFrom(body) {
|
|
538
|
+
const problem = _nullishCoalesce(problemWith(body, { plans: LIST_OF }), () => ( firstProblem(body.plans, "plans", planProblem)));
|
|
539
|
+
if (problem !== null) throw unreadable(problem, body, "GET /v1/plans");
|
|
540
|
+
return body.plans;
|
|
541
|
+
}
|
|
542
|
+
function serverSubscriptionFrom(body, route = "GET /v1/subscriptions/:id", status = 200) {
|
|
543
|
+
const problem = problemWith(body, SUBSCRIPTION);
|
|
544
|
+
if (problem !== null) throw unreadable(problem, body, route, status);
|
|
545
|
+
return body;
|
|
546
|
+
}
|
|
547
|
+
function serverSubscriptionListFrom(body) {
|
|
548
|
+
const problem = _nullishCoalesce(problemWith(body, SUBSCRIPTION_LIST), () => ( firstProblem(body.data, "data", SUBSCRIPTION)));
|
|
549
|
+
if (problem !== null) throw unreadable(problem, body, "GET /v1/subscriptions");
|
|
550
|
+
return body;
|
|
551
|
+
}
|
|
552
|
+
function subscriptionAttemptListFrom(body) {
|
|
553
|
+
const problem = _nullishCoalesce(_nullishCoalesce(problemWith(body, SUBSCRIPTION_ATTEMPT_LIST), () => ( firstProblem(body.data, "data", SUBSCRIPTION_ATTEMPT))), () => ( problemWith(
|
|
554
|
+
body.paid,
|
|
555
|
+
{ count: COUNT, amount: BASE_UNITS },
|
|
556
|
+
"paid"
|
|
557
|
+
)));
|
|
558
|
+
if (problem !== null) throw unreadable(problem, body, "GET /v1/subscriptions/:id/attempts");
|
|
559
|
+
return body;
|
|
560
|
+
}
|
|
561
|
+
function subscribeTransactionFrom(body, route = "POST /v1/subscriptions") {
|
|
562
|
+
const problem = _nullishCoalesce(_nullishCoalesce(_nullishCoalesce(_nullishCoalesce(_nullishCoalesce(problemWith(body, SUBSCRIBE_TRANSACTION), () => ( problemWith(
|
|
563
|
+
body.subscription,
|
|
564
|
+
{ id: STRING, status: STRING },
|
|
565
|
+
"subscription"
|
|
566
|
+
))), () => ( problemWith(body.costs, COSTS, "costs"))), () => ( problemWith(body.costs.rent, RENT, "costs.rent"))), () => ( problemWith(body.costs.fee, FEE, "costs.fee"))), () => ( problemWith(
|
|
567
|
+
body.terms,
|
|
568
|
+
{ message: STRING, expires_at: DATE },
|
|
569
|
+
"terms"
|
|
570
|
+
)));
|
|
571
|
+
if (problem !== null) throw unreadable(problem, body, route, 201);
|
|
572
|
+
return body;
|
|
573
|
+
}
|
|
574
|
+
function submitResultFrom(body) {
|
|
575
|
+
return confirmResultFrom(body, "POST /v1/subscriptions/:id/submit");
|
|
576
|
+
}
|
|
577
|
+
function confirmResultFrom(body, route) {
|
|
578
|
+
const problem = _nullishCoalesce(_nullishCoalesce(problemWith(body, { subscription: OBJECT }), () => ( problemWith(body.subscription, SUBSCRIPTION, "subscription"))), () => ( reasonProblem(body)));
|
|
579
|
+
if (problem !== null) throw unreadable(problem, body, route, 201);
|
|
580
|
+
return body;
|
|
581
|
+
}
|
|
582
|
+
var WALLET_TRANSACTION = {
|
|
583
|
+
transaction: STRING,
|
|
584
|
+
last_valid_block_height: STRING,
|
|
585
|
+
transaction_expires_at: TRANSACTION_EXPIRES_AT
|
|
586
|
+
};
|
|
587
|
+
function walletTransactionFrom(body, route) {
|
|
588
|
+
const problem = problemWith(body, WALLET_TRANSACTION);
|
|
589
|
+
if (problem !== null) throw unreadable(problem, body, route, 201);
|
|
590
|
+
return body;
|
|
591
|
+
}
|
|
592
|
+
function reasonProblem({ reason, reason_code }) {
|
|
593
|
+
if (reason !== void 0 && typeof reason !== "string") return "reason is not a string";
|
|
594
|
+
if (reason_code !== void 0 && typeof reason_code !== "string") {
|
|
595
|
+
return "reason_code is not a string";
|
|
596
|
+
}
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
var SEQUENCE = {
|
|
600
|
+
check: (value) => typeof value === "number" && Number.isInteger(value) && value >= 1,
|
|
601
|
+
expected: "a whole number from 1",
|
|
602
|
+
optional: true
|
|
603
|
+
};
|
|
604
|
+
var EVENT = {
|
|
605
|
+
id: STRING,
|
|
606
|
+
object: { check: (value) => value === "event", expected: '"event"' },
|
|
607
|
+
type: STRING,
|
|
608
|
+
created_at: DATE,
|
|
609
|
+
network: STRING,
|
|
610
|
+
api_version: STRING,
|
|
611
|
+
sequence: SEQUENCE,
|
|
612
|
+
data: OBJECT,
|
|
613
|
+
detail: OBJECT
|
|
614
|
+
};
|
|
615
|
+
var TEST = { check: (value) => value === true, expected: "true", optional: true };
|
|
616
|
+
var OLDER_WEBHOOK = { type: STRING, created_at: DATE, data: OBJECT, test: TEST };
|
|
617
|
+
var FIRST_PAYMENT = {
|
|
618
|
+
check: (value) => value === null || problemWith(value, { amount: STRING, mint: STRING_OR_NULL, signature: STRING_OR_NULL }) === null,
|
|
619
|
+
expected: "null or a payment (amount, mint, signature)",
|
|
620
|
+
optional: true
|
|
621
|
+
};
|
|
622
|
+
var ACCESS_CHANGED = {
|
|
623
|
+
changed_at: DATE,
|
|
624
|
+
blocked_reason: { ...STRING_OR_NULL, optional: true },
|
|
625
|
+
access_until: { ...DATE_OR_NULL, optional: true }
|
|
626
|
+
};
|
|
627
|
+
var DETAILS = {
|
|
628
|
+
"subscription.created": {
|
|
629
|
+
previous_id: OPTIONAL_STRING,
|
|
630
|
+
first_payment: FIRST_PAYMENT
|
|
631
|
+
},
|
|
632
|
+
"subscription.ended": {
|
|
633
|
+
changed_at: { ...DATE, optional: true },
|
|
634
|
+
// Only `cancelled` today: a newer word is let through, as a status is.
|
|
635
|
+
end_reason: OPTIONAL_STRING
|
|
636
|
+
},
|
|
637
|
+
"subscription.renewal_upcoming": {
|
|
638
|
+
can_pay: BOOLEAN,
|
|
639
|
+
renewal_issue: STRING_OR_NULL,
|
|
640
|
+
amount: STRING,
|
|
641
|
+
mint: STRING,
|
|
642
|
+
due_at: DATE
|
|
643
|
+
},
|
|
644
|
+
"subscription.renewed": {
|
|
645
|
+
amount: STRING,
|
|
646
|
+
mint: STRING,
|
|
647
|
+
period_start: DATE,
|
|
648
|
+
period_end: DATE,
|
|
649
|
+
signature: STRING
|
|
650
|
+
},
|
|
651
|
+
"subscription.payment_failed": {
|
|
652
|
+
reason: STRING,
|
|
653
|
+
amount: STRING,
|
|
654
|
+
mint: STRING,
|
|
655
|
+
period_start: DATE_OR_NULL,
|
|
656
|
+
period_end: DATE_OR_NULL,
|
|
657
|
+
next_retry_at: DATE_OR_NULL,
|
|
658
|
+
retry_deadline: DATE_OR_NULL,
|
|
659
|
+
retries_left: COUNT,
|
|
660
|
+
retry_mode: STRING_OR_NULL
|
|
661
|
+
},
|
|
662
|
+
"subscription.stopped": {
|
|
663
|
+
reason: STRING
|
|
664
|
+
},
|
|
665
|
+
"subscription.access_granted": ACCESS_CHANGED,
|
|
666
|
+
"subscription.access_revoked": ACCESS_CHANGED
|
|
667
|
+
};
|
|
668
|
+
function eventProblem(body, label) {
|
|
669
|
+
const prefix = label === void 0 ? "" : `${label}.`;
|
|
670
|
+
const { type, data, detail } = body;
|
|
671
|
+
const fields = DETAILS[type];
|
|
672
|
+
return _nullishCoalesce(_nullishCoalesce(problemWith(body, EVENT, label), () => ( problemWith(data, SUBSCRIPTION, `${prefix}data`))), () => ( (fields ? problemWith(detail, fields, `${prefix}detail`) : null)));
|
|
673
|
+
}
|
|
674
|
+
function eventFrom(body) {
|
|
675
|
+
const problem = eventProblem(body);
|
|
676
|
+
if (problem !== null) throw unreadable(problem, body, "GET /v1/events/:id");
|
|
677
|
+
return body;
|
|
678
|
+
}
|
|
679
|
+
function eventListFrom(body) {
|
|
680
|
+
const problem = _nullishCoalesce(problemWith(body, EVENT_LIST), () => ( firstProblem(body.data, "data", eventProblem)));
|
|
681
|
+
if (problem !== null) throw unreadable(problem, body, "GET /v1/events");
|
|
682
|
+
return body;
|
|
683
|
+
}
|
|
684
|
+
var EVENT_LIST = {
|
|
685
|
+
data: LIST_OF,
|
|
686
|
+
has_more: BOOLEAN,
|
|
687
|
+
available_since: DATE
|
|
688
|
+
};
|
|
689
|
+
function webhookEventFrom(body, deliveryId) {
|
|
690
|
+
const older = isObject(body) && !("id" in body) && !("object" in body);
|
|
691
|
+
const problem = older ? olderWebhookProblem(body) : _nullishCoalesce(eventProblem(body), () => ( problemWith(body, { test: TEST })));
|
|
692
|
+
if (problem !== null) {
|
|
693
|
+
throw new MesubError(`Mesub sent a webhook this SDK cannot read: ${problem}.`, {
|
|
694
|
+
status: null,
|
|
695
|
+
code: "unexpected",
|
|
696
|
+
body
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
if (!older) return { ...body, delivery_id: deliveryId };
|
|
700
|
+
const { type, created_at, data, test } = body;
|
|
701
|
+
const { detail, ...subscription } = data;
|
|
702
|
+
return {
|
|
703
|
+
id: null,
|
|
704
|
+
delivery_id: deliveryId,
|
|
705
|
+
object: "event",
|
|
706
|
+
type,
|
|
707
|
+
created_at,
|
|
708
|
+
network: null,
|
|
709
|
+
api_version: null,
|
|
710
|
+
data: subscription,
|
|
711
|
+
detail,
|
|
712
|
+
...test !== void 0 && { test }
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
function olderWebhookProblem(body) {
|
|
716
|
+
const { type, data } = body;
|
|
717
|
+
const fields = DETAILS[type];
|
|
718
|
+
return _nullishCoalesce(_nullishCoalesce(problemWith(body, OLDER_WEBHOOK), () => ( problemWith(data, { ...SUBSCRIPTION, detail: OBJECT }, "data"))), () => ( (fields ? problemWith(data.detail, fields, "data.detail") : null)));
|
|
719
|
+
}
|
|
720
|
+
function firstProblem(items, label, fields) {
|
|
721
|
+
for (const [index, item] of items.entries()) {
|
|
722
|
+
const at = `${label}[${index}]`;
|
|
723
|
+
const problem = typeof fields === "function" ? fields(item, at) : problemWith(item, fields, at);
|
|
724
|
+
if (problem !== null) return problem;
|
|
725
|
+
}
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
function unreadable(problem, body, route = "/v1/access", status = 200) {
|
|
729
|
+
return new MesubError(
|
|
730
|
+
`Mesub answered ${route} with an answer this SDK cannot read: ${problem}.`,
|
|
731
|
+
{ status, code: "unexpected", body }
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
function problemWith(body, fields, label) {
|
|
735
|
+
if (!isObject(body)) {
|
|
736
|
+
return `${_nullishCoalesce(label, () => ( "the body"))} is not an object`;
|
|
737
|
+
}
|
|
738
|
+
const prefix = label === void 0 ? "" : `${label}.`;
|
|
739
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
740
|
+
const value = body[name];
|
|
741
|
+
if (value === void 0 && field.optional) continue;
|
|
742
|
+
if (value === void 0 && "absent" in field) {
|
|
743
|
+
body[name] = typeof field.absent === "function" ? field.absent() : field.absent;
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
if (value === void 0) return `${prefix}${name} is missing`;
|
|
747
|
+
if (!field.check(value)) return `${prefix}${name} is not ${field.expected}`;
|
|
748
|
+
}
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
function attemptsProblem(body, label) {
|
|
752
|
+
const { attempts } = body;
|
|
753
|
+
const prefix = label === void 0 ? "" : `${label}.`;
|
|
754
|
+
if (attempts === void 0) return null;
|
|
755
|
+
if (!Array.isArray(attempts)) return `${prefix}attempts is not a list`;
|
|
756
|
+
for (const [index, attempt] of attempts.entries()) {
|
|
757
|
+
const problem = problemWith(attempt, ATTEMPT, `${prefix}attempts[${index}]`);
|
|
758
|
+
if (problem !== null) return problem;
|
|
759
|
+
}
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
762
|
+
function isDate(value) {
|
|
763
|
+
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
|
764
|
+
}
|
|
765
|
+
function isObject(value) {
|
|
766
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// src/webhooks.ts
|
|
770
|
+
var SECRET_PREFIX = "whsec_";
|
|
771
|
+
var DEFAULT_TOLERANCE = 5 * 60 * 1e3;
|
|
772
|
+
var TIMESTAMP = /^[0-9]{1,15}$/;
|
|
773
|
+
async function verifyWebhook(body, headers, options = {}) {
|
|
774
|
+
return verify(body, headers, options, void 0);
|
|
775
|
+
}
|
|
776
|
+
var Webhooks = class {
|
|
777
|
+
#secret;
|
|
778
|
+
#received;
|
|
779
|
+
/** @internal */
|
|
780
|
+
constructor(secret, received) {
|
|
781
|
+
this.#secret = secret;
|
|
782
|
+
this.#received = received;
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* `verifyWebhook`, with `webhookSecret` unless `options.secret` says
|
|
786
|
+
* otherwise. A verified event also drops every access answer cached for
|
|
787
|
+
* its customer, the yes and the no, so the next check asks Mesub.
|
|
788
|
+
* Three drop nothing: `subscription.renewal_upcoming`, which moves no
|
|
789
|
+
* access, the `test` event, and any body with `test: true`, whose
|
|
790
|
+
* subscription is made up.
|
|
791
|
+
*/
|
|
792
|
+
async verify(body, headers, options = {}) {
|
|
793
|
+
const event = await verify(body, headers, options, this.#secret);
|
|
794
|
+
const moves = event.test !== true && event.type !== "test" && event.type !== "subscription.renewal_upcoming";
|
|
795
|
+
if (moves) await this.#received(event);
|
|
796
|
+
return event;
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
async function verify(body, headers, options, fallback) {
|
|
800
|
+
const key = options.secret !== void 0 || fallback !== void 0 ? keyOf(_nullishCoalesce(options.secret, () => ( fallback)), "secret") : keyOf(envSecret(), "MESUB_WEBHOOK_SECRET");
|
|
801
|
+
const tolerance = numberOf("tolerance", options.tolerance, DEFAULT_TOLERANCE);
|
|
802
|
+
const bytes = bytesOf(body);
|
|
803
|
+
if (typeof headers !== "object" || headers === null) {
|
|
804
|
+
throw new TypeError("headers must be the request headers: a Headers or a plain object.");
|
|
805
|
+
}
|
|
806
|
+
const id = headerIn(headers, "webhook-id");
|
|
807
|
+
const timestamp = headerIn(headers, "webhook-timestamp");
|
|
808
|
+
const signatures = headerIn(headers, "webhook-signature");
|
|
809
|
+
if (!id || !timestamp || !signatures) {
|
|
810
|
+
const missing = [
|
|
811
|
+
!id && "webhook-id",
|
|
812
|
+
!timestamp && "webhook-timestamp",
|
|
813
|
+
!signatures && "webhook-signature"
|
|
814
|
+
].filter(Boolean);
|
|
815
|
+
throw invalid(`The webhook has no ${missing.join(", ")} header: it is not one of Mesub's.`);
|
|
816
|
+
}
|
|
817
|
+
if (!TIMESTAMP.test(timestamp)) {
|
|
818
|
+
throw invalid("The webhook-timestamp header is not a number of seconds.");
|
|
819
|
+
}
|
|
820
|
+
const expected = await signatureOf(key, id, timestamp, bytes);
|
|
821
|
+
let matched = false;
|
|
822
|
+
for (const candidate of signatures.split(" ")) {
|
|
823
|
+
const [version, encoded] = candidate.split(",");
|
|
824
|
+
const given = version === "v1" && encoded !== void 0 ? fromBase64(encoded) : null;
|
|
825
|
+
if (given !== null && sameBytes(given, expected)) matched = true;
|
|
826
|
+
}
|
|
827
|
+
if (!matched) {
|
|
828
|
+
throw invalid(
|
|
829
|
+
"No signature of the webhook matches the secret: not Mesub's, another endpoint's secret, or a body changed on its way (parsed then serialized again?). Verify the raw body."
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
const skew = Date.now() - Number(timestamp) * 1e3;
|
|
833
|
+
if (Math.abs(skew) > tolerance) {
|
|
834
|
+
throw invalid(
|
|
835
|
+
`The webhook was signed ${Math.round(Math.abs(skew) / 1e3)} s ${skew > 0 ? "ago" : "ahead of this clock"}, beyond the ${tolerance / 1e3} s tolerance: a replay, or this server's clock is off.`
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
return webhookEventFrom(parsed(body, bytes), id);
|
|
839
|
+
}
|
|
840
|
+
function bytesOf(body) {
|
|
841
|
+
if (typeof body === "string") return new TextEncoder().encode(body);
|
|
842
|
+
if (body instanceof Uint8Array) return new Uint8Array(body);
|
|
843
|
+
if (body instanceof ArrayBuffer) return new Uint8Array(body.slice(0));
|
|
844
|
+
throw new TypeError(
|
|
845
|
+
"body must be the raw body as received, a string or a Buffer, never parsed: the signature is over its exact bytes. With Express, read it with express.raw({ type: 'application/json' }); in a Next route, await request.text()."
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
function parsed(body, bytes) {
|
|
849
|
+
try {
|
|
850
|
+
const text = typeof body === "string" ? body : new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
851
|
+
return JSON.parse(text);
|
|
852
|
+
} catch (cause) {
|
|
853
|
+
throw new MesubError("Mesub sent a webhook whose body is not JSON.", {
|
|
854
|
+
status: null,
|
|
855
|
+
code: "unexpected",
|
|
856
|
+
cause
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
function keyOf(secret, name) {
|
|
861
|
+
if (secret === void 0 || secret === "") {
|
|
862
|
+
throw new TypeError(
|
|
863
|
+
typeof process === "undefined" ? "Missing Mesub webhook secret: there is no process.env here, so pass it: `{ secret: env.MESUB_WEBHOOK_SECRET }` or `new Mesub({ webhookSecret })`." : "Missing Mesub webhook secret: pass `{ secret }` or `new Mesub({ webhookSecret })`, or set MESUB_WEBHOOK_SECRET."
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
const key = typeof secret === "string" && secret.startsWith(SECRET_PREFIX) ? fromBase64(secret.slice(SECRET_PREFIX.length)) : null;
|
|
867
|
+
if (key === null || key.length === 0) {
|
|
868
|
+
throw new TypeError(
|
|
869
|
+
`${name} must be the endpoint's signing secret, whsec_..., from its page in the Mesub dashboard` + (typeof secret === "string" && /^(SUB|PUB)_/.test(secret) ? ", not an API key." : ".")
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
return key;
|
|
873
|
+
}
|
|
874
|
+
function webhookSecretOf(given) {
|
|
875
|
+
if (given !== void 0 && typeof given !== "string") {
|
|
876
|
+
throw new TypeError("webhookSecret must be a string.");
|
|
877
|
+
}
|
|
878
|
+
const secret = given || envSecret();
|
|
879
|
+
if (secret) keyOf(secret, given ? "webhookSecret" : "MESUB_WEBHOOK_SECRET");
|
|
880
|
+
return secret || void 0;
|
|
881
|
+
}
|
|
882
|
+
function envSecret() {
|
|
883
|
+
return typeof process === "undefined" ? void 0 : _optionalChain([process, 'access', _4 => _4.env, 'optionalAccess', _5 => _5["MESUB_WEBHOOK_SECRET"]]);
|
|
884
|
+
}
|
|
885
|
+
function headerIn(headers, name) {
|
|
886
|
+
if (typeof headers.get === "function") {
|
|
887
|
+
return _nullishCoalesce(headers.get(name), () => ( void 0));
|
|
888
|
+
}
|
|
889
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
890
|
+
if (key.toLowerCase() === name) return Array.isArray(value) ? value[0] : value;
|
|
891
|
+
}
|
|
892
|
+
return void 0;
|
|
893
|
+
}
|
|
894
|
+
async function signatureOf(key, id, timestamp, body) {
|
|
895
|
+
const prefix = new TextEncoder().encode(`${id}.${timestamp}.`);
|
|
896
|
+
const signed = new Uint8Array(prefix.length + body.length);
|
|
897
|
+
signed.set(prefix);
|
|
898
|
+
signed.set(body, prefix.length);
|
|
899
|
+
const hmac = await crypto.subtle.importKey(
|
|
900
|
+
"raw",
|
|
901
|
+
key,
|
|
902
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
903
|
+
false,
|
|
904
|
+
["sign"]
|
|
905
|
+
);
|
|
906
|
+
return new Uint8Array(await crypto.subtle.sign("HMAC", hmac, signed));
|
|
907
|
+
}
|
|
908
|
+
async function signedHeaders(secret, id, timestamp, body) {
|
|
909
|
+
const key = keyOf(secret, "secret");
|
|
910
|
+
const signature = await signatureOf(key, id, String(timestamp), new TextEncoder().encode(body));
|
|
911
|
+
return {
|
|
912
|
+
"webhook-id": id,
|
|
913
|
+
"webhook-timestamp": String(timestamp),
|
|
914
|
+
"webhook-signature": `v1,${toBase64(signature)}`
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
function sameBytes(a, b) {
|
|
918
|
+
if (a.length !== b.length) return false;
|
|
919
|
+
let difference = 0;
|
|
920
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
921
|
+
difference |= (_nullishCoalesce(a[index], () => ( 0))) ^ (_nullishCoalesce(b[index], () => ( 0)));
|
|
922
|
+
}
|
|
923
|
+
return difference === 0;
|
|
924
|
+
}
|
|
925
|
+
function fromBase64(text) {
|
|
926
|
+
try {
|
|
927
|
+
return Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
|
|
928
|
+
} catch (e6) {
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
function toBase64(bytes) {
|
|
933
|
+
return btoa(String.fromCharCode(...bytes));
|
|
934
|
+
}
|
|
935
|
+
function invalid(message) {
|
|
936
|
+
return new MesubError(message, { status: null, code: "invalid_webhook" });
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// src/transport.ts
|
|
940
|
+
var REQUEST_ID_HEADER = "Mesub-Request-Id";
|
|
941
|
+
var INITIAL_RETRY_DELAY = 500;
|
|
942
|
+
var MAX_RETRY_DELAY = 8e3;
|
|
943
|
+
var MAX_RETRY_AFTER = 6e4;
|
|
944
|
+
var Transport = class {
|
|
945
|
+
#config;
|
|
946
|
+
constructor(config) {
|
|
947
|
+
this.#config = config;
|
|
948
|
+
}
|
|
949
|
+
async get(path, query = {}, options = {}) {
|
|
950
|
+
const url = endpoint(this.#config.baseUrl, path);
|
|
951
|
+
for (const [key, value] of Object.entries(query)) {
|
|
952
|
+
if (value !== void 0) url.searchParams.set(key, String(value));
|
|
953
|
+
}
|
|
954
|
+
return this.#send("GET", url, void 0, options);
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* A POST with a JSON body, sent once: never retried here, whatever the
|
|
958
|
+
* status, a timeout or a network error. A write that got no answer may
|
|
959
|
+
* still have been done: its caller decides, knowing the route, whether
|
|
960
|
+
* the same body may be sent again (`submit` does, Mesub-io/backend#190)
|
|
961
|
+
* or the state read back.
|
|
962
|
+
*/
|
|
963
|
+
async post(path, body, options = {}) {
|
|
964
|
+
return this.#send("POST", endpoint(this.#config.baseUrl, path), body, options);
|
|
965
|
+
}
|
|
966
|
+
async #send(method, url, body, options) {
|
|
967
|
+
const maxRetries = method === "POST" ? 0 : _nullishCoalesce(options.maxRetries, () => ( this.#config.maxRetries));
|
|
968
|
+
const { deadline, signal } = options;
|
|
969
|
+
numberOf("timeout", options.timeout, this.#config.timeout, { delay: true });
|
|
970
|
+
for (let retry = 0; ; retry++) {
|
|
971
|
+
const attempt = await this.#attempt(method, url, body, options);
|
|
972
|
+
if (attempt.ok) return attempt.body;
|
|
973
|
+
if (!attempt.retry || retry >= maxRetries) throw attempt.error;
|
|
974
|
+
if (options.retryRateLimited === false && attempt.error.status === 429) {
|
|
975
|
+
throw attempt.error;
|
|
976
|
+
}
|
|
977
|
+
const wait = _nullishCoalesce(attempt.retryAfter, () => ( backoff(retry)));
|
|
978
|
+
if (deadline !== void 0 && Date.now() + wait >= deadline) {
|
|
979
|
+
throw attempt.error;
|
|
980
|
+
}
|
|
981
|
+
await sleep(wait, signal);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
async #attempt(method, url, body, { deadline, signal, ...options }) {
|
|
985
|
+
_optionalChain([signal, 'optionalAccess', _6 => _6.throwIfAborted, 'call', _7 => _7()]);
|
|
986
|
+
const perAttempt = _nullishCoalesce(options.timeout, () => ( this.#config.timeout));
|
|
987
|
+
const left = deadline === void 0 ? Infinity : deadline - Date.now();
|
|
988
|
+
if (left <= 0) {
|
|
989
|
+
const error = new MesubError("No time was left to call Mesub.", {
|
|
990
|
+
status: null,
|
|
991
|
+
code: "unavailable"
|
|
992
|
+
});
|
|
993
|
+
return { ok: false, error, retry: false, retryAfter: null };
|
|
994
|
+
}
|
|
995
|
+
const timeout = Math.min(perAttempt, left);
|
|
996
|
+
const cutByDeadline = timeout < perAttempt;
|
|
997
|
+
const controller = new AbortController();
|
|
998
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
999
|
+
const stop = () => controller.abort(_optionalChain([signal, 'optionalAccess', _8 => _8.reason]));
|
|
1000
|
+
_optionalChain([signal, 'optionalAccess', _9 => _9.addEventListener, 'call', _10 => _10("abort", stop, { once: true })]);
|
|
1001
|
+
try {
|
|
1002
|
+
const response = await this.#config.fetch(url, {
|
|
1003
|
+
method,
|
|
1004
|
+
headers: {
|
|
1005
|
+
...this.#config.headers,
|
|
1006
|
+
Authorization: `Bearer ${this.#config.apiKey}`,
|
|
1007
|
+
Accept: "application/json",
|
|
1008
|
+
"User-Agent": `@mesub/node/${VERSION}`,
|
|
1009
|
+
[API_VERSION_HEADER]: API_VERSION,
|
|
1010
|
+
...body !== void 0 && { "Content-Type": "application/json" }
|
|
1011
|
+
},
|
|
1012
|
+
...body !== void 0 && { body: JSON.stringify(body) },
|
|
1013
|
+
signal: controller.signal
|
|
1014
|
+
});
|
|
1015
|
+
const text = await response.text();
|
|
1016
|
+
const requestId = response.headers.get(REQUEST_ID_HEADER);
|
|
1017
|
+
if (response.ok) {
|
|
1018
|
+
try {
|
|
1019
|
+
return { ok: true, body: JSON.parse(text) };
|
|
1020
|
+
} catch (cause) {
|
|
1021
|
+
const error2 = new MesubError("Mesub answered with a body that is not JSON.", {
|
|
1022
|
+
status: response.status,
|
|
1023
|
+
code: "unexpected",
|
|
1024
|
+
body: text,
|
|
1025
|
+
requestId,
|
|
1026
|
+
cause
|
|
1027
|
+
});
|
|
1028
|
+
return { ok: false, error: error2, retry: false, retryAfter: null };
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
const answered = parsed2(text);
|
|
1032
|
+
const asked = retryAfter(response.headers.get("retry-after"));
|
|
1033
|
+
const error = response.status === 404 && !isMesubErrorBody(answered) ? new MesubError(
|
|
1034
|
+
`${url.pathname} answered 404 with no Mesub error: is baseUrl (${this.#config.baseUrl}) the Mesub API?`,
|
|
1035
|
+
{
|
|
1036
|
+
status: 404,
|
|
1037
|
+
code: "unexpected",
|
|
1038
|
+
body: answered,
|
|
1039
|
+
retryAfter: asked,
|
|
1040
|
+
requestId
|
|
1041
|
+
}
|
|
1042
|
+
) : errorFrom(response.status, answered, asked, requestId);
|
|
1043
|
+
return {
|
|
1044
|
+
ok: false,
|
|
1045
|
+
error,
|
|
1046
|
+
// Sent again (a GET only, a POST never is) when the error is `retryable`:
|
|
1047
|
+
// Mesub's flag when the body has one, the status's otherwise.
|
|
1048
|
+
retry: error.retryable,
|
|
1049
|
+
retryAfter: asked === null ? null : Math.min(asked, MAX_RETRY_AFTER)
|
|
1050
|
+
};
|
|
1051
|
+
} catch (cause) {
|
|
1052
|
+
if (_optionalChain([signal, 'optionalAccess', _11 => _11.aborted])) throw signal.reason;
|
|
1053
|
+
if (controller.signal.aborted && cutByDeadline) {
|
|
1054
|
+
const error2 = new MesubError(
|
|
1055
|
+
`Mesub did not answer within the ${Math.round(timeout)} ms left before the deadline.`,
|
|
1056
|
+
{ status: null, code: "unavailable", cause }
|
|
1057
|
+
);
|
|
1058
|
+
return { ok: false, error: error2, retry: false, retryAfter: null };
|
|
1059
|
+
}
|
|
1060
|
+
const message = controller.signal.aborted ? `Mesub did not answer within ${perAttempt} ms.` : `Could not reach Mesub: ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
1061
|
+
const error = new MesubError(message, { status: null, code: "unavailable", cause });
|
|
1062
|
+
return { ok: false, error, retry: true, retryAfter: null };
|
|
1063
|
+
} finally {
|
|
1064
|
+
clearTimeout(timer);
|
|
1065
|
+
_optionalChain([signal, 'optionalAccess', _12 => _12.removeEventListener, 'call', _13 => _13("abort", stop)]);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
};
|
|
1069
|
+
function endpoint(baseUrl, path) {
|
|
1070
|
+
return new URL(baseUrl + path);
|
|
1071
|
+
}
|
|
1072
|
+
function errorFrom(status, body, retryAfter2, header) {
|
|
1073
|
+
const { code, retryable, request_id } = isRecord(body) ? body : {};
|
|
1074
|
+
const apiCode = typeof code === "string" ? code : null;
|
|
1075
|
+
return new MesubError(messageFrom(body, status), {
|
|
1076
|
+
status,
|
|
1077
|
+
code: codeFor(status, apiCode),
|
|
1078
|
+
apiCode,
|
|
1079
|
+
retryable: typeof retryable === "boolean" ? retryable : retryableStatus(status),
|
|
1080
|
+
body,
|
|
1081
|
+
retryAfter: retryAfter2,
|
|
1082
|
+
// The header first: the body's says the same, and is the fallback
|
|
1083
|
+
// when a proxy in front drops the header (Mesub-io/backend#472).
|
|
1084
|
+
requestId: _nullishCoalesce(header, () => ( (typeof request_id === "string" ? request_id : null)))
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
function retryableStatus(status) {
|
|
1088
|
+
return status === 408 || status === 429 || status >= 500;
|
|
1089
|
+
}
|
|
1090
|
+
function retryAfter(header) {
|
|
1091
|
+
if (header === null || header.trim() === "") return null;
|
|
1092
|
+
const seconds = Number(header);
|
|
1093
|
+
const ms = Number.isNaN(seconds) ? Date.parse(header) - Date.now() : seconds * 1e3;
|
|
1094
|
+
if (!Number.isFinite(ms) || ms < 0) return null;
|
|
1095
|
+
return ms;
|
|
1096
|
+
}
|
|
1097
|
+
function backoff(retry) {
|
|
1098
|
+
const delay = Math.min(INITIAL_RETRY_DELAY * 2 ** retry, MAX_RETRY_DELAY);
|
|
1099
|
+
return delay * (1 - Math.random() * 0.25);
|
|
1100
|
+
}
|
|
1101
|
+
function messageFrom(body, status) {
|
|
1102
|
+
const fallback = `Mesub answered with HTTP ${status}.`;
|
|
1103
|
+
if (!isRecord(body)) return fallback;
|
|
1104
|
+
const { message, error } = body;
|
|
1105
|
+
if (typeof message === "string" && message !== "") return message;
|
|
1106
|
+
if (Array.isArray(message)) {
|
|
1107
|
+
const parts = message.filter((part) => typeof part === "string");
|
|
1108
|
+
if (parts.length > 0) return parts.join("; ");
|
|
1109
|
+
}
|
|
1110
|
+
if (typeof error === "string" && error !== "") return error;
|
|
1111
|
+
return fallback;
|
|
1112
|
+
}
|
|
1113
|
+
function isMesubErrorBody(body) {
|
|
1114
|
+
if (!isRecord(body)) return false;
|
|
1115
|
+
const { code, statusCode, message } = body;
|
|
1116
|
+
if (typeof message === "string" && /^Cannot [A-Z]+ \//.test(message)) return false;
|
|
1117
|
+
return typeof code === "string" || typeof statusCode === "number";
|
|
1118
|
+
}
|
|
1119
|
+
function parsed2(text) {
|
|
1120
|
+
try {
|
|
1121
|
+
return JSON.parse(text);
|
|
1122
|
+
} catch (e7) {
|
|
1123
|
+
return text;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
function isRecord(value) {
|
|
1127
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1128
|
+
}
|
|
1129
|
+
function sleep(ms, signal) {
|
|
1130
|
+
return new Promise((resolve, reject) => {
|
|
1131
|
+
const stop = () => {
|
|
1132
|
+
clearTimeout(timer);
|
|
1133
|
+
reject(_optionalChain([signal, 'optionalAccess', _14 => _14.reason]));
|
|
1134
|
+
};
|
|
1135
|
+
const timer = setTimeout(() => {
|
|
1136
|
+
_optionalChain([signal, 'optionalAccess', _15 => _15.removeEventListener, 'call', _16 => _16("abort", stop)]);
|
|
1137
|
+
resolve();
|
|
1138
|
+
}, ms);
|
|
1139
|
+
_optionalChain([signal, 'optionalAccess', _17 => _17.addEventListener, 'call', _18 => _18("abort", stop, { once: true })]);
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// src/subscriptions.ts
|
|
1144
|
+
var SUBMIT_TIMEOUT = 9e4;
|
|
1145
|
+
var REPLAY_ROOM = 3e4;
|
|
1146
|
+
var MAX_SENDS = 3;
|
|
1147
|
+
var REPLAY_WAIT = 1e4;
|
|
1148
|
+
var READ_BACK_TIME = 1e4;
|
|
1149
|
+
var CONFIRM_TIMEOUT = SUBMIT_TIMEOUT;
|
|
1150
|
+
var Subscriptions = class {
|
|
1151
|
+
#transport;
|
|
1152
|
+
/**
|
|
1153
|
+
* Told of every subscription `submit`, `retrieve` and `list` answer, so
|
|
1154
|
+
* the client drops the cached access answers one that landed outdates.
|
|
1155
|
+
*/
|
|
1156
|
+
#seen;
|
|
1157
|
+
/**
|
|
1158
|
+
* Told of the subscription a confirm moved, so the client drops every
|
|
1159
|
+
* access answer cached for its customer, the yes as well as the no.
|
|
1160
|
+
*/
|
|
1161
|
+
#changed;
|
|
1162
|
+
/** @internal */
|
|
1163
|
+
constructor(transport, seen = async () => {
|
|
1164
|
+
}, changed = seen) {
|
|
1165
|
+
this.#transport = transport;
|
|
1166
|
+
this.#seen = seen;
|
|
1167
|
+
this.#changed = changed;
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Reserves a subscription to that plan for that wallet, and builds what
|
|
1171
|
+
* it signs: `terms.message`, then `transaction`. Hand both to your front,
|
|
1172
|
+
* then their signatures to `submit`. Called again for the same plan,
|
|
1173
|
+
* wallet and customer (`external_id`, else `email`) while nothing landed,
|
|
1174
|
+
* it answers the same subscription with a fresh transaction; by its id,
|
|
1175
|
+
* `transaction` does. Another
|
|
1176
|
+
* customer naming that wallet gets a subscription of its own
|
|
1177
|
+
* (Mesub-io/backend#311): only the one the wallet signs for lands, and
|
|
1178
|
+
* the others then expire. Sent once, never retried.
|
|
1179
|
+
*/
|
|
1180
|
+
async create(params, options = {}) {
|
|
1181
|
+
const { plan, wallet, email, external_id } = params;
|
|
1182
|
+
const body = { plan, wallet, email, external_id };
|
|
1183
|
+
return subscribeTransactionFrom(
|
|
1184
|
+
await this.#transport.post("/v1/subscriptions", body, options)
|
|
1185
|
+
);
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Relays what the wallet signed. Mesub checks it, co-signs, sends it and
|
|
1189
|
+
* waits for the chain, so one send takes up to a minute or so: 90 s
|
|
1190
|
+
* unless `options.timeout` says otherwise.
|
|
1191
|
+
*
|
|
1192
|
+
* When the send gets no answer that says what became of it (a timeout,
|
|
1193
|
+
* a network error, a 5xx), or an error Mesub marks `retryable`, the same
|
|
1194
|
+
* request, the same body, is sent again, up to twice, after the
|
|
1195
|
+
* `Retry-After` Mesub asked for or 10 s, within `options.budget` (120 s
|
|
1196
|
+
* by default). That is safe since Mesub-io/backend#190 and #202: Mesub
|
|
1197
|
+
* recognises the transaction and the terms signature it already
|
|
1198
|
+
* co-signed, signs nothing again, and answers from the chain or from the
|
|
1199
|
+
* subscription it settled. Its answer is then returned as if the first
|
|
1200
|
+
* send had got it, a `pending` with Mesub's own `reason` included.
|
|
1201
|
+
*
|
|
1202
|
+
* When no send got an answer, the subscription is read back once, within
|
|
1203
|
+
* 10 s: it is returned if `active` or `cancelled` (it landed), and
|
|
1204
|
+
* otherwise a `MesubSubmitError` `unavailable` is thrown with it attached
|
|
1205
|
+
* as `subscription` (null if that read failed too): the wallet may have
|
|
1206
|
+
* paid and the transaction may still land, so read it again with
|
|
1207
|
+
* `retrieve` rather than create anew. A 2xx this SDK cannot read is read
|
|
1208
|
+
* back the same way, and thrown as `unexpected`; so is a replay refused
|
|
1209
|
+
* with `not_awaiting_signature` after a send that got no answer, thrown
|
|
1210
|
+
* as that `conflict`.
|
|
1211
|
+
*
|
|
1212
|
+
* A 503 `unavailable` (too many submits waiting on the project,
|
|
1213
|
+
* Mesub-io/backend#505) says nothing was signed or sent: it is replayed
|
|
1214
|
+
* after its `Retry-After` (2 s) like any retryable refusal, and when the
|
|
1215
|
+
* replays run out it is thrown as is, `retryable`, with no read back,
|
|
1216
|
+
* unless an earlier send got no answer.
|
|
1217
|
+
*
|
|
1218
|
+
* Any other refusal (`terms_expired`, `transaction_expired`, ...) is
|
|
1219
|
+
* thrown as is. After `transaction_expired` nothing was sent and the same
|
|
1220
|
+
* submit cannot land: call `transaction(id)` for a new one, never
|
|
1221
|
+
* `create` again.
|
|
1222
|
+
* An abort through `options.signal` stops everything, sends, waits and
|
|
1223
|
+
* the read back, and rejects with the signal's reason; a send already out
|
|
1224
|
+
* may have been co-signed, so read the subscription back.
|
|
1225
|
+
*
|
|
1226
|
+
* A subscription returned with `access` (`active`, or `cancelled` before
|
|
1227
|
+
* its end) drops the `/v1/access` answers cached for that customer (its
|
|
1228
|
+
* wallet, external id and email) that still say no: `hasAccess` right
|
|
1229
|
+
* after asks Mesub again.
|
|
1230
|
+
*/
|
|
1231
|
+
async submit(id, params, options = {}) {
|
|
1232
|
+
const result = await this.#submit(id, params, options);
|
|
1233
|
+
await this.#seen(result.subscription);
|
|
1234
|
+
return result;
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* A fresh transaction for a subscription still `pending`, with the terms
|
|
1238
|
+
* it names (Mesub-io/backend#427). A transaction lives about half a
|
|
1239
|
+
* minute, the terms five: have the terms signed first, call this when the
|
|
1240
|
+
* wallet is ready, have it sign the transaction at once, then `submit`.
|
|
1241
|
+
* Always sign the transaction of the last call. It is also what follows
|
|
1242
|
+
* a submit refused with `transaction_expired`.
|
|
1243
|
+
*
|
|
1244
|
+
* A terms signature you hold is still good when `terms.message` is, to
|
|
1245
|
+
* the letter, the message the wallet signed. Another text is new terms:
|
|
1246
|
+
* have the wallet sign it, the old signature is refused
|
|
1247
|
+
* (`invalid_terms_signature`).
|
|
1248
|
+
*
|
|
1249
|
+
* Reserves nothing and sends no body: the wallet, the plan and the
|
|
1250
|
+
* customer are the subscription's own. Refused with `apiCode`
|
|
1251
|
+
* `not_awaiting_signature` once it is signed, expired or ended (call
|
|
1252
|
+
* `create` again for an expired one), and with what `create` refuses of
|
|
1253
|
+
* a plan or a wallet (`plan_ended`, `insufficient_balance`,
|
|
1254
|
+
* `already_subscribed`, ...). Sent once, never retried: asking twice is
|
|
1255
|
+
* safe, only the last transaction is to be signed.
|
|
1256
|
+
*/
|
|
1257
|
+
async transaction(id, options = {}) {
|
|
1258
|
+
return subscribeTransactionFrom(
|
|
1259
|
+
await this.#transport.post(`${pathOf(id)}/transaction`, void 0, options),
|
|
1260
|
+
"POST /v1/subscriptions/:id/transaction"
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
async #submit(id, params, options) {
|
|
1264
|
+
const path = `${pathOf(id)}/submit`;
|
|
1265
|
+
const body = { transaction: params.transaction, terms_signature: params.terms_signature };
|
|
1266
|
+
const { signal } = options;
|
|
1267
|
+
const timeout = numberOf("timeout", options.timeout, SUBMIT_TIMEOUT, { delay: true });
|
|
1268
|
+
const budget = _nullishCoalesce(options.budget, () => ( timeout + REPLAY_ROOM));
|
|
1269
|
+
if (!Number.isFinite(budget) || budget <= 0) {
|
|
1270
|
+
throw new TypeError(`budget must be a positive number of ms, not ${budget}.`);
|
|
1271
|
+
}
|
|
1272
|
+
const deadline = Date.now() + budget;
|
|
1273
|
+
let lost = false;
|
|
1274
|
+
let responded = null;
|
|
1275
|
+
for (let sent = 1; ; sent++) {
|
|
1276
|
+
let error;
|
|
1277
|
+
try {
|
|
1278
|
+
const answer = await this.#transport.post(path, body, {
|
|
1279
|
+
timeout,
|
|
1280
|
+
deadline,
|
|
1281
|
+
...signal !== void 0 && { signal }
|
|
1282
|
+
});
|
|
1283
|
+
return submitResultFrom(answer);
|
|
1284
|
+
} catch (caught) {
|
|
1285
|
+
if (!(caught instanceof MesubError)) throw caught;
|
|
1286
|
+
error = caught;
|
|
1287
|
+
}
|
|
1288
|
+
const lostBefore = lost;
|
|
1289
|
+
if (unanswered(error)) lost = true;
|
|
1290
|
+
if (error.status !== null) responded = error;
|
|
1291
|
+
const unsettled = { sends: sent, cause: error, from: error, lead: error.message };
|
|
1292
|
+
if (error.status !== null && error.status < 300) {
|
|
1293
|
+
return this.#settle(id, signal, {
|
|
1294
|
+
...unsettled,
|
|
1295
|
+
code: "unexpected",
|
|
1296
|
+
retryable: false
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
if (lostBefore && error.apiCode === "not_awaiting_signature") {
|
|
1300
|
+
return this.#settle(id, signal, {
|
|
1301
|
+
...unsettled,
|
|
1302
|
+
code: error.code,
|
|
1303
|
+
retryable: error.retryable
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
const rebuild = error.apiCode === "comeback_period_rolling";
|
|
1307
|
+
const replay = !rebuild && (error.status === null || error.retryable);
|
|
1308
|
+
const wait = _nullishCoalesce(error.retryAfter, () => ( REPLAY_WAIT));
|
|
1309
|
+
if (!replay || sent >= MAX_SENDS || Date.now() + wait >= deadline) {
|
|
1310
|
+
if (!lost || !unanswered(error) && !error.retryable) throw error;
|
|
1311
|
+
return this.#settle(id, signal, {
|
|
1312
|
+
...unsettled,
|
|
1313
|
+
from: _nullishCoalesce(responded, () => ( error)),
|
|
1314
|
+
code: "unavailable",
|
|
1315
|
+
retryable: true,
|
|
1316
|
+
lead: `Mesub never said what became of the submit, sent ${sent} ${sent === 1 ? "time" : "times"}: ${error.message}`
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
await sleep(wait, signal);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* A submit whose outcome is unknown, settled from the subscription read
|
|
1324
|
+
* back: returned if it landed, thrown with it otherwise.
|
|
1325
|
+
*/
|
|
1326
|
+
async #settle(id, signal, { sends, cause, from, code, retryable, lead }) {
|
|
1327
|
+
const subscription = await this.#readBack(id, signal);
|
|
1328
|
+
if (_optionalChain([subscription, 'optionalAccess', _19 => _19.status]) === "active" || _optionalChain([subscription, 'optionalAccess', _20 => _20.status]) === "cancelled") {
|
|
1329
|
+
return { subscription };
|
|
1330
|
+
}
|
|
1331
|
+
const then = subscription === null ? "Reading the subscription back failed too" : `The subscription read back is ${subscription.status}`;
|
|
1332
|
+
throw new MesubSubmitError(
|
|
1333
|
+
`${lead} ${then}: read it again with retrieve before creating anew.`,
|
|
1334
|
+
{
|
|
1335
|
+
status: from.status,
|
|
1336
|
+
code,
|
|
1337
|
+
apiCode: from.apiCode,
|
|
1338
|
+
retryable,
|
|
1339
|
+
body: from.body,
|
|
1340
|
+
retryAfter: from.retryAfter,
|
|
1341
|
+
requestId: from.requestId,
|
|
1342
|
+
cause,
|
|
1343
|
+
subscription,
|
|
1344
|
+
sends
|
|
1345
|
+
}
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* The subscription after a submit, within `READ_BACK_TIME`: no attempt
|
|
1350
|
+
* nor retry wait goes past it. Null when it cannot be read; the caller's
|
|
1351
|
+
* abort is thrown as is.
|
|
1352
|
+
*/
|
|
1353
|
+
async #readBack(id, signal) {
|
|
1354
|
+
try {
|
|
1355
|
+
const body = await this.#transport.get(
|
|
1356
|
+
pathOf(id),
|
|
1357
|
+
{},
|
|
1358
|
+
{
|
|
1359
|
+
deadline: Date.now() + READ_BACK_TIME,
|
|
1360
|
+
...signal !== void 0 && { signal }
|
|
1361
|
+
}
|
|
1362
|
+
);
|
|
1363
|
+
return serverSubscriptionFrom(body);
|
|
1364
|
+
} catch (error) {
|
|
1365
|
+
if (_optionalChain([signal, 'optionalAccess', _21 => _21.aborted])) throw error;
|
|
1366
|
+
return null;
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
/**
|
|
1370
|
+
* Builds the transaction that cancels an `active`, `unpaid` or `stopped`
|
|
1371
|
+
* subscription. Hand it to your front: the subscription's wallet signs
|
|
1372
|
+
* and sends it, then `confirmCancel` takes the signature. Nothing is
|
|
1373
|
+
* cancelled until then. Sent once, never retried.
|
|
1374
|
+
*
|
|
1375
|
+
* One paid up keeps its access to the end of its period; a late or
|
|
1376
|
+
* stopped one has none, and its missed period is never collected.
|
|
1377
|
+
*/
|
|
1378
|
+
async cancel(id, options = {}) {
|
|
1379
|
+
return this.#build("cancel", id, options);
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Settles a cancellation the wallet sent, by the signature it answered:
|
|
1383
|
+
* `cancelled`, or the subscription as it was with a `reason` when nothing
|
|
1384
|
+
* landed. Mesub waits for the chain, so up to a minute or so: 90 s unless
|
|
1385
|
+
* `options.timeout` says otherwise. Sent once, never retried; the same
|
|
1386
|
+
* confirm sent again is answered the same.
|
|
1387
|
+
*
|
|
1388
|
+
* Settled without a `reason`, it drops every `/v1/access` answer cached
|
|
1389
|
+
* for that customer on that plan: `access` right after asks Mesub again.
|
|
1390
|
+
*/
|
|
1391
|
+
async confirmCancel(id, params, options = {}) {
|
|
1392
|
+
return this.#confirm("cancel", id, params, options);
|
|
1393
|
+
}
|
|
1394
|
+
/**
|
|
1395
|
+
* Builds the transaction that takes a `cancelled` subscription back,
|
|
1396
|
+
* before the period it paid for is over. Hand it to your front: the
|
|
1397
|
+
* wallet signs and sends it, then `confirmResume` takes the signature.
|
|
1398
|
+
* Sent once, never retried.
|
|
1399
|
+
*/
|
|
1400
|
+
async resume(id, options = {}) {
|
|
1401
|
+
return this.#build("resume", id, options);
|
|
1402
|
+
}
|
|
1403
|
+
/** As `confirmCancel`, for a resume: `active` again, or a `reason`. */
|
|
1404
|
+
async confirmResume(id, params, options = {}) {
|
|
1405
|
+
return this.#confirm("resume", id, params, options);
|
|
1406
|
+
}
|
|
1407
|
+
/**
|
|
1408
|
+
* Builds the transaction that closes the wallet's authorisation on a
|
|
1409
|
+
* subscription that is over: its rent goes back to the wallet. Mesub
|
|
1410
|
+
* builds it for a cancelled one past its end; for a stopped one
|
|
1411
|
+
* cancelled on chain and past its end, or whose authority is closed for
|
|
1412
|
+
* good; and for one `ended` with its plan (`end_reason` `plan_removed`,
|
|
1413
|
+
* `plan_replaced` or `plan_ended`). A stopped one that nothing cancelled
|
|
1414
|
+
* is refused with `subscription_not_cancelled`: cancel it first.
|
|
1415
|
+
* Hand the transaction to your front: the wallet signs and sends it,
|
|
1416
|
+
* then `confirmClose` takes the signature. Sent once, never retried.
|
|
1417
|
+
*/
|
|
1418
|
+
async close(id, options = {}) {
|
|
1419
|
+
return this.#build("close", id, options);
|
|
1420
|
+
}
|
|
1421
|
+
/** As `confirmCancel`, for a close: `ended` with `end_reason` `closed`, or a `reason`. */
|
|
1422
|
+
async confirmClose(id, params, options = {}) {
|
|
1423
|
+
return this.#confirm("close", id, params, options);
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* Pays a late payment now (Mesub-io/backend#354): the pull of the missed
|
|
1427
|
+
* period runs at once rather than at its next retry, with nothing for the
|
|
1428
|
+
* wallet to sign, since it approved the plan when it subscribed. Answers
|
|
1429
|
+
* the subscription, still `unpaid`: the outcome comes with the pull, as
|
|
1430
|
+
* the `subscription.renewed` or `subscription.payment_failed` webhook.
|
|
1431
|
+
*
|
|
1432
|
+
* The merchant's hand retry from the dashboard follows the same rules: on
|
|
1433
|
+
* Free it is one of the missed period's three, whoever fires it; on Dev
|
|
1434
|
+
* and Business it runs beside the schedule, the wait between two doubling.
|
|
1435
|
+
* Mesub refuses it first when it cannot succeed, so it never spends a
|
|
1436
|
+
* retry for nothing: `apiCode` `insufficient_balance` (top up, then pay
|
|
1437
|
+
* again), `retry_cannot_succeed` (the wallet no longer approves it, its
|
|
1438
|
+
* token account is frozen, or its authority is closed), `retry_too_soon`
|
|
1439
|
+
* (`retryAfter` says when), `retries_spent`, `retry_deadline_passed`,
|
|
1440
|
+
* `subscription_not_late`, `subscription_paused`, `plan_ended`. Two more
|
|
1441
|
+
* are `retryable`: `subscription_changed` (two retries met, or a pull
|
|
1442
|
+
* moved the subscription on) and, on Free, `pull_running` (a pull runs
|
|
1443
|
+
* now). Read the subscription back before you ask again. A 503 is
|
|
1444
|
+
* `retryable` too, and nothing changed: `unavailable` (the charge could
|
|
1445
|
+
* not be queued) or `network_unavailable`, with `retryAfter` saying when
|
|
1446
|
+
* to ask again (20 s and 10 s). Sent once, never retried: a second one is
|
|
1447
|
+
* a second retry.
|
|
1448
|
+
*/
|
|
1449
|
+
async retry(id, options = {}) {
|
|
1450
|
+
return serverSubscriptionFrom(
|
|
1451
|
+
await this.#transport.post(`${pathOf(id)}/retry`, void 0, options),
|
|
1452
|
+
"POST /v1/subscriptions/:id/retry",
|
|
1453
|
+
202
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
async #build(action, id, options) {
|
|
1457
|
+
const path = `${pathOf(id)}/${action}`;
|
|
1458
|
+
return walletTransactionFrom(
|
|
1459
|
+
await this.#transport.post(path, void 0, options),
|
|
1460
|
+
`POST /v1/subscriptions/:id/${action}`
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
async #confirm(action, id, params, options) {
|
|
1464
|
+
const path = `${pathOf(id)}/${action}/confirm`;
|
|
1465
|
+
const result = confirmResultFrom(
|
|
1466
|
+
await this.#transport.post(
|
|
1467
|
+
path,
|
|
1468
|
+
{ signature: params.signature },
|
|
1469
|
+
{ ...options, timeout: _nullishCoalesce(options.timeout, () => ( CONFIRM_TIMEOUT)) }
|
|
1470
|
+
),
|
|
1471
|
+
`POST /v1/subscriptions/:id/${action}/confirm`
|
|
1472
|
+
);
|
|
1473
|
+
await (result.reason === void 0 ? this.#changed : this.#seen)(result.subscription);
|
|
1474
|
+
return result;
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* One subscription of your project, by id. Found with `access`, it drops
|
|
1478
|
+
* the access answers cached that still say no, as `submit` does.
|
|
1479
|
+
*/
|
|
1480
|
+
async retrieve(id, options = {}) {
|
|
1481
|
+
const subscription = serverSubscriptionFrom(
|
|
1482
|
+
await this.#transport.get(pathOf(id), {}, options)
|
|
1483
|
+
);
|
|
1484
|
+
await this.#seen(subscription);
|
|
1485
|
+
return subscription;
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* A customer's subscriptions, newest first, one page at a time; expired
|
|
1489
|
+
* checkouts included. `listAll` walks every page.
|
|
1490
|
+
*
|
|
1491
|
+
* The customer is named as for `access`, and normalised the same way;
|
|
1492
|
+
* none or two of `wallet`, `external_id` and `email` throws a TypeError.
|
|
1493
|
+
* Each one with `access` drops the access answers cached that still say
|
|
1494
|
+
* no, as `submit` does.
|
|
1495
|
+
*/
|
|
1496
|
+
async list(params, options = {}) {
|
|
1497
|
+
const { kind, value } = customerOf(params);
|
|
1498
|
+
const { plan, limit, starting_after } = params;
|
|
1499
|
+
const query = {
|
|
1500
|
+
[kind]: value,
|
|
1501
|
+
plan,
|
|
1502
|
+
limit,
|
|
1503
|
+
starting_after
|
|
1504
|
+
};
|
|
1505
|
+
const page = serverSubscriptionListFrom(
|
|
1506
|
+
await this.#transport.get("/v1/subscriptions", query, options)
|
|
1507
|
+
);
|
|
1508
|
+
for (const subscription of page.data) await this.#seen(subscription);
|
|
1509
|
+
return page;
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* Every subscription `list` would answer, page after page, for `for await`.
|
|
1513
|
+
* Each page is one call, made when the previous one is used up.
|
|
1514
|
+
*/
|
|
1515
|
+
async *listAll(params, options = {}) {
|
|
1516
|
+
let page = await this.list(params, options);
|
|
1517
|
+
yield* page.data;
|
|
1518
|
+
while (page.has_more && page.data.length > 0) {
|
|
1519
|
+
page = await this.list({ ...params, starting_after: page.data.at(-1).id }, options);
|
|
1520
|
+
yield* page.data;
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
/**
|
|
1524
|
+
* That subscription's own pull attempts, newest first, one page at a
|
|
1525
|
+
* time, and `paid`: what it paid since it began, whatever the page.
|
|
1526
|
+
* `allAttempts` walks every page.
|
|
1527
|
+
*
|
|
1528
|
+
* The id asked and no other: a subscription the wallet came back over
|
|
1529
|
+
* (`superseded`) keeps its own payments, and the one that replaced it
|
|
1530
|
+
* starts from its first pull. Needs a Mesub that serves the route
|
|
1531
|
+
* (Mesub-io/backend#289): from an older one it throws a `not_found`
|
|
1532
|
+
* whose `apiCode` is null, where an id it does not hold is a `not_found`
|
|
1533
|
+
* whose `apiCode` is `subscription_not_found`.
|
|
1534
|
+
*/
|
|
1535
|
+
async attempts(id, params = {}, options = {}) {
|
|
1536
|
+
const { limit, starting_after } = params;
|
|
1537
|
+
const path = `${pathOf(id)}/attempts`;
|
|
1538
|
+
try {
|
|
1539
|
+
return subscriptionAttemptListFrom(
|
|
1540
|
+
await this.#transport.get(path, { limit, starting_after }, options)
|
|
1541
|
+
);
|
|
1542
|
+
} catch (error) {
|
|
1543
|
+
if (!(error instanceof MesubError) || !routeMissing(error)) throw error;
|
|
1544
|
+
throw new MesubError(
|
|
1545
|
+
"This Mesub does not serve GET /v1/subscriptions/:id/attempts yet.",
|
|
1546
|
+
{
|
|
1547
|
+
status: 404,
|
|
1548
|
+
code: "not_found",
|
|
1549
|
+
body: error.body,
|
|
1550
|
+
requestId: error.requestId,
|
|
1551
|
+
cause: error
|
|
1552
|
+
}
|
|
1553
|
+
);
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
/**
|
|
1557
|
+
* Every attempt `attempts` would answer, page after page, for `for await`.
|
|
1558
|
+
* Each page is one call, made when the previous one is used up.
|
|
1559
|
+
*/
|
|
1560
|
+
async *allAttempts(id, params = {}, options = {}) {
|
|
1561
|
+
let page = await this.attempts(id, params, options);
|
|
1562
|
+
yield* page.data;
|
|
1563
|
+
while (page.has_more && page.data.length > 0) {
|
|
1564
|
+
page = await this.attempts(
|
|
1565
|
+
id,
|
|
1566
|
+
{ ...params, starting_after: page.data.at(-1).id },
|
|
1567
|
+
options
|
|
1568
|
+
);
|
|
1569
|
+
yield* page.data;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
};
|
|
1573
|
+
var ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
1574
|
+
function pathOf(id) {
|
|
1575
|
+
if (typeof id !== "string" || id === "") {
|
|
1576
|
+
throw new MesubError("A subscription id is required.", {
|
|
1577
|
+
status: null,
|
|
1578
|
+
code: "invalid_request"
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
if (!ID.test(id)) {
|
|
1582
|
+
throw new MesubError(
|
|
1583
|
+
"A subscription id is letters, digits, hyphens and underscores, 64 at most.",
|
|
1584
|
+
{ status: null, code: "invalid_request" }
|
|
1585
|
+
);
|
|
1586
|
+
}
|
|
1587
|
+
return `/v1/subscriptions/${id}`;
|
|
1588
|
+
}
|
|
1589
|
+
function routeMissing(error) {
|
|
1590
|
+
const { message } = _nullishCoalesce(error.body, () => ( {}));
|
|
1591
|
+
return error.status === 404 && error.apiCode === null && typeof message === "string" && message.startsWith("Cannot GET /");
|
|
1592
|
+
}
|
|
1593
|
+
function unanswered(error) {
|
|
1594
|
+
if (error.status === 503 && error.apiCode === "unavailable") return false;
|
|
1595
|
+
return error.status === null || error.status >= 500;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// src/events.ts
|
|
1599
|
+
var Events = class {
|
|
1600
|
+
#transport;
|
|
1601
|
+
/** @internal */
|
|
1602
|
+
constructor(transport) {
|
|
1603
|
+
this.#transport = transport;
|
|
1604
|
+
}
|
|
1605
|
+
/**
|
|
1606
|
+
* Your project's events of the last 30 days, newest first, one page at a
|
|
1607
|
+
* time: each the very body its webhooks carried, the same `id`, `data`
|
|
1608
|
+
* and `detail`. Read it to catch up on what an endpoint missed while it
|
|
1609
|
+
* was down or turned off. Events before `available_since` are never
|
|
1610
|
+
* served, whatever the window.
|
|
1611
|
+
*
|
|
1612
|
+
* An event is listed a moment after its `created_at`, so one can appear
|
|
1613
|
+
* below one already read: when polling for what is new, page on until
|
|
1614
|
+
* `created_at` is a minute older than the newest you held, and skip the
|
|
1615
|
+
* ids you have seen.
|
|
1616
|
+
*
|
|
1617
|
+
* Needs a Mesub that serves the route (Mesub-io/backend#482): from an
|
|
1618
|
+
* older one it throws a `not_found` whose `apiCode` is null.
|
|
1619
|
+
*/
|
|
1620
|
+
async list(params = {}, options = {}) {
|
|
1621
|
+
const { type, subscription, limit, starting_after } = params;
|
|
1622
|
+
return eventListFrom(
|
|
1623
|
+
await this.#get("/v1/events", { type, subscription, limit, starting_after }, options)
|
|
1624
|
+
);
|
|
1625
|
+
}
|
|
1626
|
+
/**
|
|
1627
|
+
* One event, by the `id` its webhook body carries, as `list` serves it.
|
|
1628
|
+
* A `not_found` whose `apiCode` is `event_not_found` for an id older than
|
|
1629
|
+
* 30 days or than `available_since`, or of another project. A delivery's
|
|
1630
|
+
* `webhook-id` is not an event id: read `event.id`, not `delivery_id`.
|
|
1631
|
+
*/
|
|
1632
|
+
async retrieve(id, options = {}) {
|
|
1633
|
+
return eventFrom(await this.#get(pathOf2(id), {}, options));
|
|
1634
|
+
}
|
|
1635
|
+
async #get(path, query, options) {
|
|
1636
|
+
try {
|
|
1637
|
+
return await this.#transport.get(path, query, options);
|
|
1638
|
+
} catch (error) {
|
|
1639
|
+
if (!(error instanceof MesubError) || !routeMissing(error)) throw error;
|
|
1640
|
+
throw new MesubError("This Mesub does not serve GET /v1/events yet.", {
|
|
1641
|
+
status: 404,
|
|
1642
|
+
code: "not_found",
|
|
1643
|
+
body: error.body,
|
|
1644
|
+
requestId: error.requestId,
|
|
1645
|
+
cause: error
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
};
|
|
1650
|
+
var ID2 = /^[A-Za-z0-9_-]{1,64}$/;
|
|
1651
|
+
function pathOf2(id) {
|
|
1652
|
+
if (typeof id !== "string" || !ID2.test(id)) {
|
|
1653
|
+
throw new MesubError(
|
|
1654
|
+
"An event id is letters, digits, hyphens and underscores, 64 at most.",
|
|
1655
|
+
{ status: null, code: "invalid_request" }
|
|
1656
|
+
);
|
|
1657
|
+
}
|
|
1658
|
+
return `/v1/events/${id}`;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// src/plans.ts
|
|
1662
|
+
var Plans = class {
|
|
1663
|
+
#transport;
|
|
1664
|
+
/** @internal */
|
|
1665
|
+
constructor(transport) {
|
|
1666
|
+
this.#transport = transport;
|
|
1667
|
+
}
|
|
1668
|
+
/**
|
|
1669
|
+
* Every plan of your project a subscriber can hold, sorted by slug: the
|
|
1670
|
+
* active ones, and the ones ending (`sunset`, `available: false`). Never
|
|
1671
|
+
* cached: call it where you render, and cache it your way.
|
|
1672
|
+
*/
|
|
1673
|
+
async list(options = {}) {
|
|
1674
|
+
return planListFrom(await this.#transport.get("/v1/plans", {}, options));
|
|
1675
|
+
}
|
|
1676
|
+
/** One plan by its slug. A slug your project lacks throws `plan_not_found`. */
|
|
1677
|
+
async retrieve(slug, options = {}) {
|
|
1678
|
+
return planFrom(await this.#transport.get(pathOf3(slug), {}, options));
|
|
1679
|
+
}
|
|
1680
|
+
};
|
|
1681
|
+
var SLUG = /^(?=.*[a-z])[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
1682
|
+
function pathOf3(slug) {
|
|
1683
|
+
if (typeof slug !== "string" || !SLUG.test(slug)) {
|
|
1684
|
+
throw new MesubError(
|
|
1685
|
+
"A plan slug is lowercase letters, digits and hyphens, with at least one letter.",
|
|
1686
|
+
{ status: null, code: "invalid_request" }
|
|
1687
|
+
);
|
|
1688
|
+
}
|
|
1689
|
+
return `/v1/plans/${slug}`;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// src/client.ts
|
|
1693
|
+
var DEFAULT_BASE_URL = "https://api.mesub.io";
|
|
1694
|
+
var DEFAULT_GUARD_TIMEOUT = 2e3;
|
|
1695
|
+
var Mesub = (_class3 = class {
|
|
1696
|
+
/** Subscribing from your server: create, submit, retrieve and list. */
|
|
1697
|
+
|
|
1698
|
+
/** The plans of your project, for a pricing page: `list` and `retrieve`. */
|
|
1699
|
+
|
|
1700
|
+
/** Checking the webhooks Mesub sends: `verify`. */
|
|
1701
|
+
|
|
1702
|
+
/** The events of the last 30 days, as their webhooks carried them: `list` and `retrieve`. */
|
|
1703
|
+
|
|
1704
|
+
/** @internal */
|
|
1705
|
+
|
|
1706
|
+
/** @internal */
|
|
1707
|
+
|
|
1708
|
+
/** The lists of `accessList`, in the same store, under their own keys. */
|
|
1709
|
+
|
|
1710
|
+
/** What the cache keys are scoped by: a hash of the API key, computed once. */
|
|
1711
|
+
|
|
1712
|
+
/** What hashes emails and external ids in the cache keys: the API key, imported once. */
|
|
1713
|
+
|
|
1714
|
+
|
|
1715
|
+
/**
|
|
1716
|
+
* The `/v1/access` requests in flight, by cache key: a call for the same
|
|
1717
|
+
* answer waits for that one rather than send its own (#34).
|
|
1718
|
+
*/
|
|
1719
|
+
__init3() {this.flights = /* @__PURE__ */ new Map()}
|
|
1720
|
+
constructor(options = {}) {;_class3.prototype.__init3.call(this);
|
|
1721
|
+
const apiKey = apiKeyOf(options.apiKey);
|
|
1722
|
+
const baseUrl = baseUrlOf(options.baseUrl, DEFAULT_BASE_URL);
|
|
1723
|
+
const headers = headersOf(options.headers);
|
|
1724
|
+
const webhookSecret = webhookSecretOf(options.webhookSecret);
|
|
1725
|
+
if (options.fetch !== void 0 && typeof options.fetch !== "function") {
|
|
1726
|
+
throw new TypeError("fetch must be a function.");
|
|
1727
|
+
}
|
|
1728
|
+
const fetch = _nullishCoalesce(options.fetch, () => ( ((input, init) => globalThis.fetch(input, init))));
|
|
1729
|
+
this.guardTimeout = numberOf("guardTimeout", options.guardTimeout, DEFAULT_GUARD_TIMEOUT, {
|
|
1730
|
+
delay: true
|
|
1731
|
+
});
|
|
1732
|
+
this.transport = new Transport({
|
|
1733
|
+
apiKey,
|
|
1734
|
+
baseUrl,
|
|
1735
|
+
headers,
|
|
1736
|
+
fetch,
|
|
1737
|
+
timeout: numberOf("timeout", options.timeout, 5e3, { delay: true }),
|
|
1738
|
+
maxRetries: numberOf("maxRetries", options.maxRetries, 2, {
|
|
1739
|
+
zero: true,
|
|
1740
|
+
integer: true
|
|
1741
|
+
})
|
|
1742
|
+
});
|
|
1743
|
+
this.subscriptions = new Subscriptions(
|
|
1744
|
+
this.transport,
|
|
1745
|
+
(subscription) => this.landed(subscription),
|
|
1746
|
+
(subscription) => this.landed(subscription, true)
|
|
1747
|
+
);
|
|
1748
|
+
this.plans = new Plans(this.transport);
|
|
1749
|
+
this.events = new Events(this.transport);
|
|
1750
|
+
this.webhooks = new Webhooks(webhookSecret, (event) => this.landed(event.data, true));
|
|
1751
|
+
const store = _nullishCoalesce(options.cache, () => ( new MemoryStore()));
|
|
1752
|
+
const staleness = options.maxStaleMs === void 0 ? {} : { maxStaleMs: numberOf("maxStaleMs", options.maxStaleMs, 0, { zero: true }) };
|
|
1753
|
+
this.cache = new AccessCache(store, staleness);
|
|
1754
|
+
this.lists = new AccessCache(store, staleness);
|
|
1755
|
+
let cacheScope;
|
|
1756
|
+
this.cacheScope = () => cacheScope ??= hashScope(apiKey);
|
|
1757
|
+
let cacheSecret;
|
|
1758
|
+
this.cacheSecret = () => cacheSecret ??= importSecret(apiKey);
|
|
1759
|
+
}
|
|
1760
|
+
/**
|
|
1761
|
+
* Everything Mesub knows about that customer on that plan: whether they
|
|
1762
|
+
* have access, their status, their dates. Served from the cache while fresh.
|
|
1763
|
+
*
|
|
1764
|
+
* The customer is `{ wallet }`, `{ external_id }` or `{ email }`, or a
|
|
1765
|
+
* wallet as a string. Asked by external id or email, the answer is about
|
|
1766
|
+
* the wallet that grants access, if any: `wallet` is null when they have
|
|
1767
|
+
* nothing on that plan.
|
|
1768
|
+
*
|
|
1769
|
+
* Throws a `MesubError` whenever Mesub cannot answer: it is for screens.
|
|
1770
|
+
* A guard calls `hasAccess`, which falls back instead.
|
|
1771
|
+
*/
|
|
1772
|
+
async access(customer, plan, options = {}) {
|
|
1773
|
+
const asked = customerOf(customer);
|
|
1774
|
+
const slug = planOf(plan);
|
|
1775
|
+
if (options.attempts === true) return this.ask(asked, slug, null, boundOf(options));
|
|
1776
|
+
const slot = await this.slotOf(asked);
|
|
1777
|
+
const cached = await this.cache.read(slot.who, slug, slot.scope);
|
|
1778
|
+
if (_optionalChain([cached, 'optionalAccess', _22 => _22.fresh])) return cached.value;
|
|
1779
|
+
return this.ask(asked, slug, slot);
|
|
1780
|
+
}
|
|
1781
|
+
/**
|
|
1782
|
+
* Every plan of the project that customer has anything on, each answered
|
|
1783
|
+
* like `access`, for a page listing their entitlements in one call. Cached
|
|
1784
|
+
* apart from the answers by plan, for its own `revalidate_after`.
|
|
1785
|
+
*
|
|
1786
|
+
* Throws a `MesubError` whenever Mesub cannot answer, like `access`.
|
|
1787
|
+
*/
|
|
1788
|
+
async accessList(customer, options = {}) {
|
|
1789
|
+
const asked = customerOf(customer);
|
|
1790
|
+
const attempts = options.attempts === true;
|
|
1791
|
+
const slot = attempts ? null : await this.slotOf(asked);
|
|
1792
|
+
if (slot) {
|
|
1793
|
+
const cached = await this.lists.read(slot.who, null, slot.scope);
|
|
1794
|
+
if (_optionalChain([cached, 'optionalAccess', _23 => _23.fresh])) return cached.value;
|
|
1795
|
+
}
|
|
1796
|
+
const request = async () => accessListFrom(
|
|
1797
|
+
await this.transport.get("/v1/access", {
|
|
1798
|
+
[asked.kind]: asked.value,
|
|
1799
|
+
attempts: attempts || void 0
|
|
1800
|
+
})
|
|
1801
|
+
);
|
|
1802
|
+
if (!slot) return request();
|
|
1803
|
+
return this.shared(
|
|
1804
|
+
flightKey("call", slot, null),
|
|
1805
|
+
request,
|
|
1806
|
+
(list) => this.lists.write(slot.who, null, list, slot.scope)
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
/**
|
|
1810
|
+
* `access` from Mesub, cached in the slot its caller computed once and
|
|
1811
|
+
* already read, or without the cache at all (`null`) for an answer with
|
|
1812
|
+
* its attempts.
|
|
1813
|
+
*/
|
|
1814
|
+
async ask(asked, plan, slot, call = {}) {
|
|
1815
|
+
const attempts = slot === null;
|
|
1816
|
+
const request = async () => accessAnswerFrom(
|
|
1817
|
+
await this.transport.get(
|
|
1818
|
+
"/v1/access",
|
|
1819
|
+
{
|
|
1820
|
+
[asked.kind]: asked.value,
|
|
1821
|
+
plan,
|
|
1822
|
+
// Left out when not asked: the transport drops undefined values.
|
|
1823
|
+
attempts: attempts || void 0
|
|
1824
|
+
},
|
|
1825
|
+
call
|
|
1826
|
+
)
|
|
1827
|
+
);
|
|
1828
|
+
if (!slot) return request();
|
|
1829
|
+
const mode = call.deadline === void 0 ? "call" : "guard";
|
|
1830
|
+
return this.shared(
|
|
1831
|
+
flightKey(mode, slot, plan),
|
|
1832
|
+
request,
|
|
1833
|
+
(answer) => this.cache.write(slot.who, plan, answer, slot.scope)
|
|
1834
|
+
);
|
|
1835
|
+
}
|
|
1836
|
+
/**
|
|
1837
|
+
* One request in flight per key (#34): fifty calls for a customer not in
|
|
1838
|
+
* the cache made fifty requests to Mesub, and as many 429s to retry. The
|
|
1839
|
+
* first sends it, the others wait for its answer or its error. Its answer
|
|
1840
|
+
* is cached once, unless a subscription that landed dropped the flight
|
|
1841
|
+
* meanwhile: then it is answered, not cached.
|
|
1842
|
+
*/
|
|
1843
|
+
shared(key, request, keep) {
|
|
1844
|
+
const pending = this.flights.get(key);
|
|
1845
|
+
if (pending) return pending;
|
|
1846
|
+
const flight = request().then((value) => {
|
|
1847
|
+
if (this.flights.get(key) === flight) void keep(value);
|
|
1848
|
+
return value;
|
|
1849
|
+
});
|
|
1850
|
+
const land = () => {
|
|
1851
|
+
if (this.flights.get(key) === flight) this.flights.delete(key);
|
|
1852
|
+
};
|
|
1853
|
+
this.flights.set(key, flight);
|
|
1854
|
+
void flight.then(land, land);
|
|
1855
|
+
return flight;
|
|
1856
|
+
}
|
|
1857
|
+
/**
|
|
1858
|
+
* Where a customer's answers are cached: the project's scope, and who
|
|
1859
|
+
* they are as the key reads it. A wallet is public on chain and stays
|
|
1860
|
+
* readable; an email or an external id is never written in clear, only
|
|
1861
|
+
* as an HMAC under the API key, which a leaked Redis alone cannot reverse
|
|
1862
|
+
* by guessing.
|
|
1863
|
+
*/
|
|
1864
|
+
async slotOf(asked) {
|
|
1865
|
+
const scope = await this.cacheScope();
|
|
1866
|
+
if (asked.kind === "wallet") return { scope, who: `wallet:${asked.value}` };
|
|
1867
|
+
const mac = await crypto.subtle.sign(
|
|
1868
|
+
"HMAC",
|
|
1869
|
+
await this.cacheSecret(),
|
|
1870
|
+
new TextEncoder().encode(`${asked.kind}:${asked.value}`)
|
|
1871
|
+
);
|
|
1872
|
+
return { scope, who: `${asked.kind}:${hex(mac)}` };
|
|
1873
|
+
}
|
|
1874
|
+
/**
|
|
1875
|
+
* A subscription `submit`, `retrieve` or `list` answered (#33): once it
|
|
1876
|
+
* grants access (`active`, or `cancelled` before its end), the answers
|
|
1877
|
+
* cached for its customer that say no are dropped, by wallet, external
|
|
1878
|
+
* id and email, for its plan and in the list of every plan. Without it, a
|
|
1879
|
+
* `hasAccess` right after a submit kept answering the cached no until
|
|
1880
|
+
* `revalidate_after` ran out. A store that fails costs nothing more than
|
|
1881
|
+
* that: it never throws.
|
|
1882
|
+
*
|
|
1883
|
+
* With `moved`, one a confirm just cancelled, resumed or closed, or one a
|
|
1884
|
+
* verified webhook carried: every answer cached for its customer is
|
|
1885
|
+
* dropped, the yes too, since its status and dates changed whatever its
|
|
1886
|
+
* access. A `stopped` or `ended` event must not leave a yes behind.
|
|
1887
|
+
*/
|
|
1888
|
+
async landed(subscription, moved = false) {
|
|
1889
|
+
const { plan } = subscription;
|
|
1890
|
+
if (!moved && !subscription.access) return;
|
|
1891
|
+
try {
|
|
1892
|
+
for (const asked of customersOf(subscription)) {
|
|
1893
|
+
const slot = await this.slotOf(asked);
|
|
1894
|
+
for (const mode of ["call", "guard"]) {
|
|
1895
|
+
if (plan !== null) this.flights.delete(flightKey(mode, slot, plan));
|
|
1896
|
+
this.flights.delete(flightKey(mode, slot, null));
|
|
1897
|
+
}
|
|
1898
|
+
if (plan !== null) {
|
|
1899
|
+
await this.cache.forget(
|
|
1900
|
+
slot.who,
|
|
1901
|
+
plan,
|
|
1902
|
+
slot.scope,
|
|
1903
|
+
(answer) => moved || !answer.access
|
|
1904
|
+
);
|
|
1905
|
+
}
|
|
1906
|
+
await this.lists.forget(
|
|
1907
|
+
slot.who,
|
|
1908
|
+
null,
|
|
1909
|
+
slot.scope,
|
|
1910
|
+
(list) => moved || !list.plans.some((answer) => answer.plan === plan && answer.access)
|
|
1911
|
+
);
|
|
1912
|
+
}
|
|
1913
|
+
} catch (e8) {
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
/**
|
|
1917
|
+
* Whether that customer has access to that plan, for a guard. The
|
|
1918
|
+
* customer is the same as for `access`, a wallet string included.
|
|
1919
|
+
*
|
|
1920
|
+
* When Mesub is unavailable after the retries (`unavailable` or
|
|
1921
|
+
* `rate_limited`), it answers the last answer it knew, even stale, and
|
|
1922
|
+
* `false` for a customer it never saw. Any other error is thrown, never
|
|
1923
|
+
* turned into `false`: a bad key or an unknown plan is a broken
|
|
1924
|
+
* integration, not a denial.
|
|
1925
|
+
*
|
|
1926
|
+
* No time budget: it waits out the client's timeout and retries, and a
|
|
1927
|
+
* 429's `Retry-After`. A guard answers within `guardTimeout` instead.
|
|
1928
|
+
*/
|
|
1929
|
+
async hasAccess(customer, plan) {
|
|
1930
|
+
return (await this.decideBy(customer, plan)).access;
|
|
1931
|
+
}
|
|
1932
|
+
/**
|
|
1933
|
+
* `hasAccess`, with the answer it decided on, within `guardTimeout`: what
|
|
1934
|
+
* the middlewares hand the route. `answer` is null only for a customer
|
|
1935
|
+
* never seen during an outage, which is then `unavailable`; `stale` is
|
|
1936
|
+
* true when the answer came from the fallback.
|
|
1937
|
+
*
|
|
1938
|
+
* @internal
|
|
1939
|
+
*/
|
|
1940
|
+
async decide(customer, plan) {
|
|
1941
|
+
return this.decideBy(customer, plan, Date.now() + this.guardTimeout);
|
|
1942
|
+
}
|
|
1943
|
+
/**
|
|
1944
|
+
* The decision itself. With a deadline (the guards), every call to Mesub
|
|
1945
|
+
* fits before it: attempts are cut at it and a Retry-After that would
|
|
1946
|
+
* outlast it is not waited. Without one (`hasAccess` called directly),
|
|
1947
|
+
* the client's timeout and retries, as configured.
|
|
1948
|
+
*/
|
|
1949
|
+
async decideBy(customer, plan, deadline) {
|
|
1950
|
+
const call = deadline === void 0 ? {} : { deadline, retryRateLimited: false };
|
|
1951
|
+
const asked = customerOf(customer);
|
|
1952
|
+
const slug = planOf(plan);
|
|
1953
|
+
const slot = await this.slotOf(asked);
|
|
1954
|
+
const cached = await this.cache.read(slot.who, slug, slot.scope, deadline);
|
|
1955
|
+
if (_optionalChain([cached, 'optionalAccess', _24 => _24.fresh])) {
|
|
1956
|
+
return { access: cached.value.access, answer: cached.value, stale: false };
|
|
1957
|
+
}
|
|
1958
|
+
try {
|
|
1959
|
+
const answer = await this.ask(asked, slug, slot, call);
|
|
1960
|
+
return { access: answer.access, answer, stale: false };
|
|
1961
|
+
} catch (error) {
|
|
1962
|
+
const unreachable = error instanceof MesubError && (error.code === "unavailable" || error.code === "rate_limited");
|
|
1963
|
+
if (!unreachable) throw error;
|
|
1964
|
+
if (cached) {
|
|
1965
|
+
return { access: stillGrants(cached.value), answer: cached.value, stale: true };
|
|
1966
|
+
}
|
|
1967
|
+
return {
|
|
1968
|
+
access: false,
|
|
1969
|
+
answer: null,
|
|
1970
|
+
stale: true,
|
|
1971
|
+
unavailable: true,
|
|
1972
|
+
...error.retryAfter !== null && { retryAfter: error.retryAfter }
|
|
1973
|
+
};
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
}, _class3);
|
|
1977
|
+
function boundOf(options) {
|
|
1978
|
+
const { deadline, retryRateLimited } = options;
|
|
1979
|
+
return {
|
|
1980
|
+
...deadline !== void 0 && { deadline },
|
|
1981
|
+
...retryRateLimited !== void 0 && { retryRateLimited }
|
|
1982
|
+
};
|
|
1983
|
+
}
|
|
1984
|
+
function flightKey(mode, slot, plan) {
|
|
1985
|
+
return `${mode}|${AccessCache.key(slot.who, plan, slot.scope)}`;
|
|
1986
|
+
}
|
|
1987
|
+
function customersOf(subscription) {
|
|
1988
|
+
const { wallet, external_id, email } = subscription;
|
|
1989
|
+
const customers = [{ wallet }];
|
|
1990
|
+
if (external_id) customers.push({ external_id });
|
|
1991
|
+
if (email) customers.push({ email });
|
|
1992
|
+
return customers.map(customerOf);
|
|
1993
|
+
}
|
|
1994
|
+
function planOf(plan) {
|
|
1995
|
+
if (typeof plan !== "string" || plan === "") {
|
|
1996
|
+
throw new TypeError("A plan is needed: accessList(customer) lists every plan instead.");
|
|
1997
|
+
}
|
|
1998
|
+
return plan;
|
|
1999
|
+
}
|
|
2000
|
+
async function hashScope(apiKey) {
|
|
2001
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(apiKey));
|
|
2002
|
+
return `key-${hex(digest).slice(0, 16)}`;
|
|
2003
|
+
}
|
|
2004
|
+
function importSecret(apiKey) {
|
|
2005
|
+
return crypto.subtle.importKey(
|
|
2006
|
+
"raw",
|
|
2007
|
+
new TextEncoder().encode(apiKey),
|
|
2008
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
2009
|
+
false,
|
|
2010
|
+
["sign"]
|
|
2011
|
+
);
|
|
2012
|
+
}
|
|
2013
|
+
function hex(bytes) {
|
|
2014
|
+
return Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
2015
|
+
}
|
|
2016
|
+
function stillGrants(answer, now = Date.now()) {
|
|
2017
|
+
if (!answer.access || !answer.access_until) return answer.access;
|
|
2018
|
+
const renewing = Boolean(answer.next_charge_at || answer.next_retry_at);
|
|
2019
|
+
if (renewing && !answer.blocked_since) return true;
|
|
2020
|
+
return Date.parse(answer.access_until) > now;
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
|
|
2024
|
+
|
|
2025
|
+
|
|
2026
|
+
|
|
2027
|
+
|
|
2028
|
+
|
|
2029
|
+
|
|
2030
|
+
|
|
2031
|
+
|
|
2032
|
+
|
|
2033
|
+
|
|
2034
|
+
|
|
2035
|
+
|
|
2036
|
+
|
|
2037
|
+
exports.DEFAULT_MAX_STALE_MS = DEFAULT_MAX_STALE_MS; exports.AccessCache = AccessCache; exports.VERSION = VERSION; exports.API_VERSION = API_VERSION; exports.API_VERSION_HEADER = API_VERSION_HEADER; exports.DEFAULT_MAX_ENTRIES = DEFAULT_MAX_ENTRIES; exports.MemoryStore = MemoryStore; exports.customerOf = customerOf; exports.MesubError = MesubError; exports.MesubSubmitError = MesubSubmitError; exports.verifyWebhook = verifyWebhook; exports.signedHeaders = signedHeaders; exports.Mesub = Mesub;
|
|
2038
|
+
//# sourceMappingURL=chunk-SGSYQJYT.cjs.map
|