@elapse/sdk 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/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # @elapse/sdk
2
+
3
+ Per-second billing on Monad, integrated like Stripe. **You only pay what elapsed.**
4
+
5
+ ```sh
6
+ npm install @elapse/sdk
7
+ ```
8
+
9
+ ```ts
10
+ import { Elapse } from "@elapse/sdk";
11
+
12
+ const elapse = new Elapse({ secretKey: process.env.ELAPSE_SECRET_KEY });
13
+
14
+ const product = await elapse.products.create({
15
+ name: "GPU · 4090",
16
+ rateUsdPerSecond: "0.004",
17
+ });
18
+
19
+ const session = await elapse.checkout.sessions.create({
20
+ product: product.id,
21
+ successUrl: "https://merchant.example/ok",
22
+ cancelUrl: "https://merchant.example/cancel",
23
+ });
24
+
25
+ const event = elapse.webhooks.constructEvent(
26
+ rawBody,
27
+ headers["x-elapse-signature"],
28
+ process.env.ELAPSE_WEBHOOK_SECRET
29
+ );
30
+ ```
31
+
32
+ Send the subscriber to `session.url`. They start a meter, watch the counter tick, and cancel whenever. Your server finds out through a signed webhook, not a cron job.
33
+
34
+ ## Surface
35
+
36
+ | Method | REST |
37
+ | --- | --- |
38
+ | `products.create / retrieve / list` | `POST /v1/products`, `GET /v1/products/:id`, `GET /v1/products` |
39
+ | `checkout.sessions.create` | `POST /v1/checkout/sessions` |
40
+ | `subscriptions.retrieve / list / cancel` | `GET /v1/subscriptions/:id`, `GET /v1/subscriptions`, `POST /v1/subscriptions/:id/cancel` |
41
+ | `customers.retrieve` | `GET /v1/customers/:id` |
42
+ | `invoices.list` | `GET /v1/invoices` |
43
+ | `webhooks.constructEvent(rawBody, header, secret)` | local |
44
+
45
+ Requests take the camelCase keys shown above. Responses are the API's objects as they come, snake_case, identical to the cURL tab and to webhook bodies.
46
+
47
+ ## Webhooks
48
+
49
+ Six event types: `checkout.session.completed`, `subscription.created`, `subscription.updated`, `subscription.canceled`, `invoice.settled`, `invoice.payment_failed`. Never anything per second: compute a live meter from `rate_usd_per_second` and `started_at`.
50
+
51
+ Verify with the **raw** request body. If your framework parses JSON before your handler, disable that for the webhook route.
52
+
53
+ ```ts
54
+ app.post("/webhooks/elapse", express.raw({ type: "application/json" }), (req, res) => {
55
+ let event;
56
+ try {
57
+ event = elapse.webhooks.constructEvent(req.body, req.header("x-elapse-signature"), process.env.ELAPSE_WEBHOOK_SECRET);
58
+ } catch {
59
+ return res.status(400).end();
60
+ }
61
+ if (event.type === "subscription.canceled") {
62
+ // event.data.object.seconds_elapsed, event.data.object.amount_settled
63
+ }
64
+ res.status(200).end();
65
+ });
66
+ ```
67
+
68
+ `constructEvent` checks the timestamp is within 300 s and compares every signature in constant time. While you rotate a signing secret, pass both: `constructEvent(body, header, [newSecret, oldSecret])`.
69
+
70
+ ## Errors
71
+
72
+ Everything thrown extends `ElapseError`: `ElapseAuthenticationError` (401/403), `ElapseInvalidRequestError` (400/404/422, and client-side validation), `ElapseRateLimitError` (429), `ElapseAPIError` (5xx, network, `code: "timeout"`), `ElapseSignatureVerificationError`. Network errors, 429 and 5xx are retried twice with backoff; every `create` and `cancel` carries an `Idempotency-Key`, so retries never double-create. Pass `{ idempotencyKey, timeoutMs }` as the last argument of any method.
73
+
74
+ ## Keep the secret key server-side
75
+
76
+ `sk_…` keys authorise everything on your account. Read them from the server environment only. The SDK refuses to construct in a browser. Webhooks documentation: <https://docs.elapse.dev/webhooks>.
77
+
78
+ Node 20+. Zero runtime dependencies. MIT.
package/dist/index.cjs ADDED
@@ -0,0 +1,367 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ Elapse: () => Elapse,
24
+ ElapseAPIError: () => ElapseAPIError,
25
+ ElapseAuthenticationError: () => ElapseAuthenticationError,
26
+ ElapseError: () => ElapseError,
27
+ ElapseInvalidRequestError: () => ElapseInvalidRequestError,
28
+ ElapseRateLimitError: () => ElapseRateLimitError,
29
+ ElapseSignatureVerificationError: () => ElapseSignatureVerificationError,
30
+ constructEvent: () => constructEvent
31
+ });
32
+ module.exports = __toCommonJS(index_exports);
33
+
34
+ // src/errors.ts
35
+ var ElapseError = class extends Error {
36
+ /** HTTP status, when the error came from a response. */
37
+ status;
38
+ /** The API's `error.type` (`invalid_request_error`, …), when present. */
39
+ type;
40
+ code;
41
+ param;
42
+ requestId;
43
+ constructor(message, fields = {}) {
44
+ super(message);
45
+ this.name = new.target.name;
46
+ this.status = fields.status;
47
+ this.type = fields.type;
48
+ this.code = fields.code;
49
+ this.param = fields.param;
50
+ this.requestId = fields.requestId;
51
+ }
52
+ };
53
+ var ElapseAuthenticationError = class extends ElapseError {
54
+ };
55
+ var ElapseInvalidRequestError = class extends ElapseError {
56
+ };
57
+ var ElapseRateLimitError = class extends ElapseError {
58
+ retryAfter;
59
+ constructor(message, fields = {}) {
60
+ super(message, fields);
61
+ this.retryAfter = fields.retryAfter;
62
+ }
63
+ };
64
+ var ElapseAPIError = class extends ElapseError {
65
+ };
66
+ var ElapseSignatureVerificationError = class extends ElapseError {
67
+ };
68
+
69
+ // src/http.ts
70
+ var import_node_crypto = require("crypto");
71
+ var VERSION = "0.1.0";
72
+ var Transport = class {
73
+ /** A real private field: `util.inspect` and `console.log` never show it (BR-SDK-002). */
74
+ #cfg;
75
+ constructor(cfg) {
76
+ this.#cfg = cfg;
77
+ }
78
+ /** Overridable for tests. */
79
+ _sleep(ms) {
80
+ return new Promise((f) => setTimeout(f, ms));
81
+ }
82
+ async request(method, path, body, opts = {}) {
83
+ const url = `${this.#cfg.baseUrl}/v1${path}`;
84
+ const headers = {
85
+ Authorization: `Bearer ${this.#cfg.secretKey}`,
86
+ "Content-Type": "application/json",
87
+ "User-Agent": `elapse-node/${VERSION}`,
88
+ Accept: "application/json"
89
+ };
90
+ if (method === "POST") headers["Idempotency-Key"] = opts.idempotencyKey ?? (0, import_node_crypto.randomUUID)();
91
+ const timeoutMs = opts.timeoutMs ?? this.#cfg.timeoutMs;
92
+ const payload = body === void 0 ? void 0 : JSON.stringify(body);
93
+ let attempt = 0;
94
+ for (; ; ) {
95
+ let res;
96
+ try {
97
+ const init = { method, headers, signal: AbortSignal.timeout(timeoutMs) };
98
+ if (payload !== void 0) init.body = payload;
99
+ res = await (this.#cfg.fetchImpl ?? fetch)(url, init);
100
+ } catch (e) {
101
+ const err2 = networkError(e);
102
+ if (attempt < this.#cfg.maxRetries) {
103
+ await this._sleep(backoff(attempt++));
104
+ continue;
105
+ }
106
+ throw err2;
107
+ }
108
+ const requestId = res.headers.get("request-id") ?? void 0;
109
+ const text = await res.text();
110
+ if (res.ok) {
111
+ try {
112
+ return JSON.parse(text);
113
+ } catch {
114
+ throw new ElapseAPIError("Invalid JSON received from the Elapse API.", { status: res.status, ...requestId ? { requestId } : {} });
115
+ }
116
+ }
117
+ const err = mapError(res.status, text, requestId, res.headers.get("retry-after"));
118
+ const retryable = res.status === 429 || res.status >= 500;
119
+ if (retryable && attempt < this.#cfg.maxRetries) {
120
+ const ra = err instanceof ElapseRateLimitError && err.retryAfter !== void 0 ? err.retryAfter * 1e3 : void 0;
121
+ await this._sleep(ra ?? backoff(attempt));
122
+ attempt++;
123
+ continue;
124
+ }
125
+ throw err;
126
+ }
127
+ }
128
+ };
129
+ function backoff(n) {
130
+ const base = 500 * 2 ** n;
131
+ return Math.round(base * (0.75 + Math.random() * 0.5));
132
+ }
133
+ function networkError(e) {
134
+ const name = e instanceof Error ? e.name : "";
135
+ if (name === "TimeoutError" || name === "AbortError") return new ElapseAPIError("Request timed out.", { code: "timeout" });
136
+ const msg = e instanceof Error ? e.message : String(e);
137
+ return new ElapseAPIError(`Network error: ${msg}`, { code: "network_error" });
138
+ }
139
+ function mapError(status, text, requestId, retryAfter) {
140
+ let type;
141
+ let message = `Request failed with status ${status}.`;
142
+ let code;
143
+ let param;
144
+ try {
145
+ const parsed = JSON.parse(text);
146
+ if (parsed?.error) {
147
+ type = parsed.error.type;
148
+ message = parsed.error.message ?? message;
149
+ code = parsed.error.code;
150
+ param = parsed.error.param;
151
+ }
152
+ } catch {
153
+ }
154
+ const fields = {
155
+ status,
156
+ ...type ? { type } : {},
157
+ ...code ? { code } : {},
158
+ ...param ? { param } : {},
159
+ ...requestId ? { requestId } : {}
160
+ };
161
+ if (status === 401 || status === 403) return new ElapseAuthenticationError(message, fields);
162
+ if (status === 429) {
163
+ const ra = retryAfter !== null && /^\d+$/.test(retryAfter) ? Number(retryAfter) : void 0;
164
+ return new ElapseRateLimitError(message, { ...fields, ...ra !== void 0 ? { retryAfter: ra } : {} });
165
+ }
166
+ if (status >= 400 && status < 500) return new ElapseInvalidRequestError(message, fields);
167
+ return new ElapseAPIError(message, fields);
168
+ }
169
+
170
+ // src/resources.ts
171
+ var DECIMAL = /^\d+(\.\d+)?$/;
172
+ var STATUSES = ["incomplete", "active", "paused", "canceled"];
173
+ function assertId(id, prefix, name) {
174
+ if (typeof id !== "string" || !id.startsWith(`${prefix}_`) || id.length <= prefix.length + 1) {
175
+ throw new ElapseInvalidRequestError(`${name} must be an id starting with '${prefix}_'.`, { param: name });
176
+ }
177
+ return id;
178
+ }
179
+ function query(params) {
180
+ const q = new URLSearchParams();
181
+ for (const [k, v] of Object.entries(params)) if (v !== void 0) q.set(k, String(v));
182
+ const s = q.toString();
183
+ return s ? `?${s}` : "";
184
+ }
185
+ function products(t) {
186
+ return {
187
+ create(params, opts) {
188
+ if (typeof params.rateUsdPerSecond !== "string" || !DECIMAL.test(params.rateUsdPerSecond)) {
189
+ return Promise.reject(
190
+ new ElapseInvalidRequestError('rateUsdPerSecond must be a decimal string such as "0.004" (never a number).', { param: "rateUsdPerSecond" })
191
+ );
192
+ }
193
+ const body = { name: params.name, rate_usd_per_second: params.rateUsdPerSecond };
194
+ if (params.allowPause !== void 0) body.allow_pause = params.allowPause;
195
+ if (params.description !== void 0) body.description = params.description;
196
+ return t.request("POST", "/products", body, opts);
197
+ },
198
+ retrieve(id, opts) {
199
+ return Promise.resolve().then(() => assertId(id, "prod", "id")).then((v) => t.request("GET", `/products/${v}`, void 0, opts));
200
+ },
201
+ list(params = {}, opts) {
202
+ return t.request("GET", `/products${query({ limit: params.limit, starting_after: params.startingAfter })}`, void 0, opts);
203
+ }
204
+ };
205
+ }
206
+ function checkout(t) {
207
+ return {
208
+ sessions: {
209
+ create(params, opts) {
210
+ return Promise.resolve().then(() => assertId(params.product, "prod", "product")).then((product) => {
211
+ const body = { product, success_url: params.successUrl, cancel_url: params.cancelUrl };
212
+ if (params.maxDurationSeconds !== void 0) body.max_duration_seconds = params.maxDurationSeconds;
213
+ return t.request("POST", "/checkout/sessions", body, opts);
214
+ });
215
+ }
216
+ }
217
+ };
218
+ }
219
+ function subscriptions(t) {
220
+ return {
221
+ retrieve(id, opts) {
222
+ return Promise.resolve().then(() => assertId(id, "sub", "id")).then((v) => t.request("GET", `/subscriptions/${v}`, void 0, opts));
223
+ },
224
+ list(params = {}, opts) {
225
+ if (params.status !== void 0 && !STATUSES.includes(params.status)) {
226
+ return Promise.reject(new ElapseInvalidRequestError(`status must be one of ${STATUSES.join(", ")}.`, { param: "status" }));
227
+ }
228
+ return t.request(
229
+ "GET",
230
+ `/subscriptions${query({ customer: params.customer, product: params.product, status: params.status, limit: params.limit, starting_after: params.startingAfter })}`,
231
+ void 0,
232
+ opts
233
+ );
234
+ },
235
+ /** Asks the platform to end the meter; the `canceled` status arrives via webhook once the chain confirms (API FR-API-042). */
236
+ cancel(id, opts) {
237
+ return Promise.resolve().then(() => assertId(id, "sub", "id")).then((v) => t.request("POST", `/subscriptions/${v}/cancel`, {}, opts));
238
+ }
239
+ };
240
+ }
241
+ function customers(t) {
242
+ return {
243
+ retrieve(id, opts) {
244
+ return Promise.resolve().then(() => assertId(id, "cus", "id")).then((v) => t.request("GET", `/customers/${v}`, void 0, opts));
245
+ }
246
+ };
247
+ }
248
+ function invoices(t) {
249
+ return {
250
+ list(params = {}, opts) {
251
+ return t.request(
252
+ "GET",
253
+ `/invoices${query({ subscription: params.subscription, customer: params.customer, limit: params.limit, starting_after: params.startingAfter })}`,
254
+ void 0,
255
+ opts
256
+ );
257
+ }
258
+ };
259
+ }
260
+
261
+ // src/webhooks.ts
262
+ var import_node_crypto2 = require("crypto");
263
+ var DEFAULT_TOLERANCE_S = 300;
264
+ var MAX_SIGNATURES = 4;
265
+ var HEX64 = /^[0-9a-f]{64}$/;
266
+ function constructEvent(rawBody, header, secret, options = {}) {
267
+ if (header === void 0 || header === null) throw new ElapseSignatureVerificationError("Missing X-Elapse-Signature header.");
268
+ const secrets = typeof secret === "string" ? [secret] : secret === void 0 ? [] : [...secret];
269
+ if (secrets.length === 0 || secrets.some((s) => typeof s !== "string" || s.length === 0)) {
270
+ throw new ElapseSignatureVerificationError("At least one non-empty webhook secret is required.");
271
+ }
272
+ const { t, signatures } = parseHeader(header);
273
+ const tolerance = options.tolerance ?? DEFAULT_TOLERANCE_S;
274
+ const now = options.now ? options.now() : Math.floor(Date.now() / 1e3);
275
+ if (Math.abs(now - t) > tolerance) {
276
+ throw new ElapseSignatureVerificationError(`Timestamp outside the tolerance zone (${tolerance} s).`);
277
+ }
278
+ const body = typeof rawBody === "string" ? rawBody : Buffer.from(rawBody).toString("utf8");
279
+ const payload = `${t}.${body}`;
280
+ let matched = false;
281
+ for (const s of secrets) {
282
+ const expected = Buffer.from((0, import_node_crypto2.createHmac)("sha256", s).update(payload).digest("hex"), "hex");
283
+ for (const sig of signatures) {
284
+ const given = Buffer.from(sig, "hex");
285
+ if ((0, import_node_crypto2.timingSafeEqual)(given, expected)) matched = true;
286
+ }
287
+ }
288
+ if (!matched) throw new ElapseSignatureVerificationError("No signatures found matching the expected signature for payload.");
289
+ try {
290
+ return JSON.parse(body);
291
+ } catch {
292
+ throw new ElapseSignatureVerificationError("Payload is not valid JSON.");
293
+ }
294
+ }
295
+ function parseHeader(header) {
296
+ let t;
297
+ const signatures = [];
298
+ for (const part of header.split(",")) {
299
+ const eq = part.indexOf("=");
300
+ if (eq < 0) throw new ElapseSignatureVerificationError("Malformed X-Elapse-Signature header.");
301
+ const key = part.slice(0, eq).trim();
302
+ const value = part.slice(eq + 1).trim();
303
+ if (key === "t") {
304
+ if (t !== void 0 || !/^\d+$/.test(value)) throw new ElapseSignatureVerificationError("Malformed X-Elapse-Signature header: bad timestamp.");
305
+ t = Number(value);
306
+ } else if (key === "v1") {
307
+ if (!HEX64.test(value)) throw new ElapseSignatureVerificationError("Malformed X-Elapse-Signature header: bad signature.");
308
+ signatures.push(value);
309
+ }
310
+ }
311
+ if (t === void 0 || signatures.length === 0) throw new ElapseSignatureVerificationError("Malformed X-Elapse-Signature header: missing t or v1.");
312
+ if (signatures.length > MAX_SIGNATURES) throw new ElapseSignatureVerificationError("Malformed X-Elapse-Signature header: too many signatures.");
313
+ return { t, signatures };
314
+ }
315
+
316
+ // src/index.ts
317
+ var Elapse = class {
318
+ #transport;
319
+ baseUrl;
320
+ maxRetries;
321
+ timeoutMs;
322
+ products;
323
+ checkout;
324
+ subscriptions;
325
+ customers;
326
+ invoices;
327
+ webhooks = {
328
+ /** Verify a webhook and parse the event. Pass the raw body, never a parsed object. */
329
+ constructEvent: (rawBody, header, secret, options) => constructEvent(rawBody, header, secret, options)
330
+ };
331
+ constructor(config) {
332
+ if (typeof globalThis !== "undefined" && "window" in globalThis && typeof globalThis.document !== "undefined") {
333
+ throw new ElapseInvalidRequestError("@elapse/sdk is server-side only: your secret key must never reach a browser.");
334
+ }
335
+ if (!config || typeof config.secretKey !== "string" || config.secretKey.length === 0) {
336
+ throw new ElapseInvalidRequestError("Missing secretKey. Pass { secretKey: 'sk_test_\u2026' } from your server environment.");
337
+ }
338
+ this.baseUrl = (config.baseUrl ?? "https://api.elapse.dev").replace(/\/+$/, "");
339
+ this.maxRetries = config.maxRetries ?? 2;
340
+ this.timeoutMs = config.timeoutMs ?? 3e4;
341
+ this.#transport = new Transport({ secretKey: config.secretKey, baseUrl: this.baseUrl, maxRetries: this.maxRetries, timeoutMs: this.timeoutMs });
342
+ this.products = products(this.#transport);
343
+ this.checkout = checkout(this.#transport);
344
+ this.subscriptions = subscriptions(this.#transport);
345
+ this.customers = customers(this.#transport);
346
+ this.invoices = invoices(this.#transport);
347
+ }
348
+ /** Test hook: override the retry sleep. Not part of the public surface. */
349
+ set _sleep(fn) {
350
+ this.#transport._sleep = fn;
351
+ }
352
+ /** Never leak the key through logging (BR-SDK-002). */
353
+ toJSON() {
354
+ return { baseUrl: this.baseUrl };
355
+ }
356
+ };
357
+ // Annotate the CommonJS export names for ESM import in node:
358
+ 0 && (module.exports = {
359
+ Elapse,
360
+ ElapseAPIError,
361
+ ElapseAuthenticationError,
362
+ ElapseError,
363
+ ElapseInvalidRequestError,
364
+ ElapseRateLimitError,
365
+ ElapseSignatureVerificationError,
366
+ constructEvent
367
+ });