Skip to content

feat(sdk): join the browser's signals to the requests that follow - #33

Merged
cport1 merged 1 commit into
mainfrom
feat/client-signals
Aug 22, 2026
Merged

feat(sdk): join the browser's signals to the requests that follow#33
cport1 merged 1 commit into
mainfrom
feat/client-signals

Conversation

@cport1

@cport1 cport1 commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Closes #733.

The gap

@webdecoy/client collected behavioural, environmental and form signals. DetectionEngine scored them. /score returned a verdict — to the browser, which then forgot it. The origin never learned anything from the submission. Joining the two was left as an exercise for the developer, so in practice nobody did, and the README mentioned the client package in one table row and never explained a path.

That's worth closing because it's the SDK's answer to the one thing a tripwire structurally cannot catch: a Playwright-driven Chrome that browses only the links a human would. Genuine fingerprint, no hidden links followed, no honeypot paths requested — invisible to intent-based detection. What it can't fake is having a person behind it.

The wiring

const signalStore = new MemoryClientSignalStore();

app.use(webdecoyCaptcha({ secret, signalStore }));   // /score now records
app.use(webdecoy({
  rules: [
    tripwire(),                                          // intent — deterministic
    clientSignals({ store: signalStore, dryRun: true }), // interaction — probabilistic
  ],
}));

/score records the verdict against the browser's sessionId and echoes it back so the widget can set the wd_cs cookie the rule reads — without that echo the developer would need a second endpoint to hand the browser its own session id.

The store follows the same sync/async split as the rate-limit store, for the same reason: an in-memory default must not make every rule evaluation a promise. Async stores resolve through the prepare() hook.

The design decision worth reviewing

A request with no session is NOT_RUN, never a denial.

curl sends nothing. Googlebot sends nothing. A user with JavaScript disabled sends nothing. Treating absence as suspicion would deny exactly the crawlers a customer most needs to keep, and it would do it silently. So absence is "we have no information", which is the same stance readEdgeVerdict() takes about present: false.

Relatedly, the guide leads with dryRun: true. This is the one rule in the SDK that produces a judgement rather than a fact — a tripwire hit is proof, a score is not — and that difference should shape how it's adopted.

One thing I changed mid-write

The guide originally showed a <script src="https://cdn.webdecoy.com/client/…"> tag. Two problems: no integrity/crossorigin, and I had no verified CDN path for that bundle. Made-up URLs in docs are worse than no URL, so it points at the npm package, with a note about serving the global bundle from your own origin or pinning it.

Verification

16 new tests, 393 total. The end-to-end one drives a real /score submission with headless-Chrome-shaped signals, asserts the store recorded it, then asserts the next request through the SDK is denied on that basis — which is precisely the join that didn't exist.

@webdecoy/client collected behavioural, environmental and form signals,
DetectionEngine scored them, and /score returned a verdict -- to the
browser, which then forgot it. The origin never learned anything from the
submission. Every part existed and nothing joined them, so the README
mentioned the client package in one table row and never explained a path.

That gap matters because this is the answer to the one thing a tripwire
cannot catch: a Playwright-driven Chrome that browses only the links a
human would. It has a genuine fingerprint, follows no hidden links and
requests no honeypot paths -- and it still cannot fake having a person
behind it.

createCaptchaEndpoints({ signalStore }) now records the verdict against
the browser's session and echoes the session id so the widget can set the
cookie the rule reads. clientSignals({ store }) acts on it.

A request with no session is NOT_RUN, never a denial. curl and Googlebot
both send nothing, and scoring silence would deny exactly the crawlers we
most need to keep -- which is also why the guide says to start in dryRun:
this is the one rule in the SDK that is a judgement rather than a fact.

The guide points at the npm package rather than a CDN URL. A versionless
script tag with no integrity attribute is a supply-chain dependency on
whoever is serving it, and I had no verified CDN path for this bundle
anyway.

Closes WebDecoy/app#733
@cport1
cport1 merged commit 1ac4046 into main Aug 22, 2026
2 checks passed
@cport1
cport1 deleted the feat/client-signals branch August 22, 2026 03:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant