abusend Express demo: Orbit

Orbit is a small, realistic web app: prepaid credits for an API. You register, top up your balance with a (demo) card and spend 10 cents per API call. Every top-up goes through abusend, and a handful of readable rules decide who gets which verification:

rule (in the panel) outcome
New account, big top-up: hours_since(user.registered_at) < 24 && context.amount >= 500 Verify with Captcha or hCaptcha (70/30): a check group, one of the two picked at random
Never used the API, top-up of 100 or more: user.api_calls_total == 0 && context.amount >= 100 Verify with SMS: Orbit's own OTP step
5 or more top-ups in an hour: event_count("topup", "1h") >= 5 Verify with SMS
More than 2,000 in 24 hours: event_sum("topup", "24h") + context.amount > 2000 Your app decides: Orbit holds the top-up for a manual review

The last two read events: after every credited top-up Orbit calls abusend.events.track({ userId, type: "topup", value: amount, id }), so rules can count and sum what abusend itself cannot see. (event_sum covers the top-ups already made; the + context.amount counts the one being attempted. Leave it out for "already over 2,000".)

What it shows:

5-minute setup

You need an abusend test project (pk_test_… / sk_test_…): on a test project http://localhost:* is an allowed origin and the captcha providers' test keys work. Either use a hosted abusend and create a test project in the panel, or run abusend locally (AGENTS.md §3 in the repository: make probe-variants, then go run ./cmd/abusend serve; the bootstrap admin gets a test project).

cd examples/express-demo

# 1. The Node SDK: this demo links the local one (file:../../sdk/node). Your own app installs
#    it from your edge instead: npm install https://<edge>/sdk/v1/abusend-node.tgz
(cd ../../sdk/node && npm ci && npm run build)
npm install

# 2. Configure the test project: the topup action, the attributes, the checks (Turnstile and
#    hCaptcha with the providers' test keys, the SMS app check) and the four rules above,
#    enforcing. Idempotent; refuses live projects.
ABUSEND_PANEL_URL=https://<your panel> ABUSEND_PANEL_TOKEN=abp_… ABUSEND_SITE_KEY=pk_test_… \
  npm run setup

# 3. Build the assets and start
npm run build
ABUSEND_URL=https://<your edge> ABUSEND_SITE_KEY=pk_test_… ABUSEND_SECRET_KEY=sk_test_… npm start
# → http://localhost:3000

