@bot403/node 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 +46 -0
- package/dist/cjs/index.d.ts +137 -0
- package/dist/cjs/index.js +154 -0
- package/dist/cjs/package.json +1 -0
- package/dist/esm/index.d.ts +137 -0
- package/dist/esm/index.js +148 -0
- package/package.json +62 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zahary Stoyanov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# @bot403/node
|
|
2
|
+
|
|
3
|
+
Bot403 is an invisible bot shield for HTML forms: one script tag makes the browser solve a small proof of work, and your server checks it with one call.
|
|
4
|
+
|
|
5
|
+
This package is that one call, typed and with no dependencies. It runs wherever `fetch` exists: Node.js 18+, Bun, Deno, Cloudflare Workers, Vercel and Next.js route handlers.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @bot403/node
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { Bot403 } from "@bot403/node";
|
|
13
|
+
|
|
14
|
+
const bot403 = new Bot403({ secretKey: process.env.BOT403_SECRET_KEY! });
|
|
15
|
+
|
|
16
|
+
// In your form handler:
|
|
17
|
+
const result = await bot403.verifyForm(await request.formData());
|
|
18
|
+
if (!result.ok) {
|
|
19
|
+
// result.reason: expired, replay, invalid, wrong_site, honeypot, quota or automation
|
|
20
|
+
return new Response("Spam check failed", { status: 400 });
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The same check with curl:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
curl -X POST https://bot403.app/api/v1/verify \
|
|
28
|
+
-H "Authorization: Bearer sk_live_..." \
|
|
29
|
+
-H "Content-Type: application/json" \
|
|
30
|
+
-d '{"solution":"<the _bot403 field from the form>"}'
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## API
|
|
34
|
+
|
|
35
|
+
- `new Bot403({ secretKey, baseUrl?, fetch?, timeoutMs? })`: the secret key is the site's `sk_live_…` key. Keep it on the server.
|
|
36
|
+
- `verify(token)`: checks one token. Resolves with a `VerifyResult`: `ok`, and a `reason` when it failed. A failed token is an answer, not an error.
|
|
37
|
+
- `verifyForm(form)`: takes `FormData`, `URLSearchParams` or a parsed body object and sends every field, so a honeypot field set on the site is checked too.
|
|
38
|
+
- `stats()`: the site's statistics for its window, with quota usage.
|
|
39
|
+
- `tokenFrom(form)`: reads the token (`_bot403`, or `altcha` from the Altcha web component).
|
|
40
|
+
- `Bot403Error`: thrown only when the request itself fails: a wrong secret key (401), too many verify calls (429), or a network failure (`status` 0).
|
|
41
|
+
|
|
42
|
+
A `quota` reason comes with HTTP 402 and only in a capped month; the package returns it as a normal result, and you decide what the form does.
|
|
43
|
+
|
|
44
|
+
In the browser, use [`@bot403/browser`](https://www.npmjs.com/package/@bot403/browser) or [`@bot403/react`](https://www.npmjs.com/package/@bot403/react), or just the script tag.
|
|
45
|
+
|
|
46
|
+
Docs: https://bot403.app/docs/javascript · every failure reason: https://bot403.app/docs/verify
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bot403 on the server: verify the token a protected form sends, and read
|
|
3
|
+
* your site's statistics. Works wherever `fetch` exists: Node.js 18+, Bun,
|
|
4
|
+
* Deno, Cloudflare Workers, Vercel and Next.js route handlers.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* import { Bot403 } from "@bot403/node";
|
|
8
|
+
* const bot403 = new Bot403({ secretKey: process.env.BOT403_SECRET_KEY! });
|
|
9
|
+
* const result = await bot403.verifyForm(await request.formData());
|
|
10
|
+
* if (!result.ok) return new Response("Spam check failed", { status: 400 });
|
|
11
|
+
*
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
*/
|
|
14
|
+
/** The hidden form field the browser script fills with the token. */
|
|
15
|
+
export declare const FIELD = "_bot403";
|
|
16
|
+
/** Where the API lives unless you pass `baseUrl`. */
|
|
17
|
+
export declare const DEFAULT_BASE_URL = "https://bot403.app";
|
|
18
|
+
/** Why a token failed. `quota` comes with HTTP 402; the rest with 200. */
|
|
19
|
+
export type FailureReason = "expired" | "replay" | "invalid" | "wrong_site" | "quota" | "honeypot" | "automation";
|
|
20
|
+
/** Automation signals, present when they are on for the site. */
|
|
21
|
+
export interface Automation {
|
|
22
|
+
/** `clean` means nothing was found, which is not a claim that a person is there. */
|
|
23
|
+
verdict: "clean" | "suspect" | "automated";
|
|
24
|
+
score: number;
|
|
25
|
+
reasons: string[];
|
|
26
|
+
/** `log` only reports; `enforce` also fails the token with reason `automation`. */
|
|
27
|
+
mode: "log" | "enforce";
|
|
28
|
+
}
|
|
29
|
+
/** The answer of `POST /api/v1/verify`. */
|
|
30
|
+
export interface VerifyResult {
|
|
31
|
+
ok: boolean;
|
|
32
|
+
/** The site's public key. */
|
|
33
|
+
site: string;
|
|
34
|
+
/** Set when `ok` is false. */
|
|
35
|
+
reason?: FailureReason;
|
|
36
|
+
solved_at?: string;
|
|
37
|
+
/** The hostname the challenge was requested from. */
|
|
38
|
+
origin?: string;
|
|
39
|
+
difficulty?: string;
|
|
40
|
+
automation?: Automation;
|
|
41
|
+
}
|
|
42
|
+
/** The answer of `GET /api/v1/stats`. */
|
|
43
|
+
export interface Stats {
|
|
44
|
+
site: string;
|
|
45
|
+
window_days: number;
|
|
46
|
+
totals: {
|
|
47
|
+
challenges: number;
|
|
48
|
+
solved: number;
|
|
49
|
+
verify_ok: number;
|
|
50
|
+
verify_fail: number;
|
|
51
|
+
rate_limited: number;
|
|
52
|
+
fail: Record<FailureReason, number>;
|
|
53
|
+
automated: number;
|
|
54
|
+
suspect: number;
|
|
55
|
+
};
|
|
56
|
+
days: {
|
|
57
|
+
day: string;
|
|
58
|
+
challenges: number;
|
|
59
|
+
verify_ok: number;
|
|
60
|
+
verify_fail: number;
|
|
61
|
+
}[];
|
|
62
|
+
series: number[];
|
|
63
|
+
origins: {
|
|
64
|
+
origin: string;
|
|
65
|
+
count: number;
|
|
66
|
+
}[];
|
|
67
|
+
recent: {
|
|
68
|
+
at: string;
|
|
69
|
+
kind: string;
|
|
70
|
+
reason?: string;
|
|
71
|
+
origin: string;
|
|
72
|
+
difficulty: string;
|
|
73
|
+
ip_prefix: string;
|
|
74
|
+
automation?: string;
|
|
75
|
+
automation_reasons?: string[];
|
|
76
|
+
}[];
|
|
77
|
+
quota: {
|
|
78
|
+
plan: string;
|
|
79
|
+
used: number;
|
|
80
|
+
limit: number;
|
|
81
|
+
/** Where verify starts answering 402; -1 when there is no cap this month. */
|
|
82
|
+
cap: number;
|
|
83
|
+
capped: boolean;
|
|
84
|
+
over_last_month: boolean;
|
|
85
|
+
resets_at: string;
|
|
86
|
+
};
|
|
87
|
+
automation_signals: {
|
|
88
|
+
reason: string;
|
|
89
|
+
count: number;
|
|
90
|
+
}[];
|
|
91
|
+
}
|
|
92
|
+
export interface Bot403Options {
|
|
93
|
+
/** The site's secret key, `sk_live_…`. Keep it on the server. */
|
|
94
|
+
secretKey: string;
|
|
95
|
+
/** Defaults to https://bot403.app. */
|
|
96
|
+
baseUrl?: string;
|
|
97
|
+
/** Your own fetch, for tests or older runtimes. Defaults to the global fetch. */
|
|
98
|
+
fetch?: typeof fetch;
|
|
99
|
+
/** Abort a request after this many milliseconds. Defaults to 10000. */
|
|
100
|
+
timeoutMs?: number;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* A request the API refused as a whole: a wrong secret key (401), too many
|
|
104
|
+
* verify calls (429), a body over 64 KB (413), or a network failure
|
|
105
|
+
* (`status` 0). A token that fails is not an error: it is a
|
|
106
|
+
* {@link VerifyResult} with `ok: false`.
|
|
107
|
+
*/
|
|
108
|
+
export declare class Bot403Error extends Error {
|
|
109
|
+
/** HTTP status, or 0 when the request did not complete. */
|
|
110
|
+
readonly status: number;
|
|
111
|
+
/** `unauthorized`, `rate_limited`, `too_large`, `invalid`, `network` or `http_<status>`. */
|
|
112
|
+
readonly code: string;
|
|
113
|
+
constructor(message: string, status: number, code: string);
|
|
114
|
+
}
|
|
115
|
+
/** Anything a form body can arrive as. */
|
|
116
|
+
export type FormBody = FormData | URLSearchParams | Record<string, unknown>;
|
|
117
|
+
/**
|
|
118
|
+
* Reads the token from a submitted form: the `_bot403` field, or `altcha`
|
|
119
|
+
* when the Altcha web component was used.
|
|
120
|
+
*/
|
|
121
|
+
export declare function tokenFrom(body: FormBody): string | undefined;
|
|
122
|
+
export declare class Bot403 {
|
|
123
|
+
#private;
|
|
124
|
+
constructor(options: Bot403Options);
|
|
125
|
+
/**
|
|
126
|
+
* Checks one token. Resolves with `ok: true`, or `ok: false` and a reason;
|
|
127
|
+
* rejects with {@link Bot403Error} only when the request itself fails.
|
|
128
|
+
*/
|
|
129
|
+
verify(token: string | null | undefined): Promise<VerifyResult>;
|
|
130
|
+
/**
|
|
131
|
+
* Checks the token inside a submitted form. The whole form is sent, so a
|
|
132
|
+
* honeypot field set on the site is checked too.
|
|
133
|
+
*/
|
|
134
|
+
verifyForm(form: FormBody): Promise<VerifyResult>;
|
|
135
|
+
/** The site's statistics for its window, including quota usage. */
|
|
136
|
+
stats(): Promise<Stats>;
|
|
137
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Bot403 on the server: verify the token a protected form sends, and read
|
|
4
|
+
* your site's statistics. Works wherever `fetch` exists: Node.js 18+, Bun,
|
|
5
|
+
* Deno, Cloudflare Workers, Vercel and Next.js route handlers.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* import { Bot403 } from "@bot403/node";
|
|
9
|
+
* const bot403 = new Bot403({ secretKey: process.env.BOT403_SECRET_KEY! });
|
|
10
|
+
* const result = await bot403.verifyForm(await request.formData());
|
|
11
|
+
* if (!result.ok) return new Response("Spam check failed", { status: 400 });
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
|
|
16
|
+
if (kind === "m") throw new TypeError("Private method is not writable");
|
|
17
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
|
|
18
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
|
19
|
+
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
20
|
+
};
|
|
21
|
+
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
22
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
23
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
24
|
+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
25
|
+
};
|
|
26
|
+
var _Bot403_instances, _Bot403_secretKey, _Bot403_baseUrl, _Bot403_fetch, _Bot403_timeoutMs, _Bot403_verifyBody, _Bot403_request;
|
|
27
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
|
+
exports.Bot403 = exports.Bot403Error = exports.DEFAULT_BASE_URL = exports.FIELD = void 0;
|
|
29
|
+
exports.tokenFrom = tokenFrom;
|
|
30
|
+
/** The hidden form field the browser script fills with the token. */
|
|
31
|
+
exports.FIELD = "_bot403";
|
|
32
|
+
/** Where the API lives unless you pass `baseUrl`. */
|
|
33
|
+
exports.DEFAULT_BASE_URL = "https://bot403.app";
|
|
34
|
+
/**
|
|
35
|
+
* A request the API refused as a whole: a wrong secret key (401), too many
|
|
36
|
+
* verify calls (429), a body over 64 KB (413), or a network failure
|
|
37
|
+
* (`status` 0). A token that fails is not an error: it is a
|
|
38
|
+
* {@link VerifyResult} with `ok: false`.
|
|
39
|
+
*/
|
|
40
|
+
class Bot403Error extends Error {
|
|
41
|
+
constructor(message, status, code) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = "Bot403Error";
|
|
44
|
+
this.status = status;
|
|
45
|
+
this.code = code;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
exports.Bot403Error = Bot403Error;
|
|
49
|
+
/**
|
|
50
|
+
* Reads the token from a submitted form: the `_bot403` field, or `altcha`
|
|
51
|
+
* when the Altcha web component was used.
|
|
52
|
+
*/
|
|
53
|
+
function tokenFrom(body) {
|
|
54
|
+
const get = (k) => body instanceof URLSearchParams || (typeof FormData !== "undefined" && body instanceof FormData)
|
|
55
|
+
? body.get(k)
|
|
56
|
+
: body[k];
|
|
57
|
+
for (const k of [exports.FIELD, "altcha"]) {
|
|
58
|
+
const v = get(k);
|
|
59
|
+
if (typeof v === "string" && v !== "")
|
|
60
|
+
return v;
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
class Bot403 {
|
|
65
|
+
constructor(options) {
|
|
66
|
+
_Bot403_instances.add(this);
|
|
67
|
+
_Bot403_secretKey.set(this, void 0);
|
|
68
|
+
_Bot403_baseUrl.set(this, void 0);
|
|
69
|
+
_Bot403_fetch.set(this, void 0);
|
|
70
|
+
_Bot403_timeoutMs.set(this, void 0);
|
|
71
|
+
if (!options || !options.secretKey)
|
|
72
|
+
throw new TypeError("@bot403/node: secretKey is required");
|
|
73
|
+
if (options.secretKey.startsWith("pk_"))
|
|
74
|
+
throw new TypeError("@bot403/node: that is the public key; pass the secret key (sk_live_…)");
|
|
75
|
+
__classPrivateFieldSet(this, _Bot403_secretKey, options.secretKey, "f");
|
|
76
|
+
__classPrivateFieldSet(this, _Bot403_baseUrl, (options.baseUrl ?? exports.DEFAULT_BASE_URL).replace(/\/+$/, ""), "f");
|
|
77
|
+
const f = options.fetch ?? (typeof fetch === "function" ? fetch : undefined);
|
|
78
|
+
if (!f)
|
|
79
|
+
throw new TypeError("@bot403/node: no fetch in this runtime; pass options.fetch");
|
|
80
|
+
__classPrivateFieldSet(this, _Bot403_fetch, f, "f");
|
|
81
|
+
__classPrivateFieldSet(this, _Bot403_timeoutMs, options.timeoutMs ?? 10000, "f");
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Checks one token. Resolves with `ok: true`, or `ok: false` and a reason;
|
|
85
|
+
* rejects with {@link Bot403Error} only when the request itself fails.
|
|
86
|
+
*/
|
|
87
|
+
verify(token) {
|
|
88
|
+
return __classPrivateFieldGet(this, _Bot403_instances, "m", _Bot403_verifyBody).call(this, JSON.stringify({ solution: token ?? "" }), "application/json");
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Checks the token inside a submitted form. The whole form is sent, so a
|
|
92
|
+
* honeypot field set on the site is checked too.
|
|
93
|
+
*/
|
|
94
|
+
verifyForm(form) {
|
|
95
|
+
const params = new URLSearchParams();
|
|
96
|
+
const add = (k, v) => {
|
|
97
|
+
if (typeof v === "string")
|
|
98
|
+
params.append(k, v);
|
|
99
|
+
};
|
|
100
|
+
if (form instanceof URLSearchParams || (typeof FormData !== "undefined" && form instanceof FormData)) {
|
|
101
|
+
form.forEach((v, k) => add(k, v));
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
for (const [k, v] of Object.entries(form))
|
|
105
|
+
add(k, Array.isArray(v) ? v[0] : v);
|
|
106
|
+
}
|
|
107
|
+
return __classPrivateFieldGet(this, _Bot403_instances, "m", _Bot403_verifyBody).call(this, params.toString(), "application/x-www-form-urlencoded");
|
|
108
|
+
}
|
|
109
|
+
/** The site's statistics for its window, including quota usage. */
|
|
110
|
+
async stats() {
|
|
111
|
+
const res = await __classPrivateFieldGet(this, _Bot403_instances, "m", _Bot403_request).call(this, "GET", "/api/v1/stats");
|
|
112
|
+
if (res.status !== 200)
|
|
113
|
+
throw await errorFrom(res);
|
|
114
|
+
return (await res.json());
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
exports.Bot403 = Bot403;
|
|
118
|
+
_Bot403_secretKey = new WeakMap(), _Bot403_baseUrl = new WeakMap(), _Bot403_fetch = new WeakMap(), _Bot403_timeoutMs = new WeakMap(), _Bot403_instances = new WeakSet(), _Bot403_verifyBody = async function _Bot403_verifyBody(body, contentType) {
|
|
119
|
+
const res = await __classPrivateFieldGet(this, _Bot403_instances, "m", _Bot403_request).call(this, "POST", "/api/v1/verify", body, contentType);
|
|
120
|
+
// 402 is a normal answer: the monthly quota is used up in a capped month.
|
|
121
|
+
if (res.status === 200 || res.status === 402)
|
|
122
|
+
return (await res.json());
|
|
123
|
+
throw await errorFrom(res);
|
|
124
|
+
}, _Bot403_request = async function _Bot403_request(method, path, body, contentType) {
|
|
125
|
+
const ctrl = typeof AbortController === "function" ? new AbortController() : undefined;
|
|
126
|
+
const timer = ctrl ? setTimeout(() => ctrl.abort(), __classPrivateFieldGet(this, _Bot403_timeoutMs, "f")) : undefined;
|
|
127
|
+
const headers = { Authorization: `Bearer ${__classPrivateFieldGet(this, _Bot403_secretKey, "f")}`, Accept: "application/json" };
|
|
128
|
+
if (contentType)
|
|
129
|
+
headers["Content-Type"] = contentType;
|
|
130
|
+
try {
|
|
131
|
+
return await __classPrivateFieldGet(this, _Bot403_fetch, "f").call(this, __classPrivateFieldGet(this, _Bot403_baseUrl, "f") + path, { method, headers, body, signal: ctrl?.signal });
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
throw new Bot403Error(`@bot403/node: ${method} ${path} failed: ${err?.message ?? err}`, 0, "network");
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
if (timer)
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
async function errorFrom(res) {
|
|
142
|
+
let code = `http_${res.status}`;
|
|
143
|
+
let message = `@bot403/node: HTTP ${res.status}`;
|
|
144
|
+
try {
|
|
145
|
+
const j = (await res.json());
|
|
146
|
+
code = j.reason || j.error || code;
|
|
147
|
+
if (j.message)
|
|
148
|
+
message = `@bot403/node: ${j.message}`;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// not JSON; keep the status
|
|
152
|
+
}
|
|
153
|
+
return new Bot403Error(message, res.status, code);
|
|
154
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "type": "commonjs" }
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bot403 on the server: verify the token a protected form sends, and read
|
|
3
|
+
* your site's statistics. Works wherever `fetch` exists: Node.js 18+, Bun,
|
|
4
|
+
* Deno, Cloudflare Workers, Vercel and Next.js route handlers.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* import { Bot403 } from "@bot403/node";
|
|
8
|
+
* const bot403 = new Bot403({ secretKey: process.env.BOT403_SECRET_KEY! });
|
|
9
|
+
* const result = await bot403.verifyForm(await request.formData());
|
|
10
|
+
* if (!result.ok) return new Response("Spam check failed", { status: 400 });
|
|
11
|
+
*
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
*/
|
|
14
|
+
/** The hidden form field the browser script fills with the token. */
|
|
15
|
+
export declare const FIELD = "_bot403";
|
|
16
|
+
/** Where the API lives unless you pass `baseUrl`. */
|
|
17
|
+
export declare const DEFAULT_BASE_URL = "https://bot403.app";
|
|
18
|
+
/** Why a token failed. `quota` comes with HTTP 402; the rest with 200. */
|
|
19
|
+
export type FailureReason = "expired" | "replay" | "invalid" | "wrong_site" | "quota" | "honeypot" | "automation";
|
|
20
|
+
/** Automation signals, present when they are on for the site. */
|
|
21
|
+
export interface Automation {
|
|
22
|
+
/** `clean` means nothing was found, which is not a claim that a person is there. */
|
|
23
|
+
verdict: "clean" | "suspect" | "automated";
|
|
24
|
+
score: number;
|
|
25
|
+
reasons: string[];
|
|
26
|
+
/** `log` only reports; `enforce` also fails the token with reason `automation`. */
|
|
27
|
+
mode: "log" | "enforce";
|
|
28
|
+
}
|
|
29
|
+
/** The answer of `POST /api/v1/verify`. */
|
|
30
|
+
export interface VerifyResult {
|
|
31
|
+
ok: boolean;
|
|
32
|
+
/** The site's public key. */
|
|
33
|
+
site: string;
|
|
34
|
+
/** Set when `ok` is false. */
|
|
35
|
+
reason?: FailureReason;
|
|
36
|
+
solved_at?: string;
|
|
37
|
+
/** The hostname the challenge was requested from. */
|
|
38
|
+
origin?: string;
|
|
39
|
+
difficulty?: string;
|
|
40
|
+
automation?: Automation;
|
|
41
|
+
}
|
|
42
|
+
/** The answer of `GET /api/v1/stats`. */
|
|
43
|
+
export interface Stats {
|
|
44
|
+
site: string;
|
|
45
|
+
window_days: number;
|
|
46
|
+
totals: {
|
|
47
|
+
challenges: number;
|
|
48
|
+
solved: number;
|
|
49
|
+
verify_ok: number;
|
|
50
|
+
verify_fail: number;
|
|
51
|
+
rate_limited: number;
|
|
52
|
+
fail: Record<FailureReason, number>;
|
|
53
|
+
automated: number;
|
|
54
|
+
suspect: number;
|
|
55
|
+
};
|
|
56
|
+
days: {
|
|
57
|
+
day: string;
|
|
58
|
+
challenges: number;
|
|
59
|
+
verify_ok: number;
|
|
60
|
+
verify_fail: number;
|
|
61
|
+
}[];
|
|
62
|
+
series: number[];
|
|
63
|
+
origins: {
|
|
64
|
+
origin: string;
|
|
65
|
+
count: number;
|
|
66
|
+
}[];
|
|
67
|
+
recent: {
|
|
68
|
+
at: string;
|
|
69
|
+
kind: string;
|
|
70
|
+
reason?: string;
|
|
71
|
+
origin: string;
|
|
72
|
+
difficulty: string;
|
|
73
|
+
ip_prefix: string;
|
|
74
|
+
automation?: string;
|
|
75
|
+
automation_reasons?: string[];
|
|
76
|
+
}[];
|
|
77
|
+
quota: {
|
|
78
|
+
plan: string;
|
|
79
|
+
used: number;
|
|
80
|
+
limit: number;
|
|
81
|
+
/** Where verify starts answering 402; -1 when there is no cap this month. */
|
|
82
|
+
cap: number;
|
|
83
|
+
capped: boolean;
|
|
84
|
+
over_last_month: boolean;
|
|
85
|
+
resets_at: string;
|
|
86
|
+
};
|
|
87
|
+
automation_signals: {
|
|
88
|
+
reason: string;
|
|
89
|
+
count: number;
|
|
90
|
+
}[];
|
|
91
|
+
}
|
|
92
|
+
export interface Bot403Options {
|
|
93
|
+
/** The site's secret key, `sk_live_…`. Keep it on the server. */
|
|
94
|
+
secretKey: string;
|
|
95
|
+
/** Defaults to https://bot403.app. */
|
|
96
|
+
baseUrl?: string;
|
|
97
|
+
/** Your own fetch, for tests or older runtimes. Defaults to the global fetch. */
|
|
98
|
+
fetch?: typeof fetch;
|
|
99
|
+
/** Abort a request after this many milliseconds. Defaults to 10000. */
|
|
100
|
+
timeoutMs?: number;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* A request the API refused as a whole: a wrong secret key (401), too many
|
|
104
|
+
* verify calls (429), a body over 64 KB (413), or a network failure
|
|
105
|
+
* (`status` 0). A token that fails is not an error: it is a
|
|
106
|
+
* {@link VerifyResult} with `ok: false`.
|
|
107
|
+
*/
|
|
108
|
+
export declare class Bot403Error extends Error {
|
|
109
|
+
/** HTTP status, or 0 when the request did not complete. */
|
|
110
|
+
readonly status: number;
|
|
111
|
+
/** `unauthorized`, `rate_limited`, `too_large`, `invalid`, `network` or `http_<status>`. */
|
|
112
|
+
readonly code: string;
|
|
113
|
+
constructor(message: string, status: number, code: string);
|
|
114
|
+
}
|
|
115
|
+
/** Anything a form body can arrive as. */
|
|
116
|
+
export type FormBody = FormData | URLSearchParams | Record<string, unknown>;
|
|
117
|
+
/**
|
|
118
|
+
* Reads the token from a submitted form: the `_bot403` field, or `altcha`
|
|
119
|
+
* when the Altcha web component was used.
|
|
120
|
+
*/
|
|
121
|
+
export declare function tokenFrom(body: FormBody): string | undefined;
|
|
122
|
+
export declare class Bot403 {
|
|
123
|
+
#private;
|
|
124
|
+
constructor(options: Bot403Options);
|
|
125
|
+
/**
|
|
126
|
+
* Checks one token. Resolves with `ok: true`, or `ok: false` and a reason;
|
|
127
|
+
* rejects with {@link Bot403Error} only when the request itself fails.
|
|
128
|
+
*/
|
|
129
|
+
verify(token: string | null | undefined): Promise<VerifyResult>;
|
|
130
|
+
/**
|
|
131
|
+
* Checks the token inside a submitted form. The whole form is sent, so a
|
|
132
|
+
* honeypot field set on the site is checked too.
|
|
133
|
+
*/
|
|
134
|
+
verifyForm(form: FormBody): Promise<VerifyResult>;
|
|
135
|
+
/** The site's statistics for its window, including quota usage. */
|
|
136
|
+
stats(): Promise<Stats>;
|
|
137
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bot403 on the server: verify the token a protected form sends, and read
|
|
3
|
+
* your site's statistics. Works wherever `fetch` exists: Node.js 18+, Bun,
|
|
4
|
+
* Deno, Cloudflare Workers, Vercel and Next.js route handlers.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* import { Bot403 } from "@bot403/node";
|
|
8
|
+
* const bot403 = new Bot403({ secretKey: process.env.BOT403_SECRET_KEY! });
|
|
9
|
+
* const result = await bot403.verifyForm(await request.formData());
|
|
10
|
+
* if (!result.ok) return new Response("Spam check failed", { status: 400 });
|
|
11
|
+
*
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
*/
|
|
14
|
+
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
|
|
15
|
+
if (kind === "m") throw new TypeError("Private method is not writable");
|
|
16
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
|
|
17
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
|
18
|
+
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
19
|
+
};
|
|
20
|
+
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
21
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
22
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
23
|
+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
24
|
+
};
|
|
25
|
+
var _Bot403_instances, _Bot403_secretKey, _Bot403_baseUrl, _Bot403_fetch, _Bot403_timeoutMs, _Bot403_verifyBody, _Bot403_request;
|
|
26
|
+
/** The hidden form field the browser script fills with the token. */
|
|
27
|
+
export const FIELD = "_bot403";
|
|
28
|
+
/** Where the API lives unless you pass `baseUrl`. */
|
|
29
|
+
export const DEFAULT_BASE_URL = "https://bot403.app";
|
|
30
|
+
/**
|
|
31
|
+
* A request the API refused as a whole: a wrong secret key (401), too many
|
|
32
|
+
* verify calls (429), a body over 64 KB (413), or a network failure
|
|
33
|
+
* (`status` 0). A token that fails is not an error: it is a
|
|
34
|
+
* {@link VerifyResult} with `ok: false`.
|
|
35
|
+
*/
|
|
36
|
+
export class Bot403Error extends Error {
|
|
37
|
+
constructor(message, status, code) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "Bot403Error";
|
|
40
|
+
this.status = status;
|
|
41
|
+
this.code = code;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Reads the token from a submitted form: the `_bot403` field, or `altcha`
|
|
46
|
+
* when the Altcha web component was used.
|
|
47
|
+
*/
|
|
48
|
+
export function tokenFrom(body) {
|
|
49
|
+
const get = (k) => body instanceof URLSearchParams || (typeof FormData !== "undefined" && body instanceof FormData)
|
|
50
|
+
? body.get(k)
|
|
51
|
+
: body[k];
|
|
52
|
+
for (const k of [FIELD, "altcha"]) {
|
|
53
|
+
const v = get(k);
|
|
54
|
+
if (typeof v === "string" && v !== "")
|
|
55
|
+
return v;
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
export class Bot403 {
|
|
60
|
+
constructor(options) {
|
|
61
|
+
_Bot403_instances.add(this);
|
|
62
|
+
_Bot403_secretKey.set(this, void 0);
|
|
63
|
+
_Bot403_baseUrl.set(this, void 0);
|
|
64
|
+
_Bot403_fetch.set(this, void 0);
|
|
65
|
+
_Bot403_timeoutMs.set(this, void 0);
|
|
66
|
+
if (!options || !options.secretKey)
|
|
67
|
+
throw new TypeError("@bot403/node: secretKey is required");
|
|
68
|
+
if (options.secretKey.startsWith("pk_"))
|
|
69
|
+
throw new TypeError("@bot403/node: that is the public key; pass the secret key (sk_live_…)");
|
|
70
|
+
__classPrivateFieldSet(this, _Bot403_secretKey, options.secretKey, "f");
|
|
71
|
+
__classPrivateFieldSet(this, _Bot403_baseUrl, (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""), "f");
|
|
72
|
+
const f = options.fetch ?? (typeof fetch === "function" ? fetch : undefined);
|
|
73
|
+
if (!f)
|
|
74
|
+
throw new TypeError("@bot403/node: no fetch in this runtime; pass options.fetch");
|
|
75
|
+
__classPrivateFieldSet(this, _Bot403_fetch, f, "f");
|
|
76
|
+
__classPrivateFieldSet(this, _Bot403_timeoutMs, options.timeoutMs ?? 10000, "f");
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Checks one token. Resolves with `ok: true`, or `ok: false` and a reason;
|
|
80
|
+
* rejects with {@link Bot403Error} only when the request itself fails.
|
|
81
|
+
*/
|
|
82
|
+
verify(token) {
|
|
83
|
+
return __classPrivateFieldGet(this, _Bot403_instances, "m", _Bot403_verifyBody).call(this, JSON.stringify({ solution: token ?? "" }), "application/json");
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Checks the token inside a submitted form. The whole form is sent, so a
|
|
87
|
+
* honeypot field set on the site is checked too.
|
|
88
|
+
*/
|
|
89
|
+
verifyForm(form) {
|
|
90
|
+
const params = new URLSearchParams();
|
|
91
|
+
const add = (k, v) => {
|
|
92
|
+
if (typeof v === "string")
|
|
93
|
+
params.append(k, v);
|
|
94
|
+
};
|
|
95
|
+
if (form instanceof URLSearchParams || (typeof FormData !== "undefined" && form instanceof FormData)) {
|
|
96
|
+
form.forEach((v, k) => add(k, v));
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
for (const [k, v] of Object.entries(form))
|
|
100
|
+
add(k, Array.isArray(v) ? v[0] : v);
|
|
101
|
+
}
|
|
102
|
+
return __classPrivateFieldGet(this, _Bot403_instances, "m", _Bot403_verifyBody).call(this, params.toString(), "application/x-www-form-urlencoded");
|
|
103
|
+
}
|
|
104
|
+
/** The site's statistics for its window, including quota usage. */
|
|
105
|
+
async stats() {
|
|
106
|
+
const res = await __classPrivateFieldGet(this, _Bot403_instances, "m", _Bot403_request).call(this, "GET", "/api/v1/stats");
|
|
107
|
+
if (res.status !== 200)
|
|
108
|
+
throw await errorFrom(res);
|
|
109
|
+
return (await res.json());
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
_Bot403_secretKey = new WeakMap(), _Bot403_baseUrl = new WeakMap(), _Bot403_fetch = new WeakMap(), _Bot403_timeoutMs = new WeakMap(), _Bot403_instances = new WeakSet(), _Bot403_verifyBody = async function _Bot403_verifyBody(body, contentType) {
|
|
113
|
+
const res = await __classPrivateFieldGet(this, _Bot403_instances, "m", _Bot403_request).call(this, "POST", "/api/v1/verify", body, contentType);
|
|
114
|
+
// 402 is a normal answer: the monthly quota is used up in a capped month.
|
|
115
|
+
if (res.status === 200 || res.status === 402)
|
|
116
|
+
return (await res.json());
|
|
117
|
+
throw await errorFrom(res);
|
|
118
|
+
}, _Bot403_request = async function _Bot403_request(method, path, body, contentType) {
|
|
119
|
+
const ctrl = typeof AbortController === "function" ? new AbortController() : undefined;
|
|
120
|
+
const timer = ctrl ? setTimeout(() => ctrl.abort(), __classPrivateFieldGet(this, _Bot403_timeoutMs, "f")) : undefined;
|
|
121
|
+
const headers = { Authorization: `Bearer ${__classPrivateFieldGet(this, _Bot403_secretKey, "f")}`, Accept: "application/json" };
|
|
122
|
+
if (contentType)
|
|
123
|
+
headers["Content-Type"] = contentType;
|
|
124
|
+
try {
|
|
125
|
+
return await __classPrivateFieldGet(this, _Bot403_fetch, "f").call(this, __classPrivateFieldGet(this, _Bot403_baseUrl, "f") + path, { method, headers, body, signal: ctrl?.signal });
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
throw new Bot403Error(`@bot403/node: ${method} ${path} failed: ${err?.message ?? err}`, 0, "network");
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
if (timer)
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
async function errorFrom(res) {
|
|
136
|
+
let code = `http_${res.status}`;
|
|
137
|
+
let message = `@bot403/node: HTTP ${res.status}`;
|
|
138
|
+
try {
|
|
139
|
+
const j = (await res.json());
|
|
140
|
+
code = j.reason || j.error || code;
|
|
141
|
+
if (j.message)
|
|
142
|
+
message = `@bot403/node: ${j.message}`;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// not JSON; keep the status
|
|
146
|
+
}
|
|
147
|
+
return new Bot403Error(message, res.status, code);
|
|
148
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bot403/node",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Bot403 for Node.js and other server runtimes: verify form tokens and read statistics. Typed, no dependencies. Works in Node.js 18+, Bun, Deno, Cloudflare Workers and Next.js.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"bot403",
|
|
7
|
+
"captcha",
|
|
8
|
+
"spam",
|
|
9
|
+
"bot-protection",
|
|
10
|
+
"proof-of-work",
|
|
11
|
+
"altcha",
|
|
12
|
+
"forms",
|
|
13
|
+
"server",
|
|
14
|
+
"nodejs",
|
|
15
|
+
"bun",
|
|
16
|
+
"deno",
|
|
17
|
+
"cloudflare-workers"
|
|
18
|
+
],
|
|
19
|
+
"homepage": "https://bot403.app/docs/javascript",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/zaharystoyanovlabs/bot403.git",
|
|
23
|
+
"directory": "sdk/js/packages/node"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"email": "hello@bot403.app"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"author": "Zahary Stoyanov",
|
|
30
|
+
"type": "module",
|
|
31
|
+
"sideEffects": false,
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"main": "./dist/cjs/index.js",
|
|
38
|
+
"module": "./dist/esm/index.js",
|
|
39
|
+
"types": "./dist/esm/index.d.ts",
|
|
40
|
+
"exports": {
|
|
41
|
+
".": {
|
|
42
|
+
"import": {
|
|
43
|
+
"types": "./dist/esm/index.d.ts",
|
|
44
|
+
"default": "./dist/esm/index.js"
|
|
45
|
+
},
|
|
46
|
+
"require": {
|
|
47
|
+
"types": "./dist/cjs/index.d.ts",
|
|
48
|
+
"default": "./dist/cjs/index.js"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"./package.json": "./package.json"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "rm -rf dist && tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node ../../scripts/cjs-package.mjs"
|
|
58
|
+
},
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": ">=18"
|
|
61
|
+
}
|
|
62
|
+
}
|