diff --git a/README.md b/README.md
index f6ca09f..e23c161 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ It asks for a toy. You drive the claw with the joystick (or arrow keys), hit the
No physics engine behind it, just damped springs and scripted phases in one rAF loop. React state only changes when the phase changes, the rest is transforms written through refs, so it stays smooth even on weak devices.
-Obvious but worth saying: this checks that someone is *playing*, not who they are. Keep it in front of your real checks, don't replace them with it.
+Obvious but worth saying: out of the box this checks that someone is *playing*, not who they are — `onVerify()` is a client callback a bot can call directly. For a real guarantee, wire up the [server module](#server-verification) below (signed challenges, proof-of-work, behavioral scoring). Keep it in front of your real checks either way, don't replace them with it.
@@ -37,7 +37,82 @@ Leave `target` off and every mount asks for a different random toy. Or pin one:
The 12 toy ids: duck, bear, panda, bunny, dino, penguin, fox, frog, whale, cat, puppy, unicorn.
-Other props: `title` (the heading), `assetBase`, `className`. That's all of them.
+Other props: `title` (the heading), `assetBase`, `className`. That's all of them — unless you want server-backed verification (see below).
+
+## server verification
+
+The widget above is **client-only**: `onVerify()` fires the moment the right toy lands, and there's nothing a backend can trust in that. A bot can read this source and call the callback directly. That's fine for friction gates (slow spammers down, keep real users happy), not for anything that needs an actual guarantee.
+
+For real guarantees, ship the companion server module. It issues HMAC-signed challenges, the client solves a proof-of-work and plays the game (collecting behavioral + anti-automation signals), and only the server mints a token you can verify on your protected endpoints. The token can't be forged without your secret.
+
+```bash
+npm install playcaptcha
+```
+
+**Server** (Node, any framework — the module is framework-agnostic):
+
+```ts
+import { createVerifier, createFetchHandler } from 'playcaptcha/server'
+
+// keep this secret on the server — long random string, in env, never bundled
+const verifier = createVerifier({
+ secret: process.env.PLAYCAPTCHA_SECRET!,
+ powBits: 14, // leading-zero bits; 14 ≈ a few hundred-k iters, <1s on a laptop
+ challengeTtlMs: 5 * 60_000,
+ tokenTtlMs: 10 * 60_000,
+ minScore: 0.5, // behavioral humanness threshold (0..1)
+ // replayStore: new MemoryStore(), // opt into single-use challenges (blocks replay)
+})
+
+// ready-made Fetch handler (GET issue / POST verify) — Cloudflare Workers,
+// Hono, Next.js route handlers, Bun.serve all speak this shape
+export const onRequest = createFetchHandler(verifier)
+```
+
+Mount it on any path. `GET /` issues a challenge, `POST /` verifies one. On your protected endpoints, validate the token:
+
+```ts
+import { createVerifier } from 'playcaptcha/server'
+const verifier = createVerifier({ secret: process.env.PLAYCAPTCHA_SECRET! })
+
+function isHuman(req: Request): boolean {
+ const token = req.headers.get('X-Captcha-Token') ?? ''
+ const { ok } = verifier.verifyToken(token) // stateless — checks HMAC + expiry
+ return ok
+}
+```
+
+**Client** — point the widget at those endpoints:
+
+```tsx
+import { ClawCaptcha } from 'playcaptcha'
+import 'playcaptcha/clawcaptcha.css'
+
+ {
+ if (result?.ok) {
+ // result.token is the signed token — send it with your real requests
+ localStorage.setItem('captcha', result.token)
+ unlock()
+ }
+ }}
+/>
+```
+
+In server mode the green "Verified" state only flips **after** the server confirms — a bot that fakes the client can't make the UI lie. The props: `challengeUrl`/`getChallenge` (fetch a challenge) and `verifyUrl`/`verifyAttempt` (post it back). Bring your own transport by passing the `*Challenge`/`verifyAttempt` functions instead of URLs.
+
+### how it actually defends
+
+- **Signed challenges & tokens** — an attacker can't mint a valid token without your server secret. This is the load-bearing piece; everything else raises the bar.
+- **Proof-of-work** — the client grinds SHA-256 to a target difficulty before it can verify. Free for one real user, costly at spam scale. Defaults to 14 bits (≈ a few hundred-thousand hashes).
+- **Behavioral signals** — joystick trajectory (curvature, jitter, reversals), time-to-first-action, total duration, grab-attempt count. Bots that drive in straight lines at constant speed score low.
+- **Anti-automation** — `event.isTrusted` on every input (synthetic `dispatchEvent` calls are `isTrusted=false`), `navigator.webdriver`, and automation user-agent strings trip a hard veto.
+
+### honest limits
+
+The client reports its own signals and its own PoW solution — the server can't *prove* the grab happened, only that a valid challenge came back with a valid solution and a high score. The real defenses are the unforgeable token and the PoW cost; the behavioral score catches naive bot scripts. This is the same shape as reCAPTCHA / hCaptcha. If you need true proof-of-work-at-the-edge or ML risk scoring, layer something stronger on top. For multi-instance deployments, implement `ReplayStore` against Redis so challenges and tokens can't be replayed across instances.
## theming
diff --git a/package-lock.json b/package-lock.json
index 159f817..efa83ad 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,17 +1,18 @@
{
"name": "playcaptcha",
- "version": "0.1.0",
+ "version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "playcaptcha",
- "version": "0.1.0",
+ "version": "0.2.0",
"license": "MIT",
"dependencies": {
"motion": "^12.0.0"
},
"devDependencies": {
+ "@types/node": "^26.1.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"tsup": "^8.3.0",
@@ -860,13 +861,22 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/node": {
+ "version": "26.1.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz",
+ "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1002,7 +1012,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
- "peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -1263,7 +1272,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -1432,7 +1440,8 @@
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/source-map": {
"version": "0.7.6",
@@ -1596,7 +1605,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -1611,6 +1619,13 @@
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
"dev": true,
"license": "MIT"
+ },
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "dev": true,
+ "license": "MIT"
}
}
}
diff --git a/package.json b/package.json
index 31dbb27..7033f00 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "playcaptcha",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "claw machine captcha for react - grab the right toy to prove you're human",
"type": "module",
"license": "MIT",
@@ -12,6 +12,10 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
+ "./server": {
+ "types": "./dist/server/index.d.ts",
+ "import": "./dist/server/index.js"
+ },
"./clawcaptcha.css": "./dist/clawcaptcha.css"
},
"sideEffects": [
@@ -33,6 +37,7 @@
"motion": "^12.0.0"
},
"devDependencies": {
+ "@types/node": "^26.1.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"tsup": "^8.3.0",
diff --git a/src/ClawCaptcha.tsx b/src/ClawCaptcha.tsx
index 69aa870..0ff935e 100644
--- a/src/ClawCaptcha.tsx
+++ b/src/ClawCaptcha.tsx
@@ -2,6 +2,12 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { AnimatePresence, motion, MotionConfig, useReducedMotion } from 'motion/react'
import { TOY_META, type ToyId } from './toys.ts'
import { CLAW_ARM_L, CLAW_ARM_R, CLAW_BODY, CLAW_PIVOT } from './clawArt.ts'
+import {
+ solvePow,
+ SignalCollector,
+ type Challenge,
+ type VerifyResult,
+} from './verify/index.ts'
/*
* ClawCaptcha — a claw-machine human check, operated like the real thing.
@@ -212,15 +218,29 @@ type Soft = {
type Phase = 'idle' | 'seq' | 'carry' | 'toTray' | 'celebrate' | 'deny' | 'return' | 'done'
export interface ClawCaptchaProps {
- /** Which toy the challenge asks for. A random toy each mount when omitted. */
+ /** Which toy the challenge asks for. A random toy each mount when omitted.
+ * In server mode, the challenge's target wins — pass this only to pin a toy
+ * when NOT using server verification. */
target?: ToyId
- /** Fired once when the right toy lands in the tray. */
- onVerify?: () => void
+ /** Fired once when the right toy lands in the tray.
+ * - Client-only mode: called with no argument (backward compatible).
+ * - Server mode: called with `{ ok, token, score }` once the server confirms. */
+ onVerify?: (result?: VerifyResult) => void
/** Heading shown above the machine. */
title?: string
/** Where the toy PNGs are served from. */
assetBase?: string
className?: string
+
+ // ---- server-backed verification (optional — omit all to stay client-only) ----
+ /** GET a challenge from this URL. Enables server verification. */
+ challengeUrl?: string
+ /** Or supply your own challenge fetcher (overrides challengeUrl). */
+ getChallenge?: (target?: ToyId) => Promise
+ /** POST the attempt to this URL for verification. */
+ verifyUrl?: string
+ /** Or supply your own verifier (overrides verifyUrl). */
+ verifyAttempt?: (input: import('./verify/types.ts').AttemptInput) => Promise
}
export function ClawCaptcha({
@@ -229,16 +249,29 @@ export function ClawCaptcha({
title = 'Verify you’re human',
assetBase = '/toys/',
className,
+ challengeUrl,
+ getChallenge,
+ verifyUrl,
+ verifyAttempt,
}: ClawCaptchaProps) {
const reduce = useReducedMotion()
+ // server-backed mode is on if ANY of the four server props is set. In that
+ // mode the server's challenge decides the target, PoW runs in the background,
+ // and the green "verified" state only flips after the server confirms.
+ const serverMode = !!(challengeUrl || getChallenge || verifyUrl || verifyAttempt)
+
// unpinned challenges ask for a different toy every mount (stable within one)
const [autoTarget] = useState(() => TOY_SET[Math.floor(Math.random() * TOY_SET.length)].toy)
- const target = targetProp ?? autoTarget
+
+ // the challenge's target wins in server mode; otherwise the prop / auto target
+ const [challenge, setChallenge] = useState(null)
+ const target: ToyId = challenge?.target ?? targetProp ?? autoTarget
const [phase, setPhase] = useState('idle')
const [infoOpen, setInfoOpen] = useState(false)
const [verified, setVerified] = useState(false)
+ const [verifying, setVerifying] = useState(false)
const [message, setMessage] = useState(null)
const [overTray, setOverTray] = useState(false)
const [trayMode, setTrayMode] = useState<'' | 'open' | 'win' | 'no'>('')
@@ -264,6 +297,14 @@ export function ClawCaptcha({
const onVerifyRef = useRef(onVerify)
onVerifyRef.current = onVerify
+ // server-mode plumbing: the PoW solution (resolved before the user finishes),
+ // the signal collector (per-mount), and refs the rAF loop reads to know
+ // whether to gate the verified flip on a server round-trip.
+ const powRef = useRef(null)
+ const signalsRef = useRef(null)
+ if (signalsRef.current === null) signalsRef.current = new SignalCollector()
+ const serverVerifyRef = useRef<(() => Promise) | null>(null)
+
const sim = useRef({
x: GW / 2,
y: HOME_Y,
@@ -309,8 +350,11 @@ export function ClawCaptcha({
phaseRef.current = p
setPhase(p)
}
- const api = useRef({ setPhaseBoth, setMessage, setVerified, setOverTray, setTrayMode })
- api.current = { setPhaseBoth, setMessage, setVerified, setOverTray, setTrayMode }
+ const api = useRef({ setPhaseBoth, setMessage, setVerified, setVerifying, setOverTray, setTrayMode })
+ api.current = { setPhaseBoth, setMessage, setVerified, setVerifying, setOverTray, setTrayMode }
+
+ // set once on mount; the rAF loop reads it to gate the verdict
+ const verifyingRef = useRef(false)
const targetIdx = useMemo(() => pile.findIndex((p) => p.toy === target), [pile, target])
@@ -721,9 +765,48 @@ export function ClawCaptcha({
// green ring + check + dimmed glass. No hop, no waggle.
if (s.stage === 'beat') {
if (stageP(0.28, dt) >= 1) {
- nextStage('shine')
- api.current.setVerified(true) // ring + check + dim run together
- onVerifyRef.current?.()
+ // server-backed: gate the verdict on an async round-trip. The rAF
+ // loop can't await, so we flip a flag, kick off the POST, and let
+ // its .then() do the verified/deny transition. The loop idles here
+ // (stage stays 'beat', already at p>=1) until the promise resolves.
+ const run = serverVerifyRef.current
+ if (run && !verifyingRef.current) {
+ verifyingRef.current = true
+ api.current.setVerifying(true)
+ run()
+ .then((result) => {
+ if (result.ok) {
+ nextStage('shine')
+ api.current.setVerified(true) // ring + check + dim run together
+ api.current.setVerifying(false)
+ verifyingRef.current = false
+ onVerifyRef.current?.(result)
+ } else {
+ // server rejected — treat like a wrong-toy deny so the user
+ // can retry, but surface the server's reason if it gave one
+ api.current.setVerifying(false)
+ verifyingRef.current = false
+ api.current.setTrayMode('no')
+ api.current.setMessage(
+ result.reason ? `Couldn’t verify: ${result.reason}` : 'Verification failed. Try again.',
+ )
+ api.current.setPhaseBoth('deny')
+ }
+ })
+ .catch(() => {
+ api.current.setVerifying(false)
+ verifyingRef.current = false
+ api.current.setTrayMode('no')
+ api.current.setMessage('Network error during verification. Try again.')
+ api.current.setPhaseBoth('deny')
+ })
+ }
+ // client-only: fire immediately, same as before
+ if (!run) {
+ nextStage('shine')
+ api.current.setVerified(true)
+ onVerifyRef.current?.()
+ }
}
} else if (s.stage === 'shine') {
if (stageP(0.7, dt) >= 1) api.current.setPhaseBoth('done')
@@ -768,11 +851,78 @@ export function ClawCaptcha({
return () => cancelAnimationFrame(raf)
}, [reduce, targetIdx, target, pile])
+ // ---- server-backed verification: fetch the challenge on mount, solve the
+ // PoW in the background (it finishes well before a human does), and stand up
+ // a verify closure the rAF loop calls when the right toy lands. Skipped
+ // entirely in client-only mode. ----
+ useEffect(() => {
+ if (!serverMode) {
+ serverVerifyRef.current = null
+ return
+ }
+ let cancelled = false
+ const powAbort = new AbortController()
+
+ const fetcher =
+ getChallenge ??
+ (async (t?: ToyId) => {
+ const url = challengeUrl + (t ? `?target=${encodeURIComponent(t)}` : '')
+ const res = await fetch(url, { headers: { accept: 'application/json' } })
+ if (!res.ok) throw new Error(`challenge fetch failed: ${res.status}`)
+ return (await res.json()) as Challenge
+ })
+
+ fetcher(targetProp)
+ .then((ch) => {
+ if (cancelled) return
+ setChallenge(ch)
+ // kick off the PoW — it resolves into powRef; the verify closure waits
+ // on the same promise so it can't fire before the solve is done
+ const powPromise = solvePow(ch.pow, { signal: powAbort.signal }).then(
+ (sol) => {
+ powRef.current = sol
+ return sol
+ },
+ )
+ // build the verify closure the rAF loop will invoke. Returns the
+ // server's verdict; the loop's .then() handles the UI transition.
+ serverVerifyRef.current = async () => {
+ const signals = signalsRef.current!.build()
+ const pow = await powPromise
+ const input = { challenge: ch, pow, signals }
+ if (verifyAttempt) return verifyAttempt(input)
+ const res = await fetch(verifyUrl!, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
+ body: JSON.stringify(input),
+ })
+ if (!res.ok) throw new Error(`verify failed: ${res.status}`)
+ return (await res.json()) as VerifyResult
+ }
+ })
+ .catch(() => {
+ if (!cancelled) setMessage('Couldn’t reach the verification service.')
+ })
+
+ return () => {
+ cancelled = true
+ powAbort.abort()
+ serverVerifyRef.current = null
+ }
+ // re-run only if the server-mode config identity changes; target is handled
+ // by the challenge that comes back, not by re-fetching
+ }, [serverMode, challengeUrl, getChallenge, verifyUrl, verifyAttempt, targetProp])
+
// ---- controls ----
const action = () => {
const s = sim.current
if (verified) return
+ // server mode: no-op until the challenge + PoW are staged (button is also
+ // disabled, but keyboard Space bypasses that)
+ if (serverMode && !serverVerifyRef.current) return
if (phaseRef.current === 'idle') {
+ // server mode: count every grab attempt for the behavioral score
+ if (serverMode) signalsRef.current?.grabAttempt()
setMessage(null)
s.close = 0
s.stage = 'antic'
@@ -812,6 +962,10 @@ export function ClawCaptcha({
if (!d || e.pointerId !== d.id) return
const dx = Math.max(-26, Math.min(26, e.clientX - d.startX))
dir.current = dx / 26
+ // server mode: record the joystick trajectory + whether the event was
+ // dispatched by real hardware (isTrusted). Synthetic events are isTrusted
+ // false — a strong automation tell.
+ if (serverMode) signalsRef.current?.pointerMove(dx, e.nativeEvent.isTrusted)
// a stick only ROTATES around its ball joint — it never leaves the socket
if (stickEl.current) stickEl.current.style.transform = `rotate(${(dx * 1.05).toFixed(1)}deg)`
}
@@ -834,9 +988,11 @@ export function ClawCaptcha({
if (e.key === 'ArrowLeft') {
e.preventDefault()
dir.current = -1
+ if (serverMode) signalsRef.current?.keyPress(-1, e.nativeEvent.isTrusted)
} else if (e.key === 'ArrowRight') {
e.preventDefault()
dir.current = 1
+ if (serverMode) signalsRef.current?.keyPress(1, e.nativeEvent.isTrusted)
} else if ((e.key === ' ' || e.key === 'Enter') && !e.repeat) {
e.preventDefault()
action()
@@ -847,7 +1003,10 @@ export function ClawCaptcha({
}
const t = TOY_META[target]
- const busy = phase !== 'idle' && phase !== 'carry'
+ // busy = mid-animation OR the server is mid-verify OR (server mode and the
+ // challenge/PoW isn't ready yet — the user would just hit a missing closure)
+ const challengeLoading = serverMode && !serverVerifyRef.current
+ const busy = verifying || challengeLoading || (phase !== 'idle' && phase !== 'carry')
const stepNo = verified || phase === 'carry' || phase === 'toTray' || phase === 'celebrate' ? 3 : phase === 'seq' ? 2 : 1
const carried = sim.current.carried
const carriedW = carried >= 0 ? pile[carried].w : 80
@@ -870,8 +1029,14 @@ export function ClawCaptcha({
>
{verified ? (
'You’re human. Nice catch.'
+ ) : verifying ? (
+ 'Verifying with the server…'
+ ) : challengeLoading ? (
+ 'Preparing a challenge…'
) : message ? (
message
) : (
diff --git a/src/clawcaptcha.css b/src/clawcaptcha.css
index a9bbc67..981d706 100644
--- a/src/clawcaptcha.css
+++ b/src/clawcaptcha.css
@@ -79,6 +79,21 @@
background: var(--cc-green);
}
+/* server round-trip in flight — a calm pulse so the brief wait reads as
+ intentional work, not a freeze. prefers-reduced-motion drops the animation. */
+.clawcap-shield--verifying {
+ color: var(--cc-green);
+ background: color-mix(in srgb, var(--cc-green) 14%, var(--cc-fill));
+ animation: clawcap-shield-pulse 1.1s ease-in-out infinite;
+}
+@keyframes clawcap-shield-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.45; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .clawcap-shield--verifying { animation: none; opacity: 0.75; }
+}
+
/* the help icon is a real button */
button.clawcap-help {
border: none;
diff --git a/src/index.ts b/src/index.ts
index 4b4ece2..08496d0 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,2 +1,18 @@
+// Public client API: the ClawCaptcha component + the verification utilities
+// you'd reach for if you were wiring up your own transport or scoring.
+// The server-side module ships separately as `playcaptcha/server`.
export { ClawCaptcha, type ClawCaptchaProps } from './ClawCaptcha.tsx'
export { TOY_META, type ToyId } from './toys.ts'
+export {
+ solvePow,
+ isValidPow,
+ SignalCollector,
+ scoreSignals,
+ type Challenge,
+ type PowSpec,
+ type PowSolution,
+ type ClientSignals,
+ type AttemptInput,
+ type VerifyResult,
+ type TokenPayload,
+} from './verify/index.ts'
diff --git a/src/server/handlers.ts b/src/server/handlers.ts
new file mode 100644
index 0000000..e7125e6
--- /dev/null
+++ b/src/server/handlers.ts
@@ -0,0 +1,91 @@
+/*
+ * Reference Fetch handler — a single function turning a Verifier into a
+ * `(Request) => Promise`. Standard Web Fetch shape, so it drops
+ * directly into:
+ * - Cloudflare Workers (`export default { fetch }`)
+ * - Hono / Nano Fetch apps
+ * - Next.js Route Handlers (export async function POST(req) { return handler(req) })
+ * - Bun.serve
+ * - Express via a small adapter (req → Request)
+ *
+ * Routes:
+ * GET / → issue a challenge (optional ?target=duck)
+ * POST / → verify an attempt (body: AttemptInput JSON)
+ *
+ * Mount it on whatever path you like — the handler doesn't care about the URL,
+ * only the method. For token verification on your protected endpoints, call
+ * `verifier.verifyToken(token)` directly; that doesn't need an HTTP route.
+ */
+
+import type { AttemptInput, Challenge, VerifyResult } from '../verify/types.ts'
+import { TOY_META, type ToyId } from '../toys.ts'
+import type { Verifier } from './index.ts'
+
+export interface HandlerOptions {
+ /** Allowed origin for CORS (default '*'). Set to your site in production. */
+ corsOrigin?: string | ((req: Request) => string)
+}
+
+export function createFetchHandler(verifier: Verifier, opts: HandlerOptions = {}) {
+ const { corsOrigin = '*' } = opts
+
+ const corsHeaders = (req: Request): Record => {
+ const origin = typeof corsOrigin === 'function' ? corsOrigin(req) : corsOrigin
+ return {
+ 'Access-Control-Allow-Origin': origin,
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type',
+ 'Vary': 'Origin',
+ }
+ }
+
+ return async (req: Request): Promise => {
+ const cors = corsHeaders(req)
+
+ // CORS preflight
+ if (req.method === 'OPTIONS') {
+ return new Response(null, { status: 204, headers: cors })
+ }
+
+ try {
+ if (req.method === 'GET') {
+ const url = new URL(req.url)
+ const targetParam = url.searchParams.get('target')
+ const target =
+ targetParam && targetParam in TOY_META ? (targetParam as ToyId) : undefined
+ const challenge: Challenge = await verifier.issueChallenge(target)
+ return json(challenge, 200, cors)
+ }
+
+ if (req.method === 'POST') {
+ let body: AttemptInput
+ try {
+ body = (await req.json()) as AttemptInput
+ } catch {
+ return json({ ok: false, score: 0, token: '', reason: 'invalid JSON body' } satisfies VerifyResult, 400, cors)
+ }
+ if (!body?.challenge || !body?.pow || !body?.signals) {
+ return json({ ok: false, score: 0, token: '', reason: 'missing fields' } satisfies VerifyResult, 400, cors)
+ }
+ const result: VerifyResult = await verifier.verifyAttempt(body)
+ // 200 either way — the client reads `ok` from the body. A 4xx would
+ // trip fetch()'s throw-on-error patterns unnecessarily; a failed
+ // verification is an expected response, not an HTTP error.
+ return json(result, 200, cors)
+ }
+
+ return json({ ok: false, score: 0, token: '', reason: 'method not allowed' } satisfies VerifyResult, 405, cors)
+ } catch (err) {
+ // never leak internals — a generic 500
+ const msg = err instanceof Error ? err.message : 'internal error'
+ return json({ ok: false, score: 0, token: '', reason: msg } satisfies VerifyResult, 500, cors)
+ }
+ }
+}
+
+function json(body: unknown, status: number, cors: Record): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json', ...cors },
+ })
+}
diff --git a/src/server/index.ts b/src/server/index.ts
new file mode 100644
index 0000000..f91a3d2
--- /dev/null
+++ b/src/server/index.ts
@@ -0,0 +1,248 @@
+/*
+ * Stateless human-verification server module.
+ *
+ * createVerifier({ secret, ... }) → { issueChallenge, verifyAttempt, verifyToken }
+ *
+ * - issueChallenge(): mint an HMAC-signed Challenge the client round-trips back.
+ * Nothing is stored server-side by default (opt into ReplayStore for single-use).
+ * - verifyAttempt(input): re-check the signature, expiry, PoW, and behavioral
+ * score; if it all passes, mint a server-signed token the consumer trusts.
+ * - verifyToken(token): decode + check the token's signature and expiry on the
+ * consumer's protected endpoint. Stateless — no DB lookup needed.
+ *
+ * Uses only Node's built-in `crypto`. Framework-agnostic: hand the methods to
+ * whatever request handler you use (see handlers.ts for a Fetch-shaped shim),
+ * or call them directly from a worker / cron.
+ *
+ * The client cannot prove the grab happened — it reports its own signals and
+ * its own PoW solution. The real defenses here are: (1) the unforgeable HMAC
+ * signature (an attacker can't mint a valid token without the server secret),
+ * (2) the real CPU cost of the PoW at scale, and (3) the heuristic score that
+ * catches naive bot scripts. This is the same shape as reCAPTCHA / hCaptcha.
+ */
+
+import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
+import type {
+ AttemptInput,
+ Challenge,
+ ClientSignals,
+ PowSpec,
+ TokenPayload,
+ VerifyResult,
+} from '../verify/types.ts'
+import { TOY_META, type ToyId } from '../toys.ts'
+import { scoreSignals } from '../verify/signals.ts'
+import type { ReplayStore } from './store.ts'
+
+export interface VerifierOptions {
+ /** HMAC secret. KEEP THIS SERVER-SIDE. Used to sign challenges and tokens. */
+ secret: string
+ /** PoW difficulty in leading-zero bits. Default 14 (≈ a few hundred-k iters). */
+ powBits?: number
+ /** How long a challenge is good for, in ms. Default 5 min. */
+ challengeTtlMs?: number
+ /** How long a verified token is good for, in ms. Default 10 min. */
+ tokenTtlMs?: number
+ /** Minimum humanness score to issue a token. Default 0.5. */
+ minScore?: number
+ /** Namespaces tokens so you can run multiple verifiers with different secrets. */
+ audience?: string
+ /** Opt-in single-use protection. See store.ts. */
+ replayStore?: ReplayStore
+ /** Override the toy pool (default: all 12). Useful for tests. */
+ toys?: ToyId[]
+ /** Override the random source (default: crypto.randomBytes). For tests. */
+ rng?: (n: number) => Buffer
+}
+
+export interface Verifier {
+ issueChallenge(target?: ToyId): Promise | Challenge
+ verifyAttempt(input: AttemptInput): Promise | VerifyResult
+ verifyToken(token: string): { ok: true; payload: TokenPayload } | { ok: false; reason: string }
+}
+
+const DEFAULT_TOYS = Object.keys(TOY_META) as ToyId[]
+
+export function createVerifier(opts: VerifierOptions): Verifier {
+ const secret = opts.secret
+ if (!secret || secret.length < 16) {
+ throw new Error('createVerifier: secret must be at least 16 chars (use a long random string)')
+ }
+ const powBits = opts.powBits ?? 14
+ const challengeTtlMs = opts.challengeTtlMs ?? 5 * 60_000
+ const tokenTtlMs = opts.tokenTtlMs ?? 10 * 60_000
+ const minScore = opts.minScore ?? 0.5
+ const audience = opts.audience ?? 'default'
+ const toys = opts.toys ?? DEFAULT_TOYS
+ const rng = opts.rng ?? ((n: number) => randomBytes(n))
+ const store = opts.replayStore
+
+ // ---- HMAC helpers ----
+ // base64url without padding — compact, URL-safe tokens
+ const b64url = (buf: Buffer | string) =>
+ Buffer.from(buf).toString('base64url')
+
+ /** Sign a canonical JSON encoding of `payload`. Keyed so field order can't
+ * matter and extra fields can't sneak in. */
+ const sign = (payload: unknown): string => {
+ const json = stableJson(payload)
+ return b64url(createHmac('sha256', secret).update(json).digest())
+ }
+ const verifySig = (payload: unknown, sig: string): boolean => {
+ const expected = sign(payload)
+ const a = Buffer.from(sig)
+ const b = Buffer.from(expected)
+ return a.length === b.length && timingSafeEqual(a, b)
+ }
+
+ // ---- PoW (server-side authoritative check) ----
+ // MUST match src/verify/pow.ts: SHA-256(`${seed}:${solution}`), count leading
+ // zero bits. Synchronous here (Node crypto is sync); the client is async.
+ const zeroBits = (spec: PowSpec, solution: string): number => {
+ const digest = createHash('sha256').update(Buffer.from(`${spec.seed}:${solution}`)).digest()
+ return countLeadingZeros(digest)
+ }
+
+ const issueChallenge: Verifier['issueChallenge'] = (target?: ToyId): Challenge => {
+ const t: ToyId = target ?? toys[Math.floor(Math.random() * toys.length)]
+ const now = Date.now()
+ const id = b64url(rng(18))
+ const pow: PowSpec = { seed: b64url(rng(16)), bits: powBits }
+ const body = { id, target: t, pow, expiresAt: now + challengeTtlMs }
+ const sig = sign(body)
+ return { ...body, sig }
+ }
+
+ const verifyAttempt: Verifier['verifyAttempt'] = async (input: AttemptInput): Promise => {
+ const { challenge, pow, signals } = input
+ // 1. signature — reject any tampering with the challenge body
+ const body = { id: challenge.id, target: challenge.target, pow: challenge.pow, expiresAt: challenge.expiresAt }
+ if (!verifySig(body, challenge.sig)) {
+ return fail('invalid challenge signature')
+ }
+ // 2. expiry
+ if (Date.now() > challenge.expiresAt) return fail('challenge expired')
+ // 3. PoW (authoritative re-check — must match the client's hash)
+ if (challenge.pow.bits >= 1) {
+ if (zeroBits(challenge.pow, pow.solution) < challenge.pow.bits) {
+ return fail('proof-of-work invalid')
+ }
+ }
+ // 4. optional replay protection
+ if (store) {
+ const ok = await store.claimChallenge(challenge.id)
+ if (!ok) return fail('challenge already used')
+ }
+ // 5. behavioral score
+ const score = scoreSignals(signals)
+ if (score.total < minScore) {
+ return { ok: false, score: score.total, token: '', reason: score.reasons.join('; ') || 'below score threshold' }
+ }
+ // 6. mint the token
+ const payload: TokenPayload = {
+ target: challenge.target,
+ score: score.total,
+ issuedAt: Date.now(),
+ expiresAt: Date.now() + tokenTtlMs,
+ audience,
+ }
+ const token = encodeToken(payload, sign)
+ return { ok: true, score: score.total, token, reason: 'verified' }
+ }
+
+ const verifyToken: Verifier['verifyToken'] = (token: string) => {
+ const decoded = decodeToken(token)
+ if (!decoded) return { ok: false, reason: 'malformed token' }
+ const { payload, sig } = decoded
+ if (!verifySig(tokenPayloadBody(payload), sig)) return { ok: false, reason: 'bad signature' }
+ if (payload.audience !== audience) return { ok: false, reason: 'wrong audience' }
+ if (Date.now() > payload.expiresAt) return { ok: false, reason: 'token expired' }
+ return { ok: true, payload }
+ }
+
+ return { issueChallenge, verifyAttempt, verifyToken }
+}
+
+// ---- helpers exported for tests / advanced use ----
+
+function fail(reason: string): VerifyResult {
+ return { ok: false, score: 0, token: '', reason }
+}
+
+/** Canonical JSON: keys sorted, no whitespace. So {a:1,b:2} and {b:2,a:1} sign
+ * identically. */
+function stableJson(value: unknown): string {
+ if (value === null || typeof value !== 'object') return JSON.stringify(value)
+ if (Array.isArray(value)) return '[' + value.map(stableJson).join(',') + ']'
+ const obj = value as Record
+ const keys = Object.keys(obj).sort()
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableJson(obj[k])).join(',') + '}'
+}
+
+function countLeadingZeros(bytes: Buffer): number {
+ let bits = 0
+ for (let i = 0; i < bytes.length; i++) {
+ const b = bytes[i]
+ if (b === 0) {
+ bits += 8
+ continue
+ }
+ let m = 0x80
+ while ((b & m) === 0) {
+ bits++
+ m >>= 1
+ }
+ break
+ }
+ return bits
+}
+
+/** Token format: base64url(canonicalJson(payload)).sig
+ * The signature covers the SAME canonical JSON that's encoded, so what you
+ * verify is exactly what was signed — no decode-then-restringify ambiguity. */
+function encodeToken(payload: TokenPayload, sign: (p: unknown) => string): string {
+ const body = tokenPayloadBody(payload)
+ const json = stableJson(body)
+ const sig = sign(body)
+ const payloadB64 = Buffer.from(json).toString('base64url')
+ return `${payloadB64}.${sig}`
+}
+
+/** The fields of a TokenPayload that participate in the signature. `audience`
+ * included; `score` included (so a low-score token can't be inflated). */
+function tokenPayloadBody(p: TokenPayload): Record {
+ return {
+ target: p.target,
+ score: p.score,
+ issuedAt: p.issuedAt,
+ expiresAt: p.expiresAt,
+ audience: p.audience,
+ }
+}
+
+function decodeToken(
+ token: string,
+): { payload: TokenPayload; sig: string } | null {
+ const parts = token.split('.')
+ if (parts.length !== 2) return null
+ let payload: TokenPayload
+ try {
+ payload = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8'))
+ } catch {
+ return null
+ }
+ return { payload, sig: parts[1] }
+}
+
+export { MemoryStore, type ReplayStore } from './store.ts'
+export { createFetchHandler, type HandlerOptions } from './handlers.ts'
+export { scoreSignals } from '../verify/signals.ts'
+export type {
+ AttemptInput,
+ Challenge,
+ ClientSignals,
+ PowSpec,
+ PowSolution,
+ TokenPayload,
+ VerifyResult,
+} from '../verify/types.ts'
diff --git a/src/server/store.ts b/src/server/store.ts
new file mode 100644
index 0000000..862e912
--- /dev/null
+++ b/src/server/store.ts
@@ -0,0 +1,69 @@
+/*
+ * Optional replay-protection store. The verifier is stateless by default: a
+ * signed challenge can be solved and posted back at any time within its TTL.
+ * That's fine for most uses, but it means a captured (challenge, token) pair
+ * could be replayed within the window.
+ *
+ * If you care about single-use, pass a ReplayStore. An in-memory default is
+ * provided; implement the same interface against Redis / your DB for
+ * multi-instance deployments.
+ */
+
+/** A store that remembers seen challenge ids / used tokens until their TTL.
+ * All methods should be safe to call concurrently. */
+export interface ReplayStore {
+ /** Record a challenge id at issue time. Return false if it's already known
+ * (duplicate issue — shouldn't happen with a good id generator). */
+ sawChallenge(id: string, expiresAt: number): Promise | boolean
+ /** Atomically claim a challenge id for verification. Return false if it was
+ * already claimed (replay attempt). */
+ claimChallenge(id: string): Promise | boolean
+ /** Atomically mark a token as used. Return false if already used (replay). */
+ useToken(token: string, expiresAt: number): Promise | boolean
+}
+
+interface Entry {
+ expiresAt: number
+ claimed?: boolean
+}
+
+/** In-memory ReplayStore. Fine for single-process deployments. Entries are
+ * purged lazily on access; a periodic sweep is unnecessary for typical loads. */
+export class MemoryStore implements ReplayStore {
+ private challenges = new Map()
+ private tokens = new Map()
+
+ private sweep(map: Map) {
+ const now = Date.now()
+ for (const [k, v] of map) {
+ const exp = typeof v === 'number' ? v : v.expiresAt
+ if (exp <= now) map.delete(k)
+ }
+ }
+
+ sawChallenge(id: string, expiresAt: number): boolean {
+ this.sweep(this.challenges)
+ if (this.challenges.has(id)) return false
+ this.challenges.set(id, { expiresAt })
+ return true
+ }
+
+ /** Atomically claim a challenge id for verification. Return false if it was
+ * already claimed (replay attempt). First claim of an unseen id succeeds. */
+ claimChallenge(id: string): boolean {
+ this.sweep(this.challenges)
+ const e = this.challenges.get(id)
+ if (e?.claimed) return false
+ // record the claim even if we never saw it issued (defensive: it still
+ // blocks a second claim within the TTL)
+ this.challenges.set(id, { expiresAt: e?.expiresAt ?? Date.now() + 60_000, claimed: true })
+ return true
+ }
+
+ useToken(token: string, expiresAt: number): boolean {
+ this.sweep(this.tokens)
+ if (this.tokens.has(token)) return false
+ this.tokens.set(token, expiresAt)
+ return true
+ }
+}
diff --git a/src/verify/index.ts b/src/verify/index.ts
new file mode 100644
index 0000000..c755355
--- /dev/null
+++ b/src/verify/index.ts
@@ -0,0 +1,13 @@
+// Client-side verification barrel: types, PoW solver, and signal collector.
+// The server module (src/server) re-exports the shared types from here too.
+export { solvePow, isValidPow } from './pow.ts'
+export { SignalCollector, scoreSignals, type PointerSample, type ScoreBreakdown } from './signals.ts'
+export type {
+ Challenge,
+ PowSpec,
+ PowSolution,
+ ClientSignals,
+ AttemptInput,
+ VerifyResult,
+ TokenPayload,
+} from './types.ts'
diff --git a/src/verify/pow.ts b/src/verify/pow.ts
new file mode 100644
index 0000000..15e9e3d
--- /dev/null
+++ b/src/verify/pow.ts
@@ -0,0 +1,103 @@
+/*
+ * Client proof-of-work solver. The challenge is: find a `solution` string so
+ * that SHA-256(seed || ':' || solution) starts with `bits` zero bits.
+ *
+ * Runs in the browser via Web Crypto (crypto.subtle). The solver yields back to
+ * the event loop every few hundred iterations so the claw-machine rAF loop
+ * keeps animating smoothly — the user shouldn't notice the work happening.
+ *
+ * ~14 bits ≈ a few hundred-thousand iterations ≈ well under a second on a
+ * laptop. That's free for one real user and meaningfully costly for a bot farm
+ * hammering a target endpoint. It is NOT a hard barrier — a determined attacker
+ * with WASM or a GPU can crack it — it raises the cost, same role as PoW in any
+ * anti-abuse system.
+ */
+
+import type { PowSolution, PowSpec } from './types.ts'
+
+const enc = new TextEncoder()
+
+/** Count leading zero bits in a 32-byte SHA-256 digest. */
+function leadingZeros(bytes: Uint8Array): number {
+ let bits = 0
+ for (let i = 0; i < bytes.length; i++) {
+ const b = bytes[i]
+ if (b === 0) {
+ bits += 8
+ continue
+ }
+ // count leading zeros in this byte
+ let m = 0x80
+ while ((b & m) === 0) {
+ bits++
+ m >>= 1
+ }
+ break
+ }
+ return bits
+}
+
+/** Hash seed:solution and return leading-zero-bit count. Shared with the
+ * server (see server/index.ts solveHash) — keep them byte-for-byte identical. */
+async function zeroBits(spec: PowSpec, solution: string): Promise {
+ const data = enc.encode(`${spec.seed}:${solution}`)
+ const digest = await crypto.subtle.digest('SHA-256', data)
+ return leadingZeros(new Uint8Array(digest))
+}
+
+export interface SolveOptions {
+ /** Yield to the UI loop every this many iterations (default 400). */
+ yieldEvery?: number
+ /** Hard cap so a runaway solve can't pin a tab forever (default 5_000_000). */
+ maxAttempts?: number
+ /** Optional progress callback in [0,1) — purely for UI/telemetry. */
+ onProgress?: (p: number) => void
+ /** AbortSignal to cancel an in-flight solve. */
+ signal?: AbortSignal
+}
+
+/** Solve a PoW spec, returning the solution + iteration count. Rejects on
+ * abort or if maxAttempts is exceeded. The caller decides how to handle a
+ * rejection — the component shows a "Verification took too long" message. */
+export async function solvePow(spec: PowSpec, opts: SolveOptions = {}): Promise {
+ const { yieldEvery = 400, maxAttempts = 5_000_000, onProgress, signal } = opts
+ if (spec.bits < 1) return { solution: '', attempts: 0 }
+
+ // incremental solution space: base-36 counter strings. A real distribution,
+ // not "0", "1", ... — start at a random offset so two solvers on the same
+ // seed explore different regions (matters less here than for hashcash, but
+ // keeps the search feeling honest).
+ let n = Math.floor(Math.random() * 1e6)
+ let attempts = 0
+
+ // batch: check `batchSize` candidates per await, then yield.
+ // awaiting digest() once per candidate is slow; we interleave so each batch
+ // still releases the thread.
+ while (attempts < maxAttempts) {
+ if (signal?.aborted) throw new DOMException('PoW aborted', 'AbortError')
+
+ const solution = n.toString(36)
+ const z = await zeroBits(spec, solution)
+ if (z >= spec.bits) {
+ return { solution, attempts: attempts + 1 }
+ }
+ n++
+ attempts++
+
+ if (attempts % yieldEvery === 0) {
+ onProgress?.(Math.min(0.99, attempts / maxAttempts))
+ // setTimeout(0) is enough to let the rAF loop tick; we don't need a real
+ // macrotask boundary, just a chance for the animation frame to run.
+ await new Promise((r) => setTimeout(r, 0))
+ }
+ }
+
+ throw new Error('PoW exceeded max attempts')
+}
+
+/** Quick validity check a client can run on its own solution before posting
+ * (cheap sanity; the server re-checks authoritatively). */
+export async function isValidPow(spec: PowSpec, solution: string): Promise {
+ if (spec.bits < 1) return true
+ return (await zeroBits(spec, solution)) >= spec.bits
+}
diff --git a/src/verify/signals.ts b/src/verify/signals.ts
new file mode 100644
index 0000000..4245d3b
--- /dev/null
+++ b/src/verify/signals.ts
@@ -0,0 +1,254 @@
+/*
+ * Behavioral + anti-automation signal collection and scoring.
+ *
+ * Two halves:
+ * - SignalCollector: a tiny class the component instantiates per mount. It
+ * records pointer/keyboard samples and timing as the user plays, and tracks
+ * whether EVERY input event was isTrusted (real hardware) — synthetic
+ * dispatchEvent calls have isTrusted=false, a strong automation tell.
+ * - scoreSignals(): a pure function over ClientSignals returning a 0..1 score.
+ * SHARED LOGIC — the client uses it to gate its optimistic UI, the server
+ * uses it authoritatively. Keep them identical.
+ *
+ * HONESTY NOTE: all of these signals are client-reported and therefore
+ * spoofable. They feed a heuristic, not a guarantee. The load-bearing pieces of
+ * this system are the unforgeable HMAC token and the real PoW cost. Behavioral
+ * scoring raises the bar for naive bot scripts; it does not stop a determined
+ * attacker who reads this source.
+ */
+
+import type { ClientSignals } from './types.ts'
+
+/** A trajectory sample in machine-space coordinates (px, relative to the
+ * joystick start, performance.now() timestamp). */
+export interface PointerSample {
+ t: number
+ /** horizontal offset from drag start, in machine px (clamped to ±26). */
+ x: number
+}
+
+export class SignalCollector {
+ private readonly t0: number
+ private readonly pointer: PointerSample[] = []
+ private readonly keys: Array<{ t: number; dir: -1 | 1 }> = []
+ private firstActionAt: number | null = null
+ private attempts = 0
+ private allTrusted = true
+ private readonly webdriver: boolean
+ private readonly automationUa: boolean
+
+ constructor() {
+ this.t0 = now()
+ // sniffed once at construction; cheaper than re-checking per event
+ this.webdriver = safeNav('webdriver') === true
+ this.automationUa = sniffAutomationUa()
+ }
+
+ /** Record a joystick pointer move. Call from onStickMove. */
+ pointerMove(x: number, trusted: boolean) {
+ this.markFirst()
+ if (!trusted) this.allTrusted = false
+ // cap the buffer: we only need ~120 points to score a trajectory, and
+ // we don't want a long aimless drag to balloon the verify POST
+ if (this.pointer.length < 240) this.pointer.push({ t: now() - this.t0, x })
+ }
+
+ /** Record a keyboard direction press. Call from onKeyDown for arrows. */
+ keyPress(dir: -1 | 1, trusted: boolean) {
+ this.markFirst()
+ if (!trusted) this.allTrusted = false
+ if (this.keys.length < 240) this.keys.push({ t: now() - this.t0, dir })
+ }
+
+ /** One grab attempt was made (wrong or right — the component calls this on
+ * every `action()` that starts a grab). */
+ grabAttempt() {
+ this.attempts++
+ }
+
+ /** Snapshot the collected signals for the verify POST. */
+ build(): ClientSignals {
+ return {
+ pointer: this.pointer,
+ keys: this.keys,
+ timeToFirstAction: this.firstActionAt,
+ totalDuration: now() - this.t0,
+ attempts: this.attempts,
+ allEventsTrusted: this.allTrusted,
+ webdriver: this.webdriver,
+ automationUa: this.automationUa,
+ }
+ }
+
+ private markFirst() {
+ if (this.firstActionAt === null) this.firstActionAt = now() - this.t0
+ }
+}
+
+const now = () =>
+ typeof performance !== 'undefined' && typeof performance.now === 'function' ? performance.now() : Date.now()
+
+/** Read a property off `navigator` defensively (SSR / non-browser guards). */
+function safeNav(key: string): unknown {
+ try {
+ return (navigator as unknown as Record)[key]
+ } catch {
+ return undefined
+ }
+}
+
+/** Best-effort UA sniff for common automation drivers. Trivially spoofable —
+ * this is a low-value gate, not a real defense. */
+function sniffAutomationUa(): boolean {
+ try {
+ const ua = (navigator.userAgent || '').toLowerCase()
+ return (
+ ua.includes('headless') ||
+ ua.includes('puppeteer') ||
+ ua.includes('playwright') ||
+ ua.includes('selenium') ||
+ ua.includes('webdriver') ||
+ ua.includes('phantomjs')
+ )
+ } catch {
+ return false
+ }
+}
+
+// ---- scoring (pure, shared with the server) ----
+
+export interface ScoreBreakdown {
+ total: number
+ /** Each contributing signal and its 0..1 sub-score, for telemetry/logging. */
+ parts: Record
+ /** Why it scored low (empty when nothing tripped). */
+ reasons: string[]
+}
+
+/**
+ * Score a ClientSignals object into a 0..1 humanness heuristic.
+ *
+ * Hard vetoes (score 0): synthetic events (isTrusted=false), navigator.webdriver
+ * true, or an automation UA string. These are near-certain automation.
+ *
+ * Soft signals (weighted, never reaching 1.0 alone):
+ * - timing: humans take >250ms to first action and >1.5s total; a script that
+ * grabs in <100ms is suspicious.
+ * - trajectory: humans wobble. A perfectly straight, constant-velocity drag
+ * with no direction reversals reads as scripted.
+ * - cadence: a tiny number of pointer samples (e.g. 2) over a multi-second
+ * game suggests the input was injected, not dragged.
+ */
+export function scoreSignals(s: ClientSignals): ScoreBreakdown {
+ const parts: Record = {}
+ const reasons: string[] = []
+
+ // --- hard vetoes ---
+ if (!s.allEventsTrusted) {
+ parts.trusted = 0
+ reasons.push('untrusted input events')
+ return { total: 0, parts, reasons }
+ }
+ if (s.webdriver) {
+ parts.webdriver = 0
+ reasons.push('navigator.webdriver is true')
+ return { total: 0, parts, reasons }
+ }
+ if (s.automationUa) {
+ parts.ua = 0
+ reasons.push('automation user-agent')
+ return { total: 0, parts, reasons }
+ }
+
+ // --- timing ---
+ // first action: ~250ms-12s is the human band. Below 150ms is almost certainly
+ // scripted; above 30s is fine (a slow user) but we cap the bonus.
+ let timing = 0.6
+ if (s.timeToFirstAction != null) {
+ const t = s.timeToFirstAction
+ if (t < 150) timing = 0.05
+ else if (t < 250) timing = 0.35
+ else if (t <= 12000) timing = 1
+ else timing = 0.8 // slow but human
+ } else {
+ // no input recorded at all but somehow verifying — suspicious
+ timing = 0.1
+ reasons.push('no input recorded')
+ }
+ parts.timing = timing
+
+ // total duration: a full grab+carry+drop in <1.2s is implausible
+ let total = 0.6
+ if (s.totalDuration < 1200) total = 0.25
+ else if (s.totalDuration < 1800) total = 0.6
+ else total = 1
+ parts.duration = total
+
+ // --- trajectory realism (pointer) ---
+ // measures we use, all cheap:
+ // reversals: how often the drag flipped direction (humans do this a lot
+ // while aiming; a bot driving straight does it ~0-1 times)
+ // jitter: stdev of per-step dx — humans are noisy, bots are flat
+ // samples: raw count of pointer events; <3 over a real drag is suspect
+ let traj = 0.6
+ const pts = s.pointer
+ if (pts.length >= 3) {
+ const reversals = countReversals(pts.map((p) => p.x))
+ const jitter = stdev(deltas(pts.map((p) => p.x)))
+ // humans typically show several reversals + noticeable jitter on a 380px-wide
+ // machine; bots show ~0 reversals and ~0 jitter
+ const revScore = clamp01(reversals / 4) // ~4 reversals → full credit
+ const jitScore = clamp01(jitter / 1.6) // ~1.6px stdev → full credit
+ traj = 0.4 * revScore + 0.4 * jitScore + 0.2 // floor of 0.2 even for clean drags
+ } else if (s.keys.length >= 2) {
+ // keyboard-only players: score on key reversals instead
+ const reversals = countReversals(s.keys.map((k) => k.dir))
+ traj = 0.4 + 0.6 * clamp01(reversals / 3)
+ } else {
+ traj = 0.2
+ reasons.push('very few input samples')
+ }
+ parts.trajectory = traj
+
+ // --- attempts: a single clean attempt is normal; >5 wrong grabs is a bot
+ // brute-forcing positions. We don't punish 1-3 retries (a human misses). ---
+ let attemptScore = 1
+ if (s.attempts > 6) {
+ attemptScore = Math.max(0, 1 - (s.attempts - 6) * 0.15)
+ reasons.push(`${s.attempts} grab attempts`)
+ }
+ parts.attempts = attemptScore
+
+ // weighted blend — timing & trajectory carry the most weight
+ const total2 = clamp01(
+ 0.32 * timing + 0.18 * total + 0.32 * traj + 0.18 * attemptScore,
+ )
+ return { total: total2, parts, reasons }
+}
+
+const clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n)
+
+function deltas(xs: number[]): number[] {
+ const out: number[] = []
+ for (let i = 1; i < xs.length; i++) out.push(xs[i] - xs[i - 1])
+ return out
+}
+
+function countReversals(xs: number[]): number {
+ let n = 0
+ let prevSign = 0
+ for (let i = 1; i < xs.length; i++) {
+ const d = xs[i] - xs[i - 1]
+ const sign = d > 0 ? 1 : d < 0 ? -1 : 0
+ if (sign !== 0 && prevSign !== 0 && sign !== prevSign) n++
+ if (sign !== 0) prevSign = sign
+ }
+ return n
+}
+
+function stdev(xs: number[]): number {
+ if (xs.length === 0) return 0
+ const mean = xs.reduce((a, b) => a + b, 0) / xs.length
+ const variance = xs.reduce((a, b) => a + (b - mean) ** 2, 0) / xs.length
+ return Math.sqrt(variance)
+}
diff --git a/src/verify/types.ts b/src/verify/types.ts
new file mode 100644
index 0000000..de78cbe
--- /dev/null
+++ b/src/verify/types.ts
@@ -0,0 +1,105 @@
+/*
+ * Shared verification types — imported by BOTH the browser component and the
+ * Node/server module, so this file is environment-agnostic: no DOM globals,
+ * no `node:crypto`, no React. Pure shape definitions only.
+ *
+ * The flow is:
+ * 1. server issues a Challenge (HMAC-signed) and hands it to the client
+ * 2. client solves the proof-of-work and plays the game, collecting signals
+ * 3. client posts an AttemptInput back; server validates it into a VerifyResult
+ * 4. server-issued token is later checked with verifyToken on protected routes
+ */
+
+import type { ToyId } from '../toys.ts'
+
+/** Proof-of-work spec: find a `solution` so SHA-256(seed || solution) starts
+ * with `bits` zero bits. ~14 bits = a few hundred-thousand iterations,
+ * <1s on a laptop but real CPU at scale for an attacker. */
+export interface PowSpec {
+ seed: string
+ bits: number
+}
+
+/** A challenge issued by the server. `id` is opaque to the client; the whole
+ * object is what the client hands back (signed) so the server can re-validate
+ * without storing anything. */
+export interface Challenge {
+ id: string
+ /** A freshly sampled target the challenge asks for. When the client pins
+ * `target` itself, the server still has to agree — pass it in the request. */
+ target: ToyId
+ /** PoW to solve. */
+ pow: PowSpec
+ /** Unix ms, server clock. Attempts after this are rejected. */
+ expiresAt: number
+ /** HMAC of the above, base64url. Server re-computes on verify; the client
+ * never needs to read or trust this — it just round-trips it. */
+ sig: string
+}
+
+/** The PoW solution the client found. */
+export interface PowSolution {
+ solution: string
+ /** Iterations spent — reported for telemetry, not trusted. */
+ attempts: number
+}
+
+/** Behavioral + anti-automation signals collected during play. All of these
+ * are CLIENT-REPORTED and therefore spoofable by a determined attacker; they
+ * feed a heuristic score, not a hard guarantee. The load-bearing guarantees
+ * are the unforgeable signed token and the real PoW cost. */
+export interface ClientSignals {
+ /** Pointer samples from the joystick drag (machine-space px). Used to score
+ * trajectory realism — bots tend to drive straight lines at constant speed. */
+ pointer: Array<{ t: number; x: number }>
+ /** Keyboard samples (ArrowLeft/Right hold/release). */
+ keys: Array<{ t: number; dir: -1 | 1 }>
+ /** ms from challenge load to first interaction (joystick move or key). */
+ timeToFirstAction: number | null
+ /** ms from challenge load to the verify POST. */
+ totalDuration: number
+ /** Number of grab attempts (wrong + right). */
+ attempts: number
+ /** Did every input event have isTrusted === true? Synthetic dispatchEvent
+ * calls produce isTrusted=false — a strong automation tell. */
+ allEventsTrusted: boolean
+ /** navigator.webdriver at load (true = automation driver present). */
+ webdriver: boolean
+ /** Crude automation UA sniff (headless chromium, puppeteer, playwright, etc.).
+ * Best-effort only; trivial to spoof. */
+ automationUa: boolean
+}
+
+/** What the client posts to the verify endpoint. */
+export interface AttemptInput {
+ /** The original Challenge object, round-tripped. Server re-checks its sig. */
+ challenge: Challenge
+ pow: PowSolution
+ signals: ClientSignals
+}
+
+/** Result handed back to the client (and to onVerify). `token` is present iff
+ * `ok`; the consumer later calls verifyToken(token, secret) on their protected
+ * endpoint. */
+export interface VerifyResult {
+ ok: boolean
+ /** 0..1 heuristic humanness score. Only meaningful when ok. */
+ score: number
+ /** Server-signed, base64url token binding {target, score, issuedAt, expiresAt}.
+ * Empty string when !ok. */
+ token: string
+ /** Why it failed (or succeeded) — for client UI / logging. */
+ reason?: string
+}
+
+/** The decoded payload inside a token. Consumers get this from verifyToken. */
+export interface TokenPayload {
+ target: ToyId
+ score: number
+ issuedAt: number
+ expiresAt: number
+ /** Same secret-domain identifier the verifier was created with — lets a
+ * consumer run multiple verifiers with different secrets and tell tokens
+ * apart. Defaults to 'default'. */
+ audience: string
+}
diff --git a/tsup.config.ts b/tsup.config.ts
index 8fa373e..0073b53 100644
--- a/tsup.config.ts
+++ b/tsup.config.ts
@@ -2,7 +2,11 @@ import { copyFileSync } from 'node:fs'
import { defineConfig } from 'tsup'
export default defineConfig({
- entry: ['src/index.ts'],
+ // two entries: the browser component (index) and the Node/server module.
+ // They build into separate chunks so the client bundle never pulls in
+ // node:crypto, and consumers can tree-shake the server half out of the
+ // browser build entirely.
+ entry: ['src/index.ts', 'src/server/index.ts'],
format: ['esm'],
dts: true,
clean: true,