ABUSEND_PANEL_TOKEN is a personal access token with the admin role (panel → Account → API & MCP). You can also create the same setup by hand in the panel (Checks, Rules; the script's header lists every value). The rules are Enforcing and scoped to the topup action, whose token is optional: a request without a browser token (the SDK blocked, or a script calling the endpoint directly) meets the same rules.

While changing the demo, npm run dev rebuilds the assets on change (vite build --watch) and restarts the server (node --watch).

Local edge with a self-signed certificate

Configuration

env default
ABUSEND_URL https://localhost:8443 the edge: serves /sdk/v1/abusend.js and /v1/*
ABUSEND_SITE_KEY (required) public site key (pk_test_…)
ABUSEND_SECRET_KEY (required) secret key (sk_test_…, panel → API keys)
ABUSEND_FAILURE review the Node SDK's failure: the decision when the edge is unreachable, times out or answers 429 (review or deny, never allow)
ABUSEND_USER_ID_KEY a demo value HMAC key for the opaque user ids; in production a random secret, stored like the secret key
PORT 3000
DATA_DIR .data/ where users, sessions and the SMS outbox are kept (JSON files)
NODE_ENV production caches the compiled views

Try it

Open the SMS inbox in a second tab (every page links to it), register, then on the wallet:

  1. Add $20. No rule matches: the balance goes up at once. Why? says "No rule matched this top-up".
  2. Add $150. The account never made an API call, so the rule asks for SMS: the OTP dialog opens and the code appears in the SMS inbox. A wrong code shows "2 tries left" (a typo is not a failed check); the right one → Orbit reports passed → the SDK sends the top-up again → allowed.
  3. Add $20 five more times. The sixth top-up within an hour needs a code again: the event_count("topup", "1h") >= 5 rule, fed by events.track. The wallet's "What abusend sees" box shows the counts.
  4. New account, add $600. The check group runs one of Captcha / hCaptcha (70/30; the test keys pass invisibly), then the SMS step. Make an API call (api_calls_total becomes 1), then add $1,500: the 24-hour total would pass 2,000, the rule says Your app decides and Orbit shows the top-up as Held for review (it does not report held top-ups as events).
  5. Cancel the SMS dialog: the SDK reports the verification as not completed and the page shows a neutral "We could not verify this top-up". Trying again right away is declined: an unfinished SMS check is never let through (on_incomplete of app checks is Block). Three wrong codes end the same way (on_fail).

The Why? panel also mentions what rules in Monitoring would have done; headless or automated browsers often trip the seeded High risk baseline rule, which only reports until you enforce it. Signals and risk are hints for your rules, not verdicts.

How the SMS inbox works

src/lib/sms.js stands in for an SMS provider: send(to, text) appends the message to .data/sms.json and logs it. /check_sms renders the messages sent to the logged-in user's phone number, newest first, and assets/sms.js polls /check_sms/messages every 2 seconds to add new ones (with a Copy code button). In your app, send() is where you call your SMS provider.

The flow of one SMS step:

  1. protect() gets a verdict that asks for the sms check and calls the checks.sms hook (src/lib/otp.js sendCode): Orbit creates a 6-digit code, keeps it with the challenge id in the session, and "sends" it. The hook does not run again when the same pending challenge comes back (challenge.reissued).
  2. The browser SDK receives the 428 and calls the page's onChallenge("sms") handler (assets/wallet.js), which opens the OTP dialog.
  3. The dialog posts the code to POST /otp/verify (src/routes/otp.js): three tries, then abusend.challenges.result(id, "passed" | "failed", { userId }). The challenge id comes from the session, never from the browser.
  4. The handler resolves, the SDK sends the top-up again with X-Abusend-Challenge, and the next verdict decides with the result.

abusend never sees the phone number or the code, only the outcome.

How the "Why?" timings are measured

The Node SDK does not expose how long a call took, so the demo measures it (src/lib/why.js):

Times include your server's network path to the edge, so they are larger than abusend's own processing time. They are a demo aid, not a benchmark.

How events feed rules

After a credited top-up, src/routes/wallet.js reports it:

await abusend.events.track({ userId: req.abusendUserId, type: "topup", value: amount, id: topup.id });

Code tour

src/
  server.js            config check, listen
  app.js               Express: Eta views, hashed assets, sessions, routes, error pages
  config.js            environment
  routes/
    pages.js           landing page
    auth.js            register, log in, log out (server-rendered forms, redirect-after-POST)
    wallet.js          wallet page, POST /wallet/topup (protect() → credit or hold → events.track),
                       the pretend API call, GET /wallet/why (the Why? card alone)
    otp.js             POST /otp/verify: check the code, report the result
    sms.js             /check_sms and its JSON feed
  lib/
    abusend.js         the client, the opaque user id, user attributes, the loader snippet
    otp.js             the checks.sms hook (send) and the code check (verify, report)
    why.js             records each verdict of an attempt (onVerdict) with its timing and explains it
    users.js           users, balances, top-ups in .data/users.json (scrypt passwords)
    sessions.js        server-side sessions in .data/sessions.json
    sms.js             the fake SMS provider (outbox)
    assets.js          reads Vite's manifest for the views
views/                 Eta: layout.eta + partials (nav, flash, footer, why, OTP dialog, …) + pages
assets/                page scripts and styles, built by Vite into dist/ (hashed, with a manifest)
scripts/
  setup-project.mjs    configures the test project (npm run setup)
  dev.mjs              npm run dev
e2e.mjs                Playwright end-to-end check

End-to-end check (Playwright)

npm run build
PLAYWRIGHT_MODULE=/path/to/playwright/index.mjs \
ABUSEND_URL=… ABUSEND_SITE_KEY=pk_test_… ABUSEND_SECRET_KEY=sk_test_… NODE_EXTRA_CA_CERTS=… npm run e2e

e2e.mjs starts the server on port 3999 (E2E_PORT) with a fresh data directory and runs, in headless Chromium: a $20 top-up; a first $150 top-up with the SMS step (a wrong code first, the code read from the SMS inbox page); five quick top-ups and the events rule; a cancelled SMS step and the retry after it; three wrong codes; the check group ($600 on a new account) and a $1,500 top-up that is held for review; the Why? panel along the way. It prints each step with the challenges seen on the wire (428:sms, 428:captcha, verify:passed, …).