Bot defence
botsScore the sign-in, sign-up, reset and invite doors in process, challenge or refuse bots with a proof-of-work, and stop SMS and WhatsApp pumping before a message is paid for.
bot-defenceFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
Score the sign-in, sign-up, reset and invite doors in process, challenge or refuse bots with a proof-of-work, and stop SMS and WhatsApp pumping before a message is paid for.
At a glance
bots:
mode: enforce
doors:
sign-up: { challenge: 30, deny: 80, disposable-email: deny }
contact: { path: /api/contact, form: true }
challenge: { driver: pow }
pumping: { per-prefix: { max: 10, per: 1h }, countries: [IN] }
crawlers: [googlebot]
block: [198.51.100.0/24]import { bots } from "mainsdk/bots";
// a hidden token and a honeypot field for a contact form; the door is declared in main.yaml
const { html } = bots.formFields();
// before you send a code yourself
await bots.guardSend("+91 98765 43210"); // throws MAIN-BOT-0007 (429) when refusedCalls
bots.formToken()bots.formFields()bots.check(req, { door? })bots.guardSend(phone)bots.challenge({ score? })
Guarantees
Whatever the driver.
- A request is scored from its headers, a form token and counters in process, with no cookie, no script and no fingerprint
- A refusal never says why; the reasons are in the log and the security event
- No raw address, email or phone number is stored, and each counter holds at most 10,000 keys
- A store outage never blocks a sign-in; the counters fall back to local
Drivers
4 drivers. Select one to see its line in main.yaml.
bots:
driver: powChange this one line to swap providers; your calls stay the same.Proof-of-work integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-BOT-0005 | Checkmainsdk check, before deploy | A bots value is invalid: a door path that matches no route, challenge not below deny, an unknown country code or CIDR, or an unknown key |
MAIN-BOT-0001 | RuntimeError responses and logs | See the spec |
MAIN-BOT-0002 | RuntimeError responses and logs | See the spec |
MAIN-BOT-0003 | RuntimeError responses and logs | See the spec |
MAIN-BOT-0004 | RuntimeError responses and logs | See the spec |
MAIN-BOT-0006 | RuntimeError responses and logs | See the spec |
MAIN-BOT-0007 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| bots section in main.yaml with its check rule | Phase 1 | 0.3 | bot-defence#schema |
| Door scoring and velocity counters | Phase 1 | 0.3 | bot-defence#score |
| Form token and honeypot | Phase 1 | 0.3 | bot-defence#forms |
| Proof-of-work challenge and browser client | Phase 1 | 0.3 | bot-defence#pow |
| Message pumping guard | Phase 1 | 0.3 | bot-defence#pumping |
| Crawler verification | Phase 1 | 0.3 | bot-defence#crawlers |
| Turnstile and hCaptcha drivers | Phase 1 | 0.3 | bot-defence#captcha-drivers |
| Pushed block list | Phase 1 | 0.3 | bot-defence#signals |
Quick start
bots:
mode: enforce
doors:
sign-up: { challenge: 30, deny: 80, disposable-email: deny }
contact: { path: /api/contact, form: true }
challenge: { driver: pow }
pumping: { per-prefix: { max: 10, per: 1h }, countries: [IN] }
crawlers: [googlebot]
block: [198.51.100.0/24]import { bots } from "mainsdk/bots";
// a hidden token and a honeypot field for a contact form; the door is declared in main.yaml
const { html } = bots.formFields();
// before you send a code yourself
await bots.guardSend("+91 98765 43210"); // throws MAIN-BOT-0007 (429) when refusedConfiguration
A request to a door is scored from 0 (person) to 100 (bot). Below the door's challenge score it goes through. At or above it, the answer is 403 MAIN-BOT-0001 with a challenge member; the browser solves it with mainsdk/bots/client (fetchWithChallenge does this and retries once). At or above deny, it is 403 MAIN-BOT-0002. The reasons are in the log and the bot.challenged or bot.denied security event, never in the response.
The built-in doors are sign-in, sign-up, reset, whatsapp and invite, mapped onto the auth routes. Declare your own with doors.<name>.path. mode: observe lets every request through and logs what would have happened. With no bots section, nothing is scored.
Calls
| Call | What it does |
|---|---|
bots.formToken() | A signed hidden-field value for a form: the issue time and a honeypot field name. Use bots.formFields() for the ready HTML. |
bots.formFields() | The token and the honeypot input as HTML to put inside the form, with the field names. |
bots.check(req, { door? }) | Scores one request and returns the decision: allow, challenge or deny, with the score and reasons. The engine already does this for every door. |
bots.guardSend(phone) | Throws MAIN-BOT-0007 (429) when the country is not allowed or the send limits are reached. The WhatsApp code send calls it for you. |
bots.challenge({ score? }) | A fresh proof-of-work challenge sized for a score. |
Drivers
| Driver | Provider | |
|---|---|---|
pow | Proof-of-work (built in, no third party) | default |
turnstile | Cloudflare Turnstile | |
hcaptcha | hCaptcha |
What you get
- A request is scored from its headers, a form token and counters in process, with no cookie, no script and no fingerprint
- A refusal never says why; the reasons are in the log and the security event
- No raw address, email or phone number is stored, and each counter holds at most 10,000 keys
- A store outage never blocks a sign-in; the counters fall back to local
Errors and checks
| Code | When |
|---|---|
MAIN-BOT-0001 | 403: a challenge is required; the problem's challenge member carries it |
MAIN-BOT-0002 | 403: refused as automated; the reasons are logged, not returned |
MAIN-BOT-0003 | 403: the solution is wrong, expired, for another challenge or used twice |
MAIN-BOT-0004 | 403: the form token is missing, forged or older than 1 hour |
MAIN-BOT-0005 | check: a bots value is invalid, such as a door path that matches no route |
MAIN-BOT-0006 | the CAPTCHA provider did not answer in 2 s; the built-in challenge was used (logged once per outage) |
MAIN-BOT-0007 | 429: a WhatsApp or SMS code was refused before sending |
Each code's cause and fix is in check codes. mainsdk check runs the bots gate.
Swapping drivers
challenge.driver is pow (the default), turnstile or hcaptcha. A provider needs sitekey and a secret:NAME reference for its secret; the page's CSP lists the provider's hosts only when the driver is set, and the token is verified through http.fetch with a 2 s timeout and no retry. If the provider does not answer, the built-in challenge is used instead of letting the request through.
bots:
challenge:
driver: turnstile
turnstile: { sitekey: 0x4AAAA, secret: "secret:TURNSTILE_SECRET" }Testing
Call bots.check(new Request(url, { method: "POST", headers })) and read action, score and reasons. Set mode: observe first in a real app. The proof-of-work helpers solveChallenge and encodeSolution from mainsdk/bots solve a challenge in a test.
Next
Was this page helpful?