kredo 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/README.md +123 -2
- package/dist/client.d.ts +137 -0
- package/dist/client.js +238 -0
- package/dist/errors.d.ts +17 -0
- package/dist/errors.js +35 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/network.d.ts +45 -0
- package/dist/network.js +40 -0
- package/dist/types.d.ts +187 -0
- package/dist/types.js +1 -0
- package/dist/webhooks.d.ts +8 -0
- package/dist/webhooks.js +39 -0
- package/package.json +43 -4
package/README.md
CHANGED
|
@@ -1,3 +1,124 @@
|
|
|
1
|
-
#
|
|
1
|
+
# kredo
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Charge Kredo credits from your server. Your users buy credits once and spend them in every app on the network. You're paid in USDT on Whitechain for what they spend with you.
|
|
4
|
+
|
|
5
|
+
No dependencies. Node 18+.
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { Credits } from "kredo";
|
|
11
|
+
|
|
12
|
+
const credits = new Credits({ apiKey: process.env.CREDITS_API_KEY! });
|
|
13
|
+
|
|
14
|
+
// Prices in credits. 1 credit = $0.01.
|
|
15
|
+
await credits.setPrices({
|
|
16
|
+
"chat.message": { price: 1 },
|
|
17
|
+
"video.render": { rate: 6, unit: "second" },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// One-off actions: charge right away.
|
|
21
|
+
await credits.charge({ user, action: "chat.message", ref: messageId });
|
|
22
|
+
|
|
23
|
+
// Long jobs: hold the worst case, then charge what was used.
|
|
24
|
+
const hold = await credits.hold({ user, action: "video.render", quantity: 12 });
|
|
25
|
+
try {
|
|
26
|
+
const seconds = await render(job);
|
|
27
|
+
await credits.capture(hold.holdId, { credits: seconds * 6 }); // the rest goes back
|
|
28
|
+
} catch {
|
|
29
|
+
await credits.release(hold.holdId); // the user keeps everything
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Keep the API key on your server. Never ship it to a browser.
|
|
34
|
+
|
|
35
|
+
## Test mode
|
|
36
|
+
|
|
37
|
+
Every app also gets a test key, `uc_test_...`, from the dashboard. Use it in place of the live key and the same calls run against fake credits, with nothing onchain: every test user starts with 1,000 credits and a 500 credit limit for your app, and limits, holds, captures and refunds follow the vault's rules exactly, refusals included. Responses carry `test: true`.
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
const credits = new Credits({ apiKey: process.env.CREDITS_TEST_KEY! });
|
|
41
|
+
const { user } = await credits.exchangeCode(`cc_test_${someAddress}`); // any address, no hosted page
|
|
42
|
+
await credits.charge({ user, credits: 10 });
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Payouts, webhooks and reporting need the live key. `POST /v1/sandbox/reset` puts every test user back to the start.
|
|
46
|
+
|
|
47
|
+
## Connecting a user
|
|
48
|
+
|
|
49
|
+
A user has to approve a spending limit for your app before you can charge them. The vault contract enforces that limit, so not even the network's own operator can charge more.
|
|
50
|
+
|
|
51
|
+
1. Send them to the connect page: `await credits.connectUrl({ credits: 200, returnTo })`.
|
|
52
|
+
2. They sign in with email, approve the limit, and come back to `returnTo?code=...`.
|
|
53
|
+
3. On your server, swap the code for their address and keep it in your session:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
const { user } = await credits.exchangeCode(req.query.code);
|
|
57
|
+
req.session.creditsUser = user;
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Never take a user's address from the browser. The code works once, only for your app, for 10 minutes.
|
|
61
|
+
|
|
62
|
+
The React kit ([`kredo-react`](../react)) does this in a popup, and `credits.session(user)` returns exactly what the kit renders.
|
|
63
|
+
|
|
64
|
+
## Calls
|
|
65
|
+
|
|
66
|
+
| Call | What it does |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `quote({ action, quantity })` | Price in credits and dollars, and what you keep after the network fee |
|
|
69
|
+
| `user(address)` | Balance, whether they connected you, the limit left, and what's spendable now |
|
|
70
|
+
| `charge({ user, action \| credits, ref })` | Charges now |
|
|
71
|
+
| `hold({ user, action \| credits, ref, ttlSeconds })` | Reserves credits for a job, price locked |
|
|
72
|
+
| `capture(holdId, { credits? })` | Charges all or part of a hold, returns the rest |
|
|
73
|
+
| `release(holdId)` | Returns the whole hold |
|
|
74
|
+
| `refund({ user, credits, ref })` | Gives credits back, up to what they spent with you |
|
|
75
|
+
| `setExchangeRate(rate)`, `convert({ user, credits })` | Light mode: sell your own credits for ours |
|
|
76
|
+
| `payout()` | Pays your unpaid earnings in USDT to your payout address |
|
|
77
|
+
| `activity()`, `exportCsv()`, `gas()` | Reporting. Gas is paid by the network, never by you or your users |
|
|
78
|
+
| `setWebhook(url)`, `webhookDeliveries()` | Signed event delivery |
|
|
79
|
+
| `connectUrl()`, `buyUrl()`, `accountUrl()` | Links to the hosted pages |
|
|
80
|
+
| `exchangeCode(code)`, `session(user)` | Connecting users, and state for the React kit |
|
|
81
|
+
|
|
82
|
+
`CreditsNetwork` reads public data with no key: `health()`, `reserves()`, `stats()`, `directory()`.
|
|
83
|
+
|
|
84
|
+
## Retries and errors
|
|
85
|
+
|
|
86
|
+
Every write carries a `ref`. Pass your own (an order or message id), or the SDK makes one. The same ref always returns the first result, so retrying after a timeout can never charge twice. The SDK retries network failures and rate limits for you.
|
|
87
|
+
|
|
88
|
+
Failures throw a `CreditsError`:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
try {
|
|
92
|
+
await credits.charge({ user, action: "chat.message", ref });
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (err instanceof CreditsError && err.isDeclined) {
|
|
95
|
+
// Over their limit or out of credits: show the buy or connect flow.
|
|
96
|
+
return redirect(credits.buyUrl({ returnTo }));
|
|
97
|
+
}
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
| Field | Meaning |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `code` | `OverLimit`, `InsufficientBalance`, `LimitExpired`, `rate_limited`, `DailyBudgetReached`, ... |
|
|
105
|
+
| `isDeclined` | The user can't pay this. Retrying won't help |
|
|
106
|
+
| `isRetryable` | A rate limit, gas budget pause or network failure. Try again later with the same ref |
|
|
107
|
+
|
|
108
|
+
## Webhooks
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import { verifyWebhook } from "kredo";
|
|
112
|
+
|
|
113
|
+
app.post("/credits-webhook", express.raw({ type: "application/json" }), (req, res) => {
|
|
114
|
+
const event = verifyWebhook(req.body, req.header("x-credits-signature"), process.env.CREDITS_WEBHOOK_SECRET!);
|
|
115
|
+
if (event.type === "hold.released") markJobRefunded(event.data.holdId);
|
|
116
|
+
res.sendStatus(200);
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Events: `charge.succeeded`, `hold.created`, `hold.captured`, `hold.released`, `refund.succeeded`, `connection.approved`, `connection.revoked`, `payout.paid`. Each is signed with HMAC-SHA256 over `timestamp.body`, and `verifyWebhook` rejects anything older than 5 minutes. Failed deliveries are retried with backoff.
|
|
121
|
+
|
|
122
|
+
## Try it
|
|
123
|
+
|
|
124
|
+
`npm run e2e -w kredo` runs the whole flow against a local network: register, set prices, connect, charge, hold and capture, release, refund, payout. It prints every transaction.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { ActivityItem, Amount, CreditsSession, AmountInput, AppInfo, Capture, Conversion, Credits as CreditAmount, GasReport, Hold, HoldStatus, Operation, Payout, PriceList, PriceListInput, Quote, UserState, WebhookDelivery } from "./types.js";
|
|
2
|
+
export type CreditsOptions = {
|
|
3
|
+
/** Your app's API key, `uc_live_...`. */
|
|
4
|
+
apiKey: string;
|
|
5
|
+
/** The network's API. Defaults to https://kredo-api.onrender.com. */
|
|
6
|
+
baseUrl?: string;
|
|
7
|
+
/** The network's hosted pages (connect, buy, wallet). Defaults to https://kredo-omega.vercel.app. */
|
|
8
|
+
pagesUrl?: string;
|
|
9
|
+
/** Extra tries after a network failure or a rate limit. Writes are safe to retry: each carries a ref. Default 2. */
|
|
10
|
+
retries?: number;
|
|
11
|
+
/** Per-request timeout. Default 60s, long enough for a transaction to confirm. */
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
fetch?: typeof fetch;
|
|
14
|
+
};
|
|
15
|
+
type Query = Record<string, string | number | undefined>;
|
|
16
|
+
export declare class Credits {
|
|
17
|
+
readonly baseUrl: string;
|
|
18
|
+
readonly pagesUrl: string;
|
|
19
|
+
private readonly apiKey;
|
|
20
|
+
private appId?;
|
|
21
|
+
private appInfo?;
|
|
22
|
+
private readonly retries;
|
|
23
|
+
private readonly timeoutMs;
|
|
24
|
+
private readonly fetchFn;
|
|
25
|
+
constructor(opts: CreditsOptions);
|
|
26
|
+
/** Your app: payout address, fee, unpaid earnings, prices and webhook. */
|
|
27
|
+
app(): Promise<AppInfo>;
|
|
28
|
+
prices(): Promise<PriceList>;
|
|
29
|
+
/** Replaces your price list, in credits: `{ "chat.message": { price: 2 }, "video.render": { rate: 8, unit: "second" } }`. */
|
|
30
|
+
setPrices(prices: PriceListInput): Promise<PriceList>;
|
|
31
|
+
/** What an action costs the user, and what you keep after the network fee. Show it before the job starts. */
|
|
32
|
+
quote(input: AmountInput): Promise<Quote>;
|
|
33
|
+
/** A user's balance, whether they've connected your app, and the most you can charge them now. */
|
|
34
|
+
user(address: string): Promise<UserState>;
|
|
35
|
+
/**
|
|
36
|
+
* Charges the user now. Pass your own `ref` (an order or message id) to make retries safe:
|
|
37
|
+
* the same ref always returns the first result. Throws a CreditsError with `isDeclined` when
|
|
38
|
+
* the user's limit or balance doesn't cover it.
|
|
39
|
+
*/
|
|
40
|
+
charge(input: {
|
|
41
|
+
user: string;
|
|
42
|
+
ref?: string;
|
|
43
|
+
} & AmountInput): Promise<Operation>;
|
|
44
|
+
/** Reserves credits at the start of a long job. The price is locked now. Capture or release it when the job ends. */
|
|
45
|
+
hold(input: {
|
|
46
|
+
user: string;
|
|
47
|
+
ref?: string;
|
|
48
|
+
ttlSeconds?: number;
|
|
49
|
+
} & AmountInput): Promise<Hold>;
|
|
50
|
+
getHold(holdId: string): Promise<HoldStatus>;
|
|
51
|
+
/** The job succeeded. Charges the whole hold, or only `credits` of it, and returns the rest to the user. */
|
|
52
|
+
capture(holdId: string, opts?: {
|
|
53
|
+
credits?: CreditAmount;
|
|
54
|
+
}): Promise<Capture>;
|
|
55
|
+
/** The job failed. Everything held goes back to the user. */
|
|
56
|
+
release(holdId: string): Promise<Operation>;
|
|
57
|
+
/** Gives credits back, up to what this user has spent with you. Comes out of your unpaid earnings. */
|
|
58
|
+
refund(input: {
|
|
59
|
+
user: string;
|
|
60
|
+
credits: CreditAmount;
|
|
61
|
+
ref?: string;
|
|
62
|
+
}): Promise<Operation>;
|
|
63
|
+
/** Light mode: how many of our credits buy one of yours. Pass null to turn it off. */
|
|
64
|
+
setExchangeRate(rate: CreditAmount | null): Promise<{
|
|
65
|
+
exchangeRate: string | null;
|
|
66
|
+
}>;
|
|
67
|
+
/** Light mode: spends `credits` of ours. Add the returned `appCredits` to the user's balance in your own system. */
|
|
68
|
+
convert(input: {
|
|
69
|
+
user: string;
|
|
70
|
+
credits: CreditAmount;
|
|
71
|
+
ref?: string;
|
|
72
|
+
}): Promise<Conversion>;
|
|
73
|
+
/**
|
|
74
|
+
* A link to the network's connect page. The user signs in with email, approves a spending
|
|
75
|
+
* limit for your app, and comes back to `returnTo`. Nobody can charge them until they do.
|
|
76
|
+
*/
|
|
77
|
+
connectUrl(opts: {
|
|
78
|
+
credits: CreditAmount;
|
|
79
|
+
returnTo?: string;
|
|
80
|
+
expiresInDays?: number;
|
|
81
|
+
}): Promise<string>;
|
|
82
|
+
/** A link to the buy page, for a user who is out of credits. */
|
|
83
|
+
buyUrl(opts?: {
|
|
84
|
+
credits?: CreditAmount;
|
|
85
|
+
returnTo?: string;
|
|
86
|
+
}): string;
|
|
87
|
+
/** The user's account page, where they see their balance and can disconnect any app. */
|
|
88
|
+
accountUrl(): string;
|
|
89
|
+
/** @deprecated Use accountUrl(). */
|
|
90
|
+
walletUrl(): string;
|
|
91
|
+
/**
|
|
92
|
+
* Swaps the one-time code from the connect page for the user's address and email. Call it from
|
|
93
|
+
* your server, then keep both in your own session. Never take an address from the browser.
|
|
94
|
+
*/
|
|
95
|
+
exchangeCode(code: string): Promise<{
|
|
96
|
+
user: string;
|
|
97
|
+
email: string | null;
|
|
98
|
+
limit: Amount;
|
|
99
|
+
}>;
|
|
100
|
+
/**
|
|
101
|
+
* What the React kit shows for one of your users: your app, and their balance and limit.
|
|
102
|
+
* Pass undefined for a visitor who hasn't connected yet, and the email from `exchangeCode()` to
|
|
103
|
+
* show it in the UI.
|
|
104
|
+
*/
|
|
105
|
+
session(user: string | undefined, opts?: {
|
|
106
|
+
email?: string | null;
|
|
107
|
+
}): Promise<CreditsSession>;
|
|
108
|
+
/** Pays all your unpaid earnings in USDT to your payout address, on Whitechain. */
|
|
109
|
+
payout(): Promise<Payout>;
|
|
110
|
+
/** Every vault event for your app, newest first. */
|
|
111
|
+
activity(opts?: {
|
|
112
|
+
limit?: number;
|
|
113
|
+
}): Promise<ActivityItem[]>;
|
|
114
|
+
/** Charges, holds, refunds and payouts as CSV text, for your accountant. */
|
|
115
|
+
exportCsv(): Promise<string>;
|
|
116
|
+
/** Gas the network spent on your transactions. You never pay it: the network fee covers it. */
|
|
117
|
+
gas(opts?: {
|
|
118
|
+
since?: Date;
|
|
119
|
+
}): Promise<GasReport>;
|
|
120
|
+
/**
|
|
121
|
+
* Sends every event about your app to `url` as a signed POST. Returns the signing secret once;
|
|
122
|
+
* check each delivery with `verifyWebhook`. Pass null to stop.
|
|
123
|
+
*/
|
|
124
|
+
setWebhook(url: string | null): Promise<{
|
|
125
|
+
url: string | null;
|
|
126
|
+
secret: string | null;
|
|
127
|
+
}>;
|
|
128
|
+
webhookDeliveries(): Promise<WebhookDelivery[]>;
|
|
129
|
+
private page;
|
|
130
|
+
/** @internal Shared by every call. Retries network failures and rate limits; throws CreditsError. */
|
|
131
|
+
request<T>(method: string, path: string, opts?: {
|
|
132
|
+
query?: Query;
|
|
133
|
+
body?: unknown;
|
|
134
|
+
raw?: boolean;
|
|
135
|
+
}): Promise<T>;
|
|
136
|
+
}
|
|
137
|
+
export {};
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server kit for a company backend. Keep the API key on your server, never in a browser.
|
|
3
|
+
*
|
|
4
|
+
* const credits = new Credits({ apiKey: process.env.CREDITS_API_KEY });
|
|
5
|
+
*
|
|
6
|
+
* await credits.charge({ user, action: "chat.message" });
|
|
7
|
+
*
|
|
8
|
+
* const hold = await credits.hold({ user, action: "video.render", quantity: 12 });
|
|
9
|
+
* // ...run the job...
|
|
10
|
+
* await credits.capture(hold.holdId); // or credits.release(hold.holdId)
|
|
11
|
+
*/
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { CreditsError } from "./errors.js";
|
|
14
|
+
/** Longest wait we'll sit through for a rate limit before giving up and throwing. */
|
|
15
|
+
const MAX_RETRY_WAIT_MS = 10_000;
|
|
16
|
+
export class Credits {
|
|
17
|
+
baseUrl;
|
|
18
|
+
pagesUrl;
|
|
19
|
+
apiKey;
|
|
20
|
+
appId;
|
|
21
|
+
appInfo;
|
|
22
|
+
retries;
|
|
23
|
+
timeoutMs;
|
|
24
|
+
fetchFn;
|
|
25
|
+
constructor(opts) {
|
|
26
|
+
if (!opts.apiKey)
|
|
27
|
+
throw new Error("Credits: apiKey is required");
|
|
28
|
+
this.apiKey = opts.apiKey;
|
|
29
|
+
this.baseUrl = (opts.baseUrl ?? "https://kredo-api.onrender.com").replace(/\/+$/, "");
|
|
30
|
+
this.pagesUrl = (opts.pagesUrl ?? "https://kredo-omega.vercel.app").replace(/\/+$/, "");
|
|
31
|
+
this.retries = opts.retries ?? 2;
|
|
32
|
+
this.timeoutMs = opts.timeoutMs ?? 60_000;
|
|
33
|
+
this.fetchFn = opts.fetch ?? globalThis.fetch;
|
|
34
|
+
}
|
|
35
|
+
// ---------------------------------------------------------------- your app
|
|
36
|
+
/** Your app: payout address, fee, unpaid earnings, prices and webhook. */
|
|
37
|
+
async app() {
|
|
38
|
+
const app = await this.request("GET", "/v1/app");
|
|
39
|
+
this.appId = app.id;
|
|
40
|
+
return app;
|
|
41
|
+
}
|
|
42
|
+
prices() {
|
|
43
|
+
return this.request("GET", "/v1/prices");
|
|
44
|
+
}
|
|
45
|
+
/** Replaces your price list, in credits: `{ "chat.message": { price: 2 }, "video.render": { rate: 8, unit: "second" } }`. */
|
|
46
|
+
setPrices(prices) {
|
|
47
|
+
return this.request("PUT", "/v1/prices", { body: { prices } });
|
|
48
|
+
}
|
|
49
|
+
// ---------------------------------------------------------------- prices and users
|
|
50
|
+
/** What an action costs the user, and what you keep after the network fee. Show it before the job starts. */
|
|
51
|
+
quote(input) {
|
|
52
|
+
return this.request("GET", "/v1/quote", { query: amountQuery(input) });
|
|
53
|
+
}
|
|
54
|
+
/** A user's balance, whether they've connected your app, and the most you can charge them now. */
|
|
55
|
+
user(address) {
|
|
56
|
+
return this.request("GET", `/v1/users/${encodeURIComponent(address)}`);
|
|
57
|
+
}
|
|
58
|
+
// ---------------------------------------------------------------- charging
|
|
59
|
+
/**
|
|
60
|
+
* Charges the user now. Pass your own `ref` (an order or message id) to make retries safe:
|
|
61
|
+
* the same ref always returns the first result. Throws a CreditsError with `isDeclined` when
|
|
62
|
+
* the user's limit or balance doesn't cover it.
|
|
63
|
+
*/
|
|
64
|
+
charge(input) {
|
|
65
|
+
return this.request("POST", "/v1/charges", { body: withRef(input) });
|
|
66
|
+
}
|
|
67
|
+
/** Reserves credits at the start of a long job. The price is locked now. Capture or release it when the job ends. */
|
|
68
|
+
hold(input) {
|
|
69
|
+
return this.request("POST", "/v1/holds", { body: withRef(input) });
|
|
70
|
+
}
|
|
71
|
+
getHold(holdId) {
|
|
72
|
+
return this.request("GET", `/v1/holds/${encodeURIComponent(holdId)}`);
|
|
73
|
+
}
|
|
74
|
+
/** The job succeeded. Charges the whole hold, or only `credits` of it, and returns the rest to the user. */
|
|
75
|
+
capture(holdId, opts = {}) {
|
|
76
|
+
return this.request("POST", `/v1/holds/${encodeURIComponent(holdId)}/capture`, { body: opts });
|
|
77
|
+
}
|
|
78
|
+
/** The job failed. Everything held goes back to the user. */
|
|
79
|
+
release(holdId) {
|
|
80
|
+
return this.request("POST", `/v1/holds/${encodeURIComponent(holdId)}/release`, { body: {} });
|
|
81
|
+
}
|
|
82
|
+
/** Gives credits back, up to what this user has spent with you. Comes out of your unpaid earnings. */
|
|
83
|
+
refund(input) {
|
|
84
|
+
return this.request("POST", "/v1/refunds", { body: withRef(input) });
|
|
85
|
+
}
|
|
86
|
+
// ---------------------------------------------------------------- light mode
|
|
87
|
+
/** Light mode: how many of our credits buy one of yours. Pass null to turn it off. */
|
|
88
|
+
setExchangeRate(rate) {
|
|
89
|
+
return this.request("PUT", "/v1/exchange-rate", { body: { rate } });
|
|
90
|
+
}
|
|
91
|
+
/** Light mode: spends `credits` of ours. Add the returned `appCredits` to the user's balance in your own system. */
|
|
92
|
+
convert(input) {
|
|
93
|
+
return this.request("POST", "/v1/convert", { body: withRef(input) });
|
|
94
|
+
}
|
|
95
|
+
// ---------------------------------------------------------------- connecting users
|
|
96
|
+
/**
|
|
97
|
+
* A link to the network's connect page. The user signs in with email, approves a spending
|
|
98
|
+
* limit for your app, and comes back to `returnTo`. Nobody can charge them until they do.
|
|
99
|
+
*/
|
|
100
|
+
async connectUrl(opts) {
|
|
101
|
+
const app = this.appId ?? (await this.app()).id;
|
|
102
|
+
return this.page("/connect", { app, credits: String(opts.credits), days: opts.expiresInDays, returnTo: opts.returnTo });
|
|
103
|
+
}
|
|
104
|
+
/** A link to the buy page, for a user who is out of credits. */
|
|
105
|
+
buyUrl(opts = {}) {
|
|
106
|
+
return this.page("/buy", { credits: opts.credits === undefined ? undefined : String(opts.credits), returnTo: opts.returnTo });
|
|
107
|
+
}
|
|
108
|
+
/** The user's account page, where they see their balance and can disconnect any app. */
|
|
109
|
+
accountUrl() {
|
|
110
|
+
return this.page("/account", {});
|
|
111
|
+
}
|
|
112
|
+
/** @deprecated Use accountUrl(). */
|
|
113
|
+
walletUrl() {
|
|
114
|
+
return this.accountUrl();
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Swaps the one-time code from the connect page for the user's address and email. Call it from
|
|
118
|
+
* your server, then keep both in your own session. Never take an address from the browser.
|
|
119
|
+
*/
|
|
120
|
+
exchangeCode(code) {
|
|
121
|
+
return this.request("POST", "/v1/connect/exchange", { body: { code } });
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* What the React kit shows for one of your users: your app, and their balance and limit.
|
|
125
|
+
* Pass undefined for a visitor who hasn't connected yet, and the email from `exchangeCode()` to
|
|
126
|
+
* show it in the UI.
|
|
127
|
+
*/
|
|
128
|
+
async session(user, opts = {}) {
|
|
129
|
+
const app = this.appInfo ?? (this.appInfo = await this.app().then((a) => ({ id: a.id, name: a.name })));
|
|
130
|
+
if (!user)
|
|
131
|
+
return { app, user: null };
|
|
132
|
+
const u = await this.user(user);
|
|
133
|
+
return {
|
|
134
|
+
app,
|
|
135
|
+
user: { address: u.user, email: opts.email ?? null, balance: u.balance, connected: u.connected, limit: u.limit, spendable: u.spendable },
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// ---------------------------------------------------------------- money out and reporting
|
|
139
|
+
/** Pays all your unpaid earnings in USDT to your payout address, on Whitechain. */
|
|
140
|
+
payout() {
|
|
141
|
+
return this.request("POST", "/v1/payout", { body: {} });
|
|
142
|
+
}
|
|
143
|
+
/** Every vault event for your app, newest first. */
|
|
144
|
+
activity(opts = {}) {
|
|
145
|
+
return this.request("GET", "/v1/activity", { query: { limit: opts.limit } });
|
|
146
|
+
}
|
|
147
|
+
/** Charges, holds, refunds and payouts as CSV text, for your accountant. */
|
|
148
|
+
exportCsv() {
|
|
149
|
+
return this.request("GET", "/v1/export.csv", { raw: true });
|
|
150
|
+
}
|
|
151
|
+
/** Gas the network spent on your transactions. You never pay it: the network fee covers it. */
|
|
152
|
+
gas(opts = {}) {
|
|
153
|
+
return this.request("GET", "/v1/gas", { query: { since: opts.since?.toISOString() } });
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Sends every event about your app to `url` as a signed POST. Returns the signing secret once;
|
|
157
|
+
* check each delivery with `verifyWebhook`. Pass null to stop.
|
|
158
|
+
*/
|
|
159
|
+
setWebhook(url) {
|
|
160
|
+
return this.request("PUT", "/v1/webhook", { body: { url } });
|
|
161
|
+
}
|
|
162
|
+
webhookDeliveries() {
|
|
163
|
+
return this.request("GET", "/v1/webhook/deliveries");
|
|
164
|
+
}
|
|
165
|
+
// ---------------------------------------------------------------- transport
|
|
166
|
+
page(path, params) {
|
|
167
|
+
const url = new URL(this.pagesUrl + path);
|
|
168
|
+
for (const [k, v] of Object.entries(params))
|
|
169
|
+
if (v !== undefined)
|
|
170
|
+
url.searchParams.set(k, String(v));
|
|
171
|
+
return url.toString();
|
|
172
|
+
}
|
|
173
|
+
/** @internal Shared by every call. Retries network failures and rate limits; throws CreditsError. */
|
|
174
|
+
async request(method, path, opts = {}) {
|
|
175
|
+
const url = new URL(this.baseUrl + path);
|
|
176
|
+
for (const [k, v] of Object.entries(opts.query ?? {}))
|
|
177
|
+
if (v !== undefined)
|
|
178
|
+
url.searchParams.set(k, String(v));
|
|
179
|
+
for (let attempt = 0;; attempt++) {
|
|
180
|
+
let res;
|
|
181
|
+
try {
|
|
182
|
+
res = await this.fetchFn(url, {
|
|
183
|
+
method,
|
|
184
|
+
headers: {
|
|
185
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
186
|
+
...(opts.body === undefined ? {} : { "content-type": "application/json" }),
|
|
187
|
+
},
|
|
188
|
+
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
|
189
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
if (attempt < this.retries) {
|
|
194
|
+
await sleep(backoff(attempt));
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
throw new CreditsError(`Couldn't reach ${this.baseUrl}: ${err.message}`, 0, "network");
|
|
198
|
+
}
|
|
199
|
+
if (res.status === 429 && attempt < this.retries) {
|
|
200
|
+
const wait = Number(res.headers.get("retry-after")) * 1000 || backoff(attempt);
|
|
201
|
+
if (wait <= MAX_RETRY_WAIT_MS) {
|
|
202
|
+
await sleep(wait);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (opts.raw && res.ok)
|
|
207
|
+
return (await res.text());
|
|
208
|
+
const text = await res.text();
|
|
209
|
+
const data = text ? safeJson(text) : undefined;
|
|
210
|
+
if (!res.ok) {
|
|
211
|
+
const d = (data ?? {});
|
|
212
|
+
throw new CreditsError(d.error ?? `HTTP ${res.status}`, res.status, d.code ?? "error", d.txHash);
|
|
213
|
+
}
|
|
214
|
+
return data;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function amountQuery(input) {
|
|
219
|
+
return {
|
|
220
|
+
action: input.action,
|
|
221
|
+
quantity: input.quantity === undefined ? undefined : String(input.quantity),
|
|
222
|
+
credits: input.credits === undefined ? undefined : String(input.credits),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/** Every write carries a ref, so a retry after a dropped connection can never charge twice. */
|
|
226
|
+
function withRef(input) {
|
|
227
|
+
return { ...input, ref: input.ref ?? randomUUID() };
|
|
228
|
+
}
|
|
229
|
+
function safeJson(text) {
|
|
230
|
+
try {
|
|
231
|
+
return JSON.parse(text);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return { error: text };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const backoff = (attempt) => 250 * 2 ** attempt;
|
|
238
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare class CreditsError extends Error {
|
|
2
|
+
/** HTTP status, or 0 when the network failed before any response. */
|
|
3
|
+
readonly status: number;
|
|
4
|
+
/** Machine-readable code, like "OverLimit", "rate_limited" or "DailyBudgetReached". */
|
|
5
|
+
readonly code: string;
|
|
6
|
+
readonly txHash?: string | undefined;
|
|
7
|
+
name: string;
|
|
8
|
+
constructor(message: string,
|
|
9
|
+
/** HTTP status, or 0 when the network failed before any response. */
|
|
10
|
+
status: number,
|
|
11
|
+
/** Machine-readable code, like "OverLimit", "rate_limited" or "DailyBudgetReached". */
|
|
12
|
+
code: string, txHash?: string | undefined);
|
|
13
|
+
/** The user can't pay this: over their limit, out of credits, or the app is off. Show them the buy or connect flow. */
|
|
14
|
+
get isDeclined(): boolean;
|
|
15
|
+
/** Safe to try again later with the same ref: rate limits, gas budget pauses and network failures. */
|
|
16
|
+
get isRetryable(): boolean;
|
|
17
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Contract refusals: the user's limit, balance or the app's settings said no. Retrying won't help. */
|
|
2
|
+
const DECLINED = new Set([
|
|
3
|
+
"OverLimit",
|
|
4
|
+
"LimitExpired",
|
|
5
|
+
"InsufficientBalance",
|
|
6
|
+
"OverMaxCharge",
|
|
7
|
+
"AppInactive",
|
|
8
|
+
"OverHeld",
|
|
9
|
+
"OverSpent",
|
|
10
|
+
"HoldExpired",
|
|
11
|
+
]);
|
|
12
|
+
export class CreditsError extends Error {
|
|
13
|
+
status;
|
|
14
|
+
code;
|
|
15
|
+
txHash;
|
|
16
|
+
name = "CreditsError";
|
|
17
|
+
constructor(message,
|
|
18
|
+
/** HTTP status, or 0 when the network failed before any response. */
|
|
19
|
+
status,
|
|
20
|
+
/** Machine-readable code, like "OverLimit", "rate_limited" or "DailyBudgetReached". */
|
|
21
|
+
code, txHash) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.status = status;
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.txHash = txHash;
|
|
26
|
+
}
|
|
27
|
+
/** The user can't pay this: over their limit, out of credits, or the app is off. Show them the buy or connect flow. */
|
|
28
|
+
get isDeclined() {
|
|
29
|
+
return DECLINED.has(this.code);
|
|
30
|
+
}
|
|
31
|
+
/** Safe to try again later with the same ref: rate limits, gas budget pauses and network failures. */
|
|
32
|
+
get isRetryable() {
|
|
33
|
+
return this.status === 0 || this.status === 429 || this.status === 502 || this.status === 503;
|
|
34
|
+
}
|
|
35
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { Credits, type CreditsOptions } from "./client.js";
|
|
2
|
+
export { CreditsError } from "./errors.js";
|
|
3
|
+
export { CreditsNetwork, type ClaimPreview, type Health } from "./network.js";
|
|
4
|
+
export { verifyWebhook, WebhookSignatureError } from "./webhooks.js";
|
|
5
|
+
export type * from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Amount, DirectoryEntry, NetworkStats, Reserves } from "./types.js";
|
|
2
|
+
export type Health = {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
chainId: number;
|
|
5
|
+
block: number;
|
|
6
|
+
vault: string;
|
|
7
|
+
usdt: string;
|
|
8
|
+
relayer: {
|
|
9
|
+
address: string;
|
|
10
|
+
balance: string;
|
|
11
|
+
symbol: string;
|
|
12
|
+
};
|
|
13
|
+
login: "privy" | "disabled";
|
|
14
|
+
indexer: {
|
|
15
|
+
block: number;
|
|
16
|
+
head: number;
|
|
17
|
+
lag: number;
|
|
18
|
+
backed: boolean;
|
|
19
|
+
events: number;
|
|
20
|
+
pendingWebhooks: number;
|
|
21
|
+
error: string | null;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
export type ClaimPreview = {
|
|
25
|
+
claimKey: string;
|
|
26
|
+
amount: Amount;
|
|
27
|
+
status: "open" | "claimed" | "cancelled" | "expired";
|
|
28
|
+
expiresAt: string;
|
|
29
|
+
};
|
|
30
|
+
export declare class CreditsNetwork {
|
|
31
|
+
readonly baseUrl: string;
|
|
32
|
+
private readonly fetchFn;
|
|
33
|
+
constructor(opts?: {
|
|
34
|
+
baseUrl?: string;
|
|
35
|
+
fetch?: typeof fetch;
|
|
36
|
+
});
|
|
37
|
+
health(): Promise<Health>;
|
|
38
|
+
/** The vault's USDT next to everything it owes. `backed` is checked onchain on every call. */
|
|
39
|
+
reserves(): Promise<Reserves>;
|
|
40
|
+
stats(): Promise<NetworkStats>;
|
|
41
|
+
/** Every app that accepts credits, with its prices. */
|
|
42
|
+
directory(): Promise<DirectoryEntry[]>;
|
|
43
|
+
claim(claimKey: string): Promise<ClaimPreview>;
|
|
44
|
+
private get;
|
|
45
|
+
}
|
package/dist/network.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Calls anyone can make, with no API key: health, live reserves, network stats and the app directory. */
|
|
2
|
+
import { CreditsError } from "./errors.js";
|
|
3
|
+
export class CreditsNetwork {
|
|
4
|
+
baseUrl;
|
|
5
|
+
fetchFn;
|
|
6
|
+
constructor(opts = {}) {
|
|
7
|
+
this.baseUrl = (opts.baseUrl ?? "https://kredo-api.onrender.com").replace(/\/+$/, "");
|
|
8
|
+
this.fetchFn = opts.fetch ?? globalThis.fetch;
|
|
9
|
+
}
|
|
10
|
+
health() {
|
|
11
|
+
return this.get("/v1/health");
|
|
12
|
+
}
|
|
13
|
+
/** The vault's USDT next to everything it owes. `backed` is checked onchain on every call. */
|
|
14
|
+
reserves() {
|
|
15
|
+
return this.get("/v1/reserves");
|
|
16
|
+
}
|
|
17
|
+
stats() {
|
|
18
|
+
return this.get("/v1/stats");
|
|
19
|
+
}
|
|
20
|
+
/** Every app that accepts credits, with its prices. */
|
|
21
|
+
directory() {
|
|
22
|
+
return this.get("/v1/directory");
|
|
23
|
+
}
|
|
24
|
+
claim(claimKey) {
|
|
25
|
+
return this.get(`/v1/claims/${encodeURIComponent(claimKey)}`);
|
|
26
|
+
}
|
|
27
|
+
async get(path) {
|
|
28
|
+
let res;
|
|
29
|
+
try {
|
|
30
|
+
res = await this.fetchFn(this.baseUrl + path, { signal: AbortSignal.timeout(15_000) });
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
throw new CreditsError(`Couldn't reach ${this.baseUrl}: ${err.message}`, 0, "network");
|
|
34
|
+
}
|
|
35
|
+
const data = (await res.json().catch(() => ({})));
|
|
36
|
+
if (!res.ok)
|
|
37
|
+
throw new CreditsError(data.error ?? `HTTP ${res.status}`, res.status, data.code ?? "error");
|
|
38
|
+
return data;
|
|
39
|
+
}
|
|
40
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/** Every amount comes back exact, in USDT units, with both display forms. 1 credit = $0.01. */
|
|
2
|
+
export type Amount = {
|
|
3
|
+
units: string;
|
|
4
|
+
credits: string;
|
|
5
|
+
usd: string;
|
|
6
|
+
};
|
|
7
|
+
/** Credits as a number or an exact decimal string, like 2 or "0.5". */
|
|
8
|
+
export type Credits = number | string;
|
|
9
|
+
/** What to charge: an action from your price list (times `quantity` for per-unit prices), or credits. */
|
|
10
|
+
export type AmountInput = {
|
|
11
|
+
action: string;
|
|
12
|
+
quantity?: number | string;
|
|
13
|
+
credits?: never;
|
|
14
|
+
} | {
|
|
15
|
+
credits: Credits;
|
|
16
|
+
action?: never;
|
|
17
|
+
quantity?: never;
|
|
18
|
+
};
|
|
19
|
+
export type TxInfo = {
|
|
20
|
+
txHash: string;
|
|
21
|
+
explorer?: string;
|
|
22
|
+
};
|
|
23
|
+
export type Quote = {
|
|
24
|
+
label: string;
|
|
25
|
+
price: Amount;
|
|
26
|
+
networkFee: Amount;
|
|
27
|
+
youEarn: Amount;
|
|
28
|
+
};
|
|
29
|
+
export type Operation = TxInfo & {
|
|
30
|
+
kind: "charge" | "hold" | "capture" | "release" | "refund" | "payout" | "convert";
|
|
31
|
+
ref?: string;
|
|
32
|
+
user?: string;
|
|
33
|
+
amount: Amount;
|
|
34
|
+
fee?: Amount;
|
|
35
|
+
label?: string;
|
|
36
|
+
holdId?: string;
|
|
37
|
+
at: string;
|
|
38
|
+
/** True when this ref was already used, and this is the first result again. Nothing was charged twice. */
|
|
39
|
+
replayed?: boolean;
|
|
40
|
+
};
|
|
41
|
+
export type Hold = Operation & {
|
|
42
|
+
kind: "hold";
|
|
43
|
+
holdId: string;
|
|
44
|
+
expiresAt: string;
|
|
45
|
+
};
|
|
46
|
+
export type HoldStatus = TxInfo & {
|
|
47
|
+
holdId: string;
|
|
48
|
+
user: string;
|
|
49
|
+
amount: Amount;
|
|
50
|
+
label: string;
|
|
51
|
+
status: "open" | "captured" | "released";
|
|
52
|
+
expiresAt: string;
|
|
53
|
+
};
|
|
54
|
+
export type Capture = Operation & {
|
|
55
|
+
kind: "capture";
|
|
56
|
+
returned: Amount;
|
|
57
|
+
};
|
|
58
|
+
export type UserState = {
|
|
59
|
+
user: string;
|
|
60
|
+
balance: Amount;
|
|
61
|
+
/** The user approved this app and the limit isn't used up or expired. */
|
|
62
|
+
connected: boolean;
|
|
63
|
+
/** What this app may still charge. */
|
|
64
|
+
limit: Amount;
|
|
65
|
+
limitExpiresAt: string | null;
|
|
66
|
+
/** The smaller of balance and limit: the most a charge can be right now. */
|
|
67
|
+
spendable: Amount;
|
|
68
|
+
};
|
|
69
|
+
/** Flat price, or a per-unit rate that's multiplied by `quantity`. In credits. */
|
|
70
|
+
export type PriceEntry = {
|
|
71
|
+
price: string;
|
|
72
|
+
} | {
|
|
73
|
+
rate: string;
|
|
74
|
+
unit: string;
|
|
75
|
+
};
|
|
76
|
+
export type PriceList = Record<string, PriceEntry>;
|
|
77
|
+
export type PriceListInput = Record<string, {
|
|
78
|
+
price: Credits;
|
|
79
|
+
} | {
|
|
80
|
+
rate: Credits;
|
|
81
|
+
unit: string;
|
|
82
|
+
}>;
|
|
83
|
+
export type AppInfo = {
|
|
84
|
+
id: string;
|
|
85
|
+
name: string;
|
|
86
|
+
payout: string;
|
|
87
|
+
feeBps: number;
|
|
88
|
+
maxCharge: Amount | null;
|
|
89
|
+
active: boolean;
|
|
90
|
+
unpaidEarnings: Amount;
|
|
91
|
+
prices: PriceList;
|
|
92
|
+
exchangeRate: string | null;
|
|
93
|
+
webhookUrl: string | null;
|
|
94
|
+
apiKeyPrefix: string;
|
|
95
|
+
};
|
|
96
|
+
export type Conversion = Operation & {
|
|
97
|
+
kind: "convert";
|
|
98
|
+
appCredits: string;
|
|
99
|
+
};
|
|
100
|
+
export type Payout = Operation & {
|
|
101
|
+
kind: "payout";
|
|
102
|
+
to: string;
|
|
103
|
+
usdt: string;
|
|
104
|
+
};
|
|
105
|
+
export type GasReport = {
|
|
106
|
+
transactions: number;
|
|
107
|
+
wei: string;
|
|
108
|
+
gas: string;
|
|
109
|
+
symbol: string;
|
|
110
|
+
paidBy: "network";
|
|
111
|
+
};
|
|
112
|
+
/** One vault event, newest first in every feed. */
|
|
113
|
+
export type ActivityItem = {
|
|
114
|
+
id: string;
|
|
115
|
+
type: string;
|
|
116
|
+
at: string;
|
|
117
|
+
user?: string;
|
|
118
|
+
app?: string;
|
|
119
|
+
appName?: string;
|
|
120
|
+
from?: string;
|
|
121
|
+
to?: string;
|
|
122
|
+
amount?: Amount;
|
|
123
|
+
fee?: Amount;
|
|
124
|
+
returned?: Amount;
|
|
125
|
+
ref?: string;
|
|
126
|
+
label?: string;
|
|
127
|
+
holdId?: string;
|
|
128
|
+
claimKey?: string;
|
|
129
|
+
block: number;
|
|
130
|
+
txHash: string;
|
|
131
|
+
explorer?: string;
|
|
132
|
+
};
|
|
133
|
+
export type WebhookDelivery = {
|
|
134
|
+
id: string;
|
|
135
|
+
type: string;
|
|
136
|
+
status: "pending" | "delivered" | "failed";
|
|
137
|
+
attempts: number;
|
|
138
|
+
lastStatus: number | null;
|
|
139
|
+
lastError: string | null;
|
|
140
|
+
};
|
|
141
|
+
export type WebhookEvent = {
|
|
142
|
+
id: string;
|
|
143
|
+
type: "charge.succeeded" | "hold.created" | "hold.captured" | "hold.released" | "refund.succeeded" | "connection.approved" | "connection.revoked" | "payout.paid";
|
|
144
|
+
createdAt: string;
|
|
145
|
+
data: ActivityItem;
|
|
146
|
+
};
|
|
147
|
+
export type Reserves = {
|
|
148
|
+
backed: boolean;
|
|
149
|
+
usdtInVault: Amount;
|
|
150
|
+
owed: Amount;
|
|
151
|
+
surplus: Amount;
|
|
152
|
+
breakdown: Record<"userBalances" | "held" | "claimLinks" | "companyEarnings" | "networkFees", Amount>;
|
|
153
|
+
vault: string;
|
|
154
|
+
explorer?: string;
|
|
155
|
+
};
|
|
156
|
+
export type NetworkStats = {
|
|
157
|
+
apps: number;
|
|
158
|
+
users: number;
|
|
159
|
+
events: number;
|
|
160
|
+
deposited: Amount;
|
|
161
|
+
spent: Amount;
|
|
162
|
+
networkFees: Amount;
|
|
163
|
+
refunded: Amount;
|
|
164
|
+
paidOut: Amount;
|
|
165
|
+
indexedToBlock: number;
|
|
166
|
+
};
|
|
167
|
+
export type DirectoryEntry = {
|
|
168
|
+
id: string;
|
|
169
|
+
name: string;
|
|
170
|
+
prices: PriceList;
|
|
171
|
+
exchangeRate: string | null;
|
|
172
|
+
};
|
|
173
|
+
/** The state the React kit renders, as your backend returns it from `credits.session()`. */
|
|
174
|
+
export type CreditsSession = {
|
|
175
|
+
app: {
|
|
176
|
+
id: string;
|
|
177
|
+
name: string;
|
|
178
|
+
};
|
|
179
|
+
user: {
|
|
180
|
+
address: string;
|
|
181
|
+
email: string | null;
|
|
182
|
+
balance: Amount;
|
|
183
|
+
connected: boolean;
|
|
184
|
+
limit: Amount;
|
|
185
|
+
spendable: Amount;
|
|
186
|
+
} | null;
|
|
187
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { WebhookEvent } from "./types.js";
|
|
2
|
+
export declare class WebhookSignatureError extends Error {
|
|
3
|
+
name: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function verifyWebhook(rawBody: string | Buffer, signatureHeader: string | null | undefined, secret: string, opts?: {
|
|
6
|
+
toleranceSeconds?: number;
|
|
7
|
+
now?: number;
|
|
8
|
+
}): WebhookEvent;
|
package/dist/webhooks.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks a webhook really came from the network, then parses it.
|
|
3
|
+
*
|
|
4
|
+
* app.post("/credits-webhook", express.raw({ type: "application/json" }), (req, res) => {
|
|
5
|
+
* const event = verifyWebhook(req.body, req.header("x-credits-signature"), process.env.CREDITS_WEBHOOK_SECRET);
|
|
6
|
+
* if (event.type === "hold.released") refundTheJob(event.data.holdId);
|
|
7
|
+
* res.sendStatus(200);
|
|
8
|
+
* });
|
|
9
|
+
*
|
|
10
|
+
* Pass the raw body, exactly as received: re-serialised JSON won't match the signature.
|
|
11
|
+
*/
|
|
12
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
13
|
+
export class WebhookSignatureError extends Error {
|
|
14
|
+
name = "WebhookSignatureError";
|
|
15
|
+
}
|
|
16
|
+
/** Rejects deliveries signed more than 5 minutes ago, so a captured request can't be replayed later. */
|
|
17
|
+
const DEFAULT_TOLERANCE_SECONDS = 300;
|
|
18
|
+
export function verifyWebhook(rawBody, signatureHeader, secret, opts = {}) {
|
|
19
|
+
if (!signatureHeader)
|
|
20
|
+
throw new WebhookSignatureError("Missing x-credits-signature header");
|
|
21
|
+
const parts = Object.fromEntries(signatureHeader.split(",").map((p) => {
|
|
22
|
+
const i = p.indexOf("=");
|
|
23
|
+
return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
|
|
24
|
+
}));
|
|
25
|
+
const t = Number(parts.t);
|
|
26
|
+
if (!Number.isInteger(t) || !parts.v1)
|
|
27
|
+
throw new WebhookSignatureError("Malformed x-credits-signature header");
|
|
28
|
+
const now = opts.now ?? Math.floor(Date.now() / 1000);
|
|
29
|
+
if (Math.abs(now - t) > (opts.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS)) {
|
|
30
|
+
throw new WebhookSignatureError("Webhook timestamp is too old");
|
|
31
|
+
}
|
|
32
|
+
const body = typeof rawBody === "string" ? rawBody : rawBody.toString("utf8");
|
|
33
|
+
const expected = createHmac("sha256", secret).update(`${t}.${body}`).digest();
|
|
34
|
+
const given = Buffer.from(parts.v1, "hex");
|
|
35
|
+
if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
|
|
36
|
+
throw new WebhookSignatureError("Webhook signature doesn't match");
|
|
37
|
+
}
|
|
38
|
+
return JSON.parse(body);
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kredo",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Server kit for charging Kredo credits from a company backend",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://kredo-omega.vercel.app/docs/sdk",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"kredo",
|
|
9
|
+
"credits",
|
|
10
|
+
"payments",
|
|
11
|
+
"usdt",
|
|
12
|
+
"whitechain",
|
|
13
|
+
"sdk"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "dist/index.js",
|
|
17
|
+
"types": "dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc -p tsconfig.build.json",
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"test": "node --import tsx --test \"test/**/*.test.ts\"",
|
|
34
|
+
"e2e": "tsx examples/e2e.ts"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^20.19.43",
|
|
38
|
+
"tsx": "^4.23.15",
|
|
39
|
+
"typescript": "^5.9.3",
|
|
40
|
+
"viem": "^2.57.16"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
}
|
|
45
|
+
}
|