diff --git a/CHANGELOG.md b/CHANGELOG.md index 88be733..c23ce04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **The client-signal path is wired end to end.** `@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, and joining the two was left to the developer, so in practice nobody did. Now `createCaptchaEndpoints({ signalStore })` records the verdict against the browser's session, and `clientSignals({ store })` lets the requests that follow act on it. This is the SDK's answer to a Playwright-driven Chrome that browses only the links a human would: it has a genuine fingerprint and follows no hidden links, so no tripwire sees it, but it cannot fake having a person behind 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 most worth keeping. Guide: `docs/client-signals.md`. + - **`@webdecoy/node/testing`** — helpers for the *application's* test suite. The SDK had hundreds of tests and a customer had none: there was no supported way to write "assert this request would be denied" against your own rules, so the first time anyone learned what the middleware does to their traffic was in production. `createTestHarness()` is offline by default (an API key in the environment is ignored, so a unit test never becomes a live call or files test traffic as a real detection) and gives each harness its own rule state. `request()`/`get()`/`post()`/`botRequest()` build metadata; `expectDenied`/`expectAllowed`/`expectRuleState` assert on the decision and print every rule and its state on failure; `protectMany()` runs a rate limit to its edge without sleeping. - **A pluggable logger.** `logger` accepts anything with `debug`/`info`/`warn`/`error`, defaulting to the previous console behaviour. Warnings and errors are no longer gated on `debug` — a violation that failed to report is not diagnostic output. `fromPino()` wraps a pino-style logger, whose argument order is reversed; passing one directly type-checks and then silently drops every structured field. diff --git a/README.md b/README.md index f2187f6..7880bfd 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,38 @@ names the ones in your deny set that do not: `policy.unenforceable` is the same list, in code. Requires no API key. +## Catching a real browser that isn't a real user + +A Playwright-driven Chrome that browses only the links a human would presents a +genuine fingerprint, follows no hidden links and requests no honeypot paths — the +one thing a tripwire cannot see. What it cannot fake is having a person behind +it. + +```typescript +import { MemoryClientSignalStore, clientSignals, tripwire } from '@webdecoy/node'; + +const signalStore = new MemoryClientSignalStore(); + +app.use(webdecoyCaptcha({ secret: process.env.WEBDECOY_SECRET, signalStore })); +app.use(webdecoy({ + rules: [ + tripwire(), // intent — deterministic + clientSignals({ store: signalStore, dryRun: true }), // interaction — probabilistic + ], +})); +``` + +`@webdecoy/client` collects behavioural, environmental and form signals in the +browser; `/score` records the verdict against the session; `clientSignals()` acts +on it for the requests that follow. Before this the score went back to the +browser and the origin never learned anything from 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 you most +need to keep. This augments the keyless rules; it does not replace them. + +Full guide: [**Catching a real browser that isn't a real user**](docs/client-signals.md). + ## Attack signatures Tripwires catch scanners by the path they ask for. `attackSignatures()` looks at diff --git a/docs/client-signals.md b/docs/client-signals.md new file mode 100644 index 0000000..2f4b166 --- /dev/null +++ b/docs/client-signals.md @@ -0,0 +1,120 @@ +# Catching a real browser that isn't a real user + +Tripwires catch automation by intent: a hidden path a person can never reach, so +any request for it is a bot by construction. That is deterministic and +unspoofable, and it has one blind spot — a Playwright-driven Chrome that browses +only the links a human would. It presents a genuine fingerprint, it follows no +hidden links, and it requests no honeypot paths. + +What it cannot fake is having a person behind it. `@webdecoy/client` collects +that evidence in the browser, `DetectionEngine` scores it on your server, and +`clientSignals()` lets the requests that follow act on the result. + +**This augments the keyless rules; it does not replace them.** No JavaScript +means no signals, and 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 you most need to keep. + +## 1. Serve the endpoints, with a store + +The `/score` endpoint already existed. What is new is the store: without it the +verdict goes back to the browser and your origin never learns anything from it. + +```typescript +import express from 'express'; +import { webdecoyCaptcha } from '@webdecoy/express'; +import { MemoryClientSignalStore } from '@webdecoy/node'; + +const signalStore = new MemoryClientSignalStore(); + +const app = express(); +app.use(express.json()); +app.use(webdecoyCaptcha({ secret: process.env.WEBDECOY_SECRET, signalStore })); +``` + +`MemoryClientSignalStore` is per-process, like the rate limiter. On more than one +replica implement `ClientSignalStore` over something shared, or the request after +the submission may land on a different instance and find nothing. + +## 2. Add the rule + +```typescript +import { webdecoy } from '@webdecoy/express'; +import { tripwire, clientSignals } from '@webdecoy/node'; + +app.use(webdecoy({ + rules: [ + tripwire(), // intent — deterministic + clientSignals({ store: signalStore }), // interaction — probabilistic + ], +})); +``` + +By default the rule follows the engine's own recommendation. `minScore` overrides +it with a threshold of your own, 0–1, higher being more bot-like. + +Start with `dryRun: true`. This is the one rule in the SDK that is a judgement +rather than a fact, and you want a day of your own traffic before it blocks +anyone. + +## 3. Load the widget + +```bash +npm install @webdecoy/client +``` + +```typescript +import { WebDecoyCaptcha } from '@webdecoy/client'; + +WebDecoyCaptcha.configure({ serverUrl: '' }); // same origin +WebDecoyCaptcha.invisible({ action: 'browse' }); +``` + +The package also ships a prebuilt global bundle at +`@webdecoy/client/global` for pages without a bundler. Serve it from your own +origin rather than a third-party CDN, or pin a version and add +`integrity`/`crossorigin` — a script tag with neither is a supply-chain +dependency on whoever is serving it. + +The widget submits to `/score` with a `sessionId` and sets the `wd_cs` cookie. +The rule reads that cookie — or an `X-WD-Session` header, for a client that +cannot use cookies. + +## 4. Confirm it works + +```typescript +import { createTestHarness, request, expectDenied } from '@webdecoy/node/testing'; + +const wd = createTestHarness({ rules: [clientSignals({ store, minScore: 0.5 })] }); +expectDenied(await wd.protect(request({ headers: { cookie: 'wd_cs=sess-1' } }))); +``` + +Against a real browser: drive the page with Playwright, let the widget submit, +and compare the recorded score with your own session. `webdriver` alone +contributes to the score, and a session with no pointer movement, no scroll and +no keystrokes contributes considerably more. + +## What the signals are + +The collection contract is the code — `summarizeBehavior()` in +`@webdecoy/client` is the published list of what leaves the browser. In outline: + +| Group | Examples | +|---|---| +| Behavioural | pointer trajectory, micro-tremor, velocity variance, scroll and key events | +| Environmental | `navigator.webdriver`, plugin count, automation flags, CDP artifacts | +| Temporal | time to first interaction, session duration, event deltas | +| Form | per-field dwell times, paste versus keystroke, submit timing | + +No page content, no form values, no cookies other than the session id. + +## Limits worth knowing + +- **A client signal is a claim by code running on the client.** A determined + attacker can lie to it. Its value is that most automation does not bother, and + that faking human interaction convincingly is much harder than faking a + fingerprint. +- **The score is probabilistic.** Unlike a tripwire hit, a high score is not + proof. That is why `dryRun` is the recommended starting point and why the + deterministic rules stay in the list. +- **Sessions expire** after 15 minutes by default. diff --git a/packages/webdecoy/src/captcha/http.ts b/packages/webdecoy/src/captcha/http.ts index 3edcef4..8003469 100644 --- a/packages/webdecoy/src/captcha/http.ts +++ b/packages/webdecoy/src/captcha/http.ts @@ -13,6 +13,7 @@ import { Captcha, type CaptchaOptions } from './service'; import type { Signals } from '../detection/types'; +import type { ClientSignalStore } from '../client-signals'; /** Normalized inbound request the adapters construct. */ export interface CaptchaRequest { @@ -36,6 +37,12 @@ export interface CaptchaHttpResponse { } export interface CaptchaEndpointsOptions extends CaptchaOptions { + /** + * Where `/score` records its verdict, for `clientSignals()` to read on + * subsequent requests. Omit and the score is returned to the browser and + * forgotten, which is what happened before this existed. + */ + signalStore?: ClientSignalStore; /** Base path the routes are mounted under (default `/__webdecoy`). */ basePath?: string; } @@ -48,6 +55,8 @@ interface VerifyBody { powTiming?: { duration?: number; iterations?: number } | null; action?: string; token?: string; + /** The browser widget's session id, sent by `@webdecoy/client`. */ + sessionId?: string; } const JSON_HEADERS = { 'content-type': 'application/json' }; @@ -58,7 +67,7 @@ const JSON_HEADERS = { 'content-type': 'application/json' }; * middleware can fall through to the next handler). */ export function createCaptchaEndpoints(options: CaptchaEndpointsOptions = {}) { - const { basePath = '/__webdecoy', ...captchaOptions } = options; + const { basePath = '/__webdecoy', signalStore, ...captchaOptions } = options; const captcha = new Captcha(captchaOptions); const base = basePath.replace(/\/$/, ''); @@ -118,7 +127,26 @@ export function createCaptchaEndpoints(options: CaptchaEndpointsOptions = {}) { ja3Hash, action: b.action, }); - return json(200, result); + + // Remember the verdict against the browser's session, so the requests + // that follow can act on it. Without this the score goes back to the + // browser and the origin never learns anything from it — which was the + // gap: all the parts existed and nothing joined them. + if (signalStore && b.sessionId) { + await signalStore.set({ + sessionId: b.sessionId, + score: result.score, + recommendation: result.recommendation, + at: Date.now(), + }); + } + + return json(200, { + ...result, + // Echoed so the widget can set the cookie the rule reads, without the + // developer wiring a second endpoint to hand it one. + sessionId: b.sessionId ?? null, + }); } // POST {base}/token/verify diff --git a/packages/webdecoy/src/client-signals.test.ts b/packages/webdecoy/src/client-signals.test.ts new file mode 100644 index 0000000..e1da197 --- /dev/null +++ b/packages/webdecoy/src/client-signals.test.ts @@ -0,0 +1,201 @@ +import { + clientSignals, + MemoryClientSignalStore, + readClientSession, + CLIENT_SESSION_COOKIE, + type ClientSignalStore, + type ClientVerdict, +} from './client-signals'; +import { createCaptchaEndpoints } from './captcha'; +import { createTestHarness, request, expectDenied, expectAllowed, expectRuleState } from './testing'; +import type { RuleContext } from './rules/types'; + +const verdict = (over: Partial = {}): ClientVerdict => ({ + sessionId: 's1', + score: 0.9, + recommendation: 'block', + at: Date.now(), + ...over, +}); + +const ctx = (headers: Record = {}): RuleContext => ({ + ip: '203.0.113.9', + path: '/', + method: 'GET', + headers, + timestamp: Date.now(), +}); + +const withSession = (id: string) => ({ cookie: `a=1; ${CLIENT_SESSION_COOKIE}=${id}; b=2` }); + +describe('reading the browser session', () => { + it('finds the cookie among others', () => { + expect(readClientSession(withSession('abc'))).toBe('abc'); + }); + + it('falls back to the header', () => { + expect(readClientSession({ 'x-wd-session': 'abc' })).toBe('abc'); + }); + + it('is undefined when neither is present or the value is empty', () => { + expect(readClientSession({})).toBeUndefined(); + expect(readClientSession({ cookie: 'wd_cs=' })).toBeUndefined(); + }); +}); + +describe('the in-memory store', () => { + it('round-trips a verdict', () => { + const store = new MemoryClientSignalStore(); + store.set(verdict()); + expect(store.get('s1')?.score).toBe(0.9); + }); + + it('expires', () => { + const store = new MemoryClientSignalStore({ ttl: 10 }); + store.set(verdict({ at: Date.now() - 1000 })); + expect(store.get('s1')).toBeUndefined(); + }); + + it('is bounded', () => { + const store = new MemoryClientSignalStore({ max: 2 }); + store.set(verdict({ sessionId: 'a' })); + store.set(verdict({ sessionId: 'b' })); + store.set(verdict({ sessionId: 'c' })); + expect(store.size).toBe(2); + expect(store.get('a')).toBeUndefined(); + }); +}); + +describe('the clientSignals rule', () => { + it('denies a session the engine called a bot', () => { + const store = new MemoryClientSignalStore(); + store.set(verdict()); + const result = clientSignals({ store }).evaluate(ctx(withSession('s1'))); + expect(result.action).toBe('DENY'); + expect(result.reason).toMatch(/0\.90/); + }); + + it('allows a session that scored human', () => { + const store = new MemoryClientSignalStore(); + store.set(verdict({ score: 0.1, recommendation: 'allow' })); + expect(clientSignals({ store }).evaluate(ctx(withSession('s1'))).action).toBe('ALLOW'); + }); + + it('honours an explicit minScore over the engine recommendation', () => { + const store = new MemoryClientSignalStore(); + store.set(verdict({ score: 0.4, recommendation: 'allow' })); + expect(clientSignals({ store }).evaluate(ctx(withSession('s1'))).action).toBe('ALLOW'); + expect( + clientSignals({ store, minScore: 0.3 }).evaluate(ctx(withSession('s1'))).action, + ).toBe('DENY'); + }); + + it('is NOT_RUN for a request with no browser session', () => { + // A curl request and a search crawler both send nothing. Scoring silence + // would deny exactly the crawlers we most need to keep. + const result = clientSignals({ store: new MemoryClientSignalStore() }).evaluate(ctx()); + expect(result.action).toBe('ALLOW'); + expect(result.state).toBe('NOT_RUN'); + expect(result.reason).toMatch(/did not run/); + }); + + it('is NOT_RUN when the session has submitted nothing yet', () => { + const result = clientSignals({ store: new MemoryClientSignalStore() }).evaluate( + ctx(withSession('unknown')), + ); + expect(result.state).toBe('NOT_RUN'); + expect(result.reason).toMatch(/No signals recorded/); + }); + + it('honours dryRun', () => { + const store = new MemoryClientSignalStore(); + store.set(verdict()); + const result = clientSignals({ store, dryRun: true }).evaluate(ctx(withSession('s1'))); + expect(result.action).toBe('ALLOW'); + expect(result.metadata?.dryRun).toBe(true); + }); +}); + +describe('an async store', () => { + function asyncStore(): ClientSignalStore { + const inner = new MemoryClientSignalStore(); + return { + sync: false, + async get(id) { + await Promise.resolve(); + return inner.get(id); + }, + async set(v) { + await Promise.resolve(); + inner.set(v); + }, + }; + } + + it('is resolved during the async pre-fetch', async () => { + const store = asyncStore(); + await store.set(verdict()); + const wd = createTestHarness({ rules: [clientSignals({ store })] }); + expectDenied(await wd.protect(request({ headers: withSession('s1') })), { + rule: 'client-signals', + }); + }); + + it('allows a session the store has nothing for', async () => { + const wd = createTestHarness({ rules: [clientSignals({ store: asyncStore() })] }); + const d = await wd.protect(request({ headers: withSession('nope') })); + expectAllowed(d); + expectRuleState(d, 'client-signals', 'NOT_RUN'); + }); +}); + +describe('the /score endpoint records the verdict', () => { + it('joins the submission to the requests that follow it', async () => { + // This is the gap the feature closes: the score used to go back to the + // browser and the origin never learned anything from it. + const store = new MemoryClientSignalStore(); + const endpoints = createCaptchaEndpoints({ secret: 'test-secret-value', signalStore: store }); + + const response = await endpoints.handle({ + method: 'POST', + pathname: '/__webdecoy/score', + query: {}, + headers: { 'user-agent': 'HeadlessChrome/124.0' }, + ip: '203.0.113.9', + body: { + sessionId: 'sess-42', + signals: { + environmental: { webdriver: true, automationFlags: { plugins: 0 } }, + behavioral: { totalPoints: 0, trajectoryLength: 0 }, + }, + }, + }); + + expect(response?.status).toBe(200); + // Echoed so the widget can set the cookie the rule reads. + expect((response?.body as { sessionId?: string }).sessionId).toBe('sess-42'); + + const recorded = store.get('sess-42'); + expect(recorded).toBeDefined(); + expect(recorded?.score).toBeGreaterThan(0); + + // And the very next request through the SDK acts on it. + const wd = createTestHarness({ rules: [clientSignals({ store, minScore: 0.01 })] }); + expectDenied(await wd.protect(request({ headers: withSession('sess-42') })), { + rule: 'client-signals', + }); + }); + + it('records nothing when no store is configured, as before', async () => { + const endpoints = createCaptchaEndpoints({ secret: 'test-secret-value' }); + const response = await endpoints.handle({ + method: 'POST', + pathname: '/__webdecoy/score', + query: {}, + headers: {}, + ip: '203.0.113.9', + body: { sessionId: 'sess-1', signals: {} }, + }); + expect(response?.status).toBe(200); + }); +}); diff --git a/packages/webdecoy/src/client-signals.ts b/packages/webdecoy/src/client-signals.ts new file mode 100644 index 0000000..75ce815 --- /dev/null +++ b/packages/webdecoy/src/client-signals.ts @@ -0,0 +1,222 @@ +/** + * What the browser told us, made available to the request that follows. + * + * WHY THIS EXISTS + * + * `@webdecoy/client` collects behavioural, environmental and form signals and + * can run proof-of-work; `DetectionEngine` scores them; `/score` already + * accepted a submission and returned a verdict. The parts were all there and + * they were not connected: the verdict went back to the browser, and the next + * request to the origin knew nothing about it. Joining the two was left as an + * exercise for the developer, so in practice nobody did. + * + * That matters because this is the SDK's answer to the one thing a tripwire + * cannot catch — a real browser, driven by Playwright, that follows no hidden + * links and requests no honeypot paths. It has a genuine fingerprint and it + * still cannot fake human interaction. Competitors launched exactly this shape + * as a headline feature; we have had the parts for longer and no way to use + * them. + * + * WHAT IT IS NOT + * + * A client signal is a claim by code running on the client, so it is evidence + * about a cooperative browser and nothing else. No JavaScript means no signals, + * which is why {@link ClientSignalRule} treats "absent" as `NOT_RUN` rather than + * as suspicion: a curl request and a search crawler both send nothing, and + * scoring silence would deny the crawlers we most need to keep. + * + * This augments the keyless deterministic rules. It does not replace them. + */ + +import type { Rule, RuleContext, RuleResult } from './rules/types'; +import type { Recommendation } from './detection'; + +/** The engine's verdict on one browser session. */ +export interface ClientVerdict { + /** Session id supplied by the browser widget. */ + sessionId: string; + /** 0–1, higher is more bot-like. */ + score: number; + /** What the engine recommends doing about it. */ + recommendation: Recommendation; + /** When the verdict was recorded, Unix ms. */ + at: number; +} + +/** + * Where verdicts live between the `/score` submission and the requests that + * follow it. + * + * Same sync/async split as the rate-limit store, and for the same reason: an + * in-memory default must not make every rule evaluation a promise. + */ +export interface ClientSignalStore { + readonly sync: boolean; + get(sessionId: string): ClientVerdict | undefined | Promise; + set(verdict: ClientVerdict): void | Promise; +} + +export interface MemoryClientSignalStoreOptions { + /** How long a verdict stays usable. @default 900_000 (15 minutes) */ + ttl?: number; + /** Maximum sessions held. @default 10_000 */ + max?: number; +} + +export class MemoryClientSignalStore implements ClientSignalStore { + readonly sync = true as const; + private readonly ttl: number; + private readonly max: number; + private entries = new Map(); + + constructor(options: MemoryClientSignalStoreOptions = {}) { + this.ttl = options.ttl ?? 900_000; + this.max = options.max ?? 10_000; + } + + get(sessionId: string): ClientVerdict | undefined { + const entry = this.entries.get(sessionId); + if (!entry) return undefined; + if (Date.now() - entry.at > this.ttl) { + this.entries.delete(sessionId); + return undefined; + } + return entry; + } + + set(verdict: ClientVerdict): void { + this.entries.delete(verdict.sessionId); + this.entries.set(verdict.sessionId, verdict); + while (this.entries.size > this.max) { + const oldest = this.entries.keys().next(); + if (oldest.done) break; + this.entries.delete(oldest.value); + } + } + + get size(): number { + return this.entries.size; + } +} + +/** The cookie the widget sets, and the header it will also accept. */ +export const CLIENT_SESSION_COOKIE = 'wd_cs'; +export const CLIENT_SESSION_HEADER = 'x-wd-session'; + +/** Read the browser session id off a request, cookie first then header. */ +export function readClientSession(headers: Record): string | undefined { + const cookie = headers['cookie']; + if (cookie) { + for (const part of cookie.split(';')) { + const eq = part.indexOf('='); + if (eq < 0) continue; + if (part.slice(0, eq).trim() === CLIENT_SESSION_COOKIE) { + const value = part.slice(eq + 1).trim(); + if (value) return value; + } + } + } + const header = headers[CLIENT_SESSION_HEADER]; + return header || undefined; +} + +export interface ClientSignalConfig { + /** Where verdicts are read from. Must be the store `/score` writes to. */ + store: ClientSignalStore; + /** + * Deny at or above this score, 0–1. The engine's own `recommendation` is used + * when this is omitted. + */ + minScore?: number; + /** Action on a match. @default 'DENY' */ + action?: 'DENY' | 'THROTTLE'; + /** Log the violation but do not block. */ + dryRun?: boolean; +} + +/** + * Act on what the browser widget reported for this session. + * + * A request with no session — no JavaScript, no widget, a crawler, curl — is + * `NOT_RUN`, never a denial. See the module note. + */ +export class ClientSignalRule implements Rule { + readonly name = 'client-signals'; + private readonly store: ClientSignalStore; + private readonly minScore?: number; + private readonly action: 'DENY' | 'THROTTLE'; + private readonly dryRun: boolean; + + constructor(config: ClientSignalConfig) { + this.store = config.store; + this.minScore = config.minScore; + this.action = config.action ?? 'DENY'; + this.dryRun = config.dryRun ?? false; + } + + async prepare(context: RuleContext): Promise { + if (this.store.sync) return; + const sessionId = readClientSession(context.headers); + if (!sessionId) return; + const verdict = await this.store.get(sessionId); + if (verdict) { + context.prepared ??= {}; + context.prepared[this.name] = verdict; + } + } + + evaluate(context: RuleContext): RuleResult { + const sessionId = readClientSession(context.headers); + if (!sessionId) { + return { + action: 'ALLOW', + rule: this.name, + state: 'NOT_RUN', + reason: 'No browser session — the client widget did not run for this request', + }; + } + + const verdict = this.store.sync + ? (this.store.get(sessionId) as ClientVerdict | undefined) + : (context.prepared?.[this.name] as ClientVerdict | undefined); + + if (!verdict) { + return { + action: 'ALLOW', + rule: this.name, + state: 'NOT_RUN', + reason: 'No signals recorded for this session yet', + }; + } + + const bad = + this.minScore !== undefined + ? verdict.score >= this.minScore + : verdict.recommendation === 'block'; + + if (!bad) { + return { + action: 'ALLOW', + rule: this.name, + metadata: { score: verdict.score, recommendation: verdict.recommendation }, + }; + } + + return { + action: this.dryRun ? 'ALLOW' : this.action, + rule: this.name, + reason: `Client signals scored ${verdict.score.toFixed(2)} (${verdict.recommendation})`, + metadata: { + score: verdict.score, + recommendation: verdict.recommendation, + sessionId, + dryRun: this.dryRun, + }, + }; + } +} + +/** Act on the browser widget's verdict for this session. See {@link ClientSignalConfig}. */ +export function clientSignals(config: ClientSignalConfig): Rule { + return new ClientSignalRule(config); +} diff --git a/packages/webdecoy/src/index.ts b/packages/webdecoy/src/index.ts index 1f04526..4568b0a 100644 --- a/packages/webdecoy/src/index.ts +++ b/packages/webdecoy/src/index.ts @@ -86,6 +86,23 @@ export type { BotVerdict, BotAgent, BotCategory } from './bots'; export { consoleLogger, silentLogger, fromPino } from './logger'; export type { Logger, LogFields } from './logger'; +// Browser signals, joined to the requests that follow them. See client-signals.ts +// for why this is not a threat judgement about a client that sends none. +export { + clientSignals, + ClientSignalRule, + MemoryClientSignalStore, + readClientSession, + CLIENT_SESSION_COOKIE, + CLIENT_SESSION_HEADER, +} from './client-signals'; +export type { + ClientVerdict, + ClientSignalStore, + ClientSignalConfig, + MemoryClientSignalStoreOptions, +} from './client-signals'; + export { createFetchGuard } from './fetch-guard'; export type { FetchGuard, FetchGuardOptions, GuardOutcome } from './fetch-guard';