Skip to content

Commit 66639d8

Browse files
committed
docs+example: captcha docs, client README, runnable example; align client endpoint paths
- Document the self-hosted captcha + detection engine in @webdecoy/node README (mounting endpoints, verifyToken, direct engine use, security/deployment). - Add @webdecoy/client README (widget, auto-init, invisible mode, server setup). - Add examples/captcha-express: browser widget → Express endpoints → token verify. - FIX: align @webdecoy/client request paths with the @webdecoy/node handler. The client posted to FCaptcha's legacy /api/pow/challenge,/api/verify,/api/score while the SDK serves /__webdecoy/{challenge,verify,score}. The client now uses a configurable base path (default /__webdecoy) via configure({ basePath }). Verified the full flow end-to-end over HTTP through the example server.
1 parent 1cb57b4 commit 66639d8

13 files changed

Lines changed: 414 additions & 11 deletions

File tree

examples/captcha-express/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# WebDecoy Captcha Example
2+
3+
Self-hosted captcha demo: the [`@webdecoy/client`](../../packages/client) checkbox
4+
widget in the browser, the [`@webdecoy/express`](../../packages/express) captcha
5+
endpoints + in-process detection engine on the server.
6+
7+
## Run
8+
9+
```bash
10+
npm install # from the repo root (workspaces)
11+
npm run build # build the @webdecoy/* packages
12+
cd examples/captcha-express
13+
npm run dev # → http://localhost:3000
14+
```
15+
16+
Open http://localhost:3000, tick the captcha, then **Log in**. The widget:
17+
18+
1. Fetches a proof-of-work challenge from `GET /__webdecoy/challenge`.
19+
2. Collects ~40 behavioral/environmental/fingerprint signals.
20+
3. Solves the PoW (binding the signals hash) and posts to `POST /__webdecoy/verify`.
21+
4. Receives a signed session token, which the form submits to `/login`, where
22+
the server verifies it with `Captcha.verifyToken()`.
23+
24+
## Notes
25+
26+
- Set `WEBDECOY_SECRET` for a real signing secret (required when
27+
`NODE_ENV=production`).
28+
- This demo uses in-memory stores. For multi-instance / serverless deployments,
29+
provide a shared `challengeStore` / `tokenStore` (Redis).
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "webdecoy-captcha-example",
3+
"version": "0.1.0",
4+
"private": true,
5+
"description": "Self-hosted captcha example: @webdecoy/client widget + Express endpoints",
6+
"scripts": {
7+
"dev": "tsx src/server.ts",
8+
"build": "tsc",
9+
"start": "node dist/server.js"
10+
},
11+
"dependencies": {
12+
"@webdecoy/client": "*",
13+
"@webdecoy/express": "*",
14+
"@webdecoy/node": "*",
15+
"express": "^4.18.2"
16+
},
17+
"devDependencies": {
18+
"@types/express": "^4.17.21",
19+
"@types/node": "^20.11.0",
20+
"tsx": "^4.7.0",
21+
"typescript": "^5.3.3"
22+
},
23+
"engines": {
24+
"node": ">=18.0.0"
25+
}
26+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>WebDecoy Captcha Example</title>
7+
<style>
8+
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 440px; margin: 60px auto; color: #222; }
9+
h1 { font-size: 20px; }
10+
button { margin-top: 16px; padding: 8px 18px; font-size: 14px; border-radius: 6px; border: 1px solid #c1c1c1; background: #fff; cursor: pointer; }
11+
button:disabled { opacity: 0.5; cursor: not-allowed; }
12+
#result { margin-top: 16px; font-size: 14px; }
13+
.ok { color: #2e7d32; }
14+
.err { color: #c62828; }
15+
</style>
16+
</head>
17+
<body>
18+
<h1>WebDecoy Captcha Example</h1>
19+
<p>Solve the captcha, then submit. The token is verified server-side.</p>
20+
21+
<form id="login-form">
22+
<div id="captcha"></div>
23+
<button type="submit" id="submit" disabled>Log in</button>
24+
</form>
25+
<div id="result"></div>
26+
27+
<script src="/webdecoy.js"></script>
28+
<script>
29+
let token = null;
30+
const submitBtn = document.getElementById('submit');
31+
const resultEl = document.getElementById('result');
32+
33+
// Same-origin server; endpoints are mounted at the default /__webdecoy base.
34+
WebDecoyCaptcha.configure({ serverUrl: window.location.origin });
35+
WebDecoyCaptcha.render('captcha', {
36+
siteKey: 'pk_demo',
37+
callback: (t) => {
38+
token = t;
39+
submitBtn.disabled = false;
40+
},
41+
});
42+
43+
document.getElementById('login-form').addEventListener('submit', async (e) => {
44+
e.preventDefault();
45+
resultEl.textContent = 'Submitting...';
46+
const res = await fetch('/login', {
47+
method: 'POST',
48+
headers: { 'Content-Type': 'application/json' },
49+
body: JSON.stringify({ webdecoy_token: token }),
50+
});
51+
const data = await res.json();
52+
resultEl.textContent = data.ok ? '✓ ' + data.message : '✕ ' + data.error;
53+
resultEl.className = data.ok ? 'ok' : 'err';
54+
});
55+
</script>
56+
</body>
57+
</html>
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Self-hosted captcha example.
3+
*
4+
* Serves a page with the @webdecoy/client checkbox widget, mounts the WebDecoy
5+
* captcha endpoints (PoW + in-process detection + tokens), and verifies the
6+
* issued token on a protected /login route.
7+
*
8+
* npm run dev → http://localhost:3000
9+
*/
10+
11+
import path from 'path';
12+
import express, { type Request, type Response } from 'express';
13+
import { webdecoyCaptcha } from '@webdecoy/express';
14+
import { Captcha } from '@webdecoy/node';
15+
16+
const SECRET = process.env.WEBDECOY_SECRET || 'example-dev-secret-change-me';
17+
const PORT = Number(process.env.PORT) || 3000;
18+
19+
const app = express();
20+
app.use(express.json());
21+
app.use(express.static(path.join(__dirname, '..', 'public')));
22+
23+
// Serve the standalone browser bundle from the @webdecoy/client package.
24+
app.get('/webdecoy.js', (_req: Request, res: Response) => {
25+
res.sendFile(require.resolve('@webdecoy/client/global'));
26+
});
27+
28+
// Mount GET/POST /__webdecoy/{challenge,verify,score,token/verify}.
29+
app.use(webdecoyCaptcha({ secret: SECRET }));
30+
31+
// Protected route: verify the session token the widget produced.
32+
// (In production, share one Captcha instance/store between the endpoints and
33+
// this check so single-use replay protection spans both.)
34+
const captcha = new Captcha({ secret: SECRET });
35+
36+
app.post('/login', (req: Request, res: Response) => {
37+
const token = req.body?.webdecoy_token as string | undefined;
38+
if (!token) {
39+
res.status(400).json({ ok: false, error: 'missing token' });
40+
return;
41+
}
42+
const result = captcha.verifyToken(token, req.ip);
43+
if (!result.valid) {
44+
res.status(403).json({ ok: false, error: `captcha failed: ${result.reason}` });
45+
return;
46+
}
47+
res.json({ ok: true, message: 'Logged in', score: result.score });
48+
});
49+
50+
app.listen(PORT, () => {
51+
console.log(`WebDecoy captcha example running at http://localhost:${PORT}`);
52+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"outDir": "./dist",
5+
"rootDir": "./src",
6+
"lib": ["ES2020"]
7+
},
8+
"include": ["src/**/*"],
9+
"exclude": ["node_modules", "dist"]
10+
}

package-lock.json

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/client/README.md

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# @webdecoy/client
2+
3+
Browser widget for [WebDecoy](https://github.com/WebDecoy/node-sdk) captcha — collects behavioral, environmental, and fingerprint signals, solves a SHA-256 proof-of-work, and submits to your WebDecoy-protected server.
4+
5+
[![npm version](https://img.shields.io/npm/v/@webdecoy/client.svg)](https://www.npmjs.com/package/@webdecoy/client)
6+
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7+
8+
Pairs with the in-process detection engine in [`@webdecoy/node`](https://www.npmjs.com/package/@webdecoy/node).
9+
10+
## Installation
11+
12+
```bash
13+
npm install @webdecoy/client
14+
```
15+
16+
Or load the standalone bundle directly via `<script>`:
17+
18+
```html
19+
<script src="https://unpkg.com/@webdecoy/client/dist/webdecoy.global.js"></script>
20+
```
21+
22+
## Usage
23+
24+
### Checkbox widget
25+
26+
```html
27+
<div id="captcha-box"></div>
28+
<script type="module">
29+
import { WebDecoyCaptcha } from '@webdecoy/client';
30+
31+
WebDecoyCaptcha.configure({ serverUrl: 'https://your-server.com' });
32+
const id = WebDecoyCaptcha.render('captcha-box', {
33+
siteKey: 'pk_live_...',
34+
theme: 'light',
35+
callback: (token) => console.log('verified:', token),
36+
});
37+
38+
// Later: WebDecoyCaptcha.getResponse(id) → token
39+
</script>
40+
```
41+
42+
### Auto-init (no JS)
43+
44+
```html
45+
<div data-webdecoy="pk_live_..." data-endpoint="https://your-server.com"></div>
46+
<script src="https://unpkg.com/@webdecoy/client/dist/webdecoy.global.js"></script>
47+
```
48+
49+
### Invisible mode
50+
51+
Passively scores the session and auto-protects form submissions (injects a
52+
hidden `webdecoy_token` field, scores on submit):
53+
54+
```js
55+
import { WebDecoyCaptcha } from '@webdecoy/client';
56+
57+
WebDecoyCaptcha.configure({ serverUrl: 'https://your-server.com' });
58+
WebDecoyCaptcha.invisible({ siteKey: 'pk_live_...' });
59+
```
60+
61+
Or score on demand:
62+
63+
```js
64+
const result = await WebDecoyCaptcha.execute('pk_live_...', { action: 'login' });
65+
if (result.success) { /* result.token */ }
66+
```
67+
68+
## What it collects
69+
70+
| Group | Signals |
71+
|-------|---------|
72+
| Behavioral | mouse trajectory/velocity/micro-tremor, click precision, touch kinematics, scroll, keystroke cadence |
73+
| Sensor | device motion/orientation entropy (mobile) |
74+
| Environmental | WebDriver/CDP/Playwright markers, canvas, WebGL, audio, fonts, WebRTC, speech, worker consistency, CSS media, permissions, DOMRect, RAF/JS timing |
75+
| Form | submit method, per-field textarea typing stats |
76+
77+
Signals are hashed and bound into the proof-of-work so they can't be tampered
78+
with after solving. The server scores them with `@webdecoy/node`.
79+
80+
## Server setup
81+
82+
Mount the matching endpoints on your backend (default base path `/__webdecoy`):
83+
84+
```js
85+
import express from 'express';
86+
import { webdecoyCaptcha } from '@webdecoy/express';
87+
88+
const app = express();
89+
app.use(express.json());
90+
app.use(webdecoyCaptcha({ secret: process.env.WEBDECOY_SECRET }));
91+
```
92+
93+
See [`@webdecoy/node`](https://www.npmjs.com/package/@webdecoy/node) for Fastify
94+
and Next.js adapters and the full detection/scoring reference.
95+
96+
## API
97+
98+
- `WebDecoyCaptcha.configure({ serverUrl })`
99+
- `WebDecoyCaptcha.render(container, options) → widgetId`
100+
- `WebDecoyCaptcha.getResponse(widgetId) → token | null`
101+
- `WebDecoyCaptcha.reset(widgetId)`
102+
- `WebDecoyCaptcha.invisible(options) → InvisibleSession`
103+
- `WebDecoyCaptcha.execute(siteKey, { action, minTime, powDifficulty }) → Promise<result>`
104+
- `WebDecoyCaptcha.autoInit()`
105+
106+
## License
107+
108+
MIT

packages/client/src/client.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import { CaptchaWidget } from './widget';
1010
import { InvisibleSession } from './invisible';
1111
import { EnvironmentalCollector } from './collectors/environment';
12-
import { getServerUrl, setServerUrl } from './config';
12+
import { getServerUrl, setServerUrl, setBasePath } from './config';
1313
import type { InvisibleOptions, VerifyResponse, WidgetOptions } from './types';
1414

1515
type AnyWidget = CaptchaWidget | InvisibleSession;
@@ -18,7 +18,7 @@ export interface WebDecoyCaptchaAPI {
1818
widgets: Map<string, AnyWidget>;
1919
get serverUrl(): string | null;
2020
set serverUrl(url: string | null);
21-
configure(options: { serverUrl?: string }): void;
21+
configure(options: { serverUrl?: string; basePath?: string }): void;
2222
render(container: string | HTMLElement, options?: WidgetOptions): string;
2323
getResponse(widgetId: string): string | null;
2424
reset(widgetId: string): void;
@@ -41,8 +41,9 @@ export const WebDecoyCaptcha: WebDecoyCaptchaAPI = {
4141
setServerUrl(url);
4242
},
4343

44-
configure(options: { serverUrl?: string }): void {
44+
configure(options: { serverUrl?: string; basePath?: string }): void {
4545
if (options.serverUrl) setServerUrl(options.serverUrl);
46+
if (options.basePath) setBasePath(options.basePath);
4647
},
4748

4849
render(container: string | HTMLElement, options?: WidgetOptions): string {

packages/client/src/config.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,28 @@
44
* Replaces the mutable `FCaptcha.serverUrl` global from the reference client so
55
* the collectors and PoW manager can read the configured server origin without
66
* a circular import on the public API object.
7+
*
8+
* Endpoint paths align with the `@webdecoy/node` captcha handler
9+
* (`createCaptchaEndpoints`), whose default base path is `/__webdecoy`.
710
*/
811

912
let serverUrl: string | null = null;
13+
let basePath = '/__webdecoy';
1014

11-
/** Origin that serves the WebDecoy captcha endpoints (`/api/pow/challenge`, etc). */
15+
/** Origin that serves the WebDecoy captcha endpoints. */
1216
export function getServerUrl(): string | null {
1317
return serverUrl;
1418
}
1519

1620
export function setServerUrl(url: string | null): void {
1721
serverUrl = url;
1822
}
23+
24+
/** Base path the captcha endpoints are mounted under (default `/__webdecoy`). */
25+
export function getBasePath(): string {
26+
return basePath;
27+
}
28+
29+
export function setBasePath(path: string): void {
30+
basePath = path.replace(/\/$/, '');
31+
}

0 commit comments

Comments
 (0)