Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 201 additions & 0 deletions packages/client/src/clearance-behavior.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/**
* @jest-environment jsdom
*
* Locks the behavioral-clearance contract (app repo #328, PRD FR10): what may
* leave the browser, when a session is summarized at all, and that scripted
* input produces measurably different aggregates than a human hand.
*/

import { BehavioralCollector } from './collectors/behavioral';
import {
summarizeBehavior,
hasEnoughInteraction,
watchInteraction,
type BehaviorAggregate,
} from './clearance-behavior';

/**
* THE PUBLISHED COLLECTION CONTRACT. Adding a field here is a deliberate act
* that must also update the docs page and the ingest-side struct — it is not a
* detail to slip in, because everything on this list is data we told customers
* (and their visitors) we collect.
*/
const ALLOWED_FIELDS = [
'delta_var',
'dir_changes',
'duration_ms',
'hold_ms',
'keys',
'micro_moves',
'pointer_nonmouse',
'samples',
'scrolls',
'straight_ratio',
'touch_force_var',
'touch_points',
'tremor',
'velocity_var',
];

/** A human-ish pointer path: arcs, jitter and uneven event timing. */
function humanCollector(): BehavioralCollector {
const c = new BehavioralCollector();
let t = 0;
let x = 100;
let y = 100;
for (let i = 0; i < 60; i++) {
// Uneven cadence (8–40ms), curved travel, sub-pixel-scale correction.
t += 8 + ((i * 7) % 33);
x += Math.round(6 * Math.cos(i / 4)) + (i % 3 === 0 ? 1 : -1);
y += Math.round(5 * Math.sin(i / 3)) + (i % 4 === 0 ? -1 : 1);
c.mousePositions.push({ x, y, t });
if (i > 0) {
c.mouseVelocities.push({ v: 0.2 + (i % 5) * 0.11, t });
c.eventDeltas.push(8 + ((i * 7) % 33));
}
}
c.clickData = { x, y, button: 0, downTime: t, upTime: t + 88, holdDuration: 88 };
return c;
}

/**
* Scripted input: page.mouse.move(x, y, {steps: n}) — exact linear
* interpolation on a uniform beat. Paced at 40ms/step rather than a tight loop,
* so it clears the client-side window floor: this is the HARDER case, the one
* that actually reaches the server and has to be scored there.
*/
function scriptedCollector(): BehavioralCollector {
const c = new BehavioralCollector();
for (let i = 0; i < 60; i++) {
c.mousePositions.push({ x: 100 + i * 5, y: 100 + i * 5, t: i * 40 }); // exactly collinear
if (i > 0) {
c.mouseVelocities.push({ v: 7.07, t: i * 40 }); // constant speed
c.eventDeltas.push(40); // isochronous
}
}
c.clickData = { x: 395, y: 395, button: 0, downTime: 2400, upTime: 2401, holdDuration: 1 };
return c;
}

describe('collection contract', () => {
it('emits only the published aggregate fields', () => {
const summary = summarizeBehavior(humanCollector()) as BehaviorAggregate;
expect(summary).not.toBeNull();
expect(Object.keys(summary).sort()).toEqual(ALLOWED_FIELDS);
});

it('carries no coordinates, key identities or replayable event stream', () => {
const c = humanCollector();
c.keyEvents.push({ type: 'keydown', keyLength: 1, t: 10 });
const serialized = JSON.stringify(summarizeBehavior(c));

// The collector holds x/y positions and a per-event delta list; none of it
// may reach the wire. Guard on the shape, not on incidental values.
for (const banned of ['x', 'y', 'clickData', 'eventDeltas', 'mousePositions', 'key', 'target']) {
expect(JSON.parse(serialized)).not.toHaveProperty(banned);
}
// Nothing nested either — every value must be a scalar.
for (const v of Object.values(JSON.parse(serialized))) {
expect(['number', 'boolean']).toContain(typeof v);
}
});

it('reports keystrokes as a count only', () => {
const c = humanCollector();
for (const key of ['p', 'a', 's', 's']) {
c.keyEvents.push({ type: 'keydown', keyLength: key.length, t: 1 });
}
const summary = summarizeBehavior(c) as BehaviorAggregate;
expect(summary.keys).toBe(4);
expect(JSON.stringify(summary)).not.toContain('pass');
});
});

describe('when a session is summarized', () => {
it('declines sessions with too little interaction', () => {
expect(summarizeBehavior(new BehavioralCollector())).toBeNull();
});

it('declines a keyboard-only session rather than scoring it badly', () => {
// The accessibility case: no pointer, no touch, plenty of typing. It must
// produce NO summary, so the mint stays clean instead of looking bot-like.
const c = new BehavioralCollector();
for (let i = 0; i < 50; i++) c.keyEvents.push({ type: 'keydown', keyLength: 1, t: i * 90 });
for (let i = 0; i < 10; i++) c.scrollEvents.push({ x: 0, y: i * 40, t: i * 120 });
expect(hasEnoughInteraction(c)).toBe(false);
expect(summarizeBehavior(c)).toBeNull();
});

it('declines a short burst that would report placeholder tremor', () => {
// Under 20 samples the collector returns a 0.5 tremor PLACEHOLDER, which
// the server would read as genuine human tremor. Never ship it.
const c = new BehavioralCollector();
for (let i = 0; i < 15; i++) c.mousePositions.push({ x: i, y: i, t: i * 100 });
expect(hasEnoughInteraction(c)).toBe(false);
expect(summarizeBehavior(c)).toBeNull();
});

it('accepts a touch-only session (mobile has no mouse cadence to offer)', () => {
const c = new BehavioralCollector();
for (let i = 0; i < 12; i++) {
c.touchEvents.push({
x: 50 + i * 3, y: 200 - i * 7, t: i * 30,
force: 0.4 + (i % 3) * 0.05, radiusX: 12, radiusY: 14,
rotationAngle: 0, identifier: 1, touchCount: 1,
});
}
const summary = summarizeBehavior(c) as BehaviorAggregate;
expect(summary).not.toBeNull();
expect(summary.touch_points).toBe(12);
expect(summary.touch_force_var).toBeGreaterThan(0);
});
});

describe('scripted vs human aggregates', () => {
it('separates them on the tells the server scores', () => {
const human = summarizeBehavior(humanCollector()) as BehaviorAggregate;
const scripted = summarizeBehavior(scriptedCollector()) as BehaviorAggregate;

// Collinearity: interpolation draws a line, a hand does not.
expect(scripted.straight_ratio).toBeGreaterThan(0.9);
expect(human.straight_ratio).toBeLessThan(scripted.straight_ratio);

// Physiological tremor is present in one and absent in the other.
expect(human.tremor).toBeGreaterThan(scripted.tremor);

// Isochrony: a script's inter-event timing barely varies.
expect(human.delta_var).toBeGreaterThan(scripted.delta_var);

// Constant velocity, and a click released in the same task it was pressed.
expect(scripted.velocity_var).toBeCloseTo(0, 5);
expect(human.velocity_var).toBeGreaterThan(0);
expect(scripted.hold_ms).toBeLessThan(15);
expect(human.hold_ms).toBeGreaterThan(30);
});
});

describe('watchInteraction', () => {
const move = (x: number, y: number): void => {
document.dispatchEvent(new MouseEvent('mousemove', { clientX: x, clientY: y, bubbles: true }));
};

beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());

it('never fires for a session that does not interact', () => {
const onReady = jest.fn();
const stop = watchInteraction(onReady);
jest.advanceTimersByTime(60_000);
expect(onReady).not.toHaveBeenCalled();
stop();
});

it('stops listening once cancelled', () => {
const onReady = jest.fn();
const stop = watchInteraction(onReady);
stop();
for (let i = 0; i < 80; i++) move(i * 3, i * 2);
jest.advanceTimersByTime(60_000);
expect(onReady).not.toHaveBeenCalled();
});
});
185 changes: 185 additions & 0 deletions packages/client/src/clearance-behavior.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* Behavioral human-likelihood for clearance minting (app repo #328, PRD FR10).
*
* A wd_clearance token normally says only "this is a real browser that hasn't
* tripped deception". This module lets a session also present POSITIVE evidence
* that a human is driving it, which the mint turns into a graded 'human-likely'
* token — the grade a route can require for its most sensitive paths.
*
* WHAT LEAVES THE BROWSER (this is the whole list):
* counts — how many pointer samples, scrolls, keystrokes, touches
* durations — the observation window, and how long a click was held
* variances — of pointer velocity, of inter-event timing, of touch force
* ratios — micro-tremor and collinearity of the pointer path, 0–1
*
* WHAT NEVER LEAVES THE BROWSER: pointer coordinates, the keys pressed, form
* values, element or page content, URLs, screenshots, and any kind of replayable
* event stream. Nothing here can reconstruct what a person typed, read, clicked
* or looked at — the aggregates describe HOW the session moved, never WHAT it
* did. That is the whole difference between this and a session-replay vendor,
* and it is why the collection contract is published rather than buried.
*
* The scoring itself is deliberately NOT done here: the browser reports
* observations, the server decides what they earn. A client that fabricates
* flattering aggregates gains nothing it could not gain by lying about its
* fingerprint, and the mint caps any grade by the actor's threat score.
*/

import { BehavioralCollector } from './collectors/behavioral';

/** The wire shape posted as `behavior` on a clearance mint. */
export interface BehaviorAggregate {
samples: number;
duration_ms: number;
tremor: number;
straight_ratio: number;
velocity_var: number;
delta_var: number;
dir_changes: number;
micro_moves: number;
hold_ms: number;
touch_points: number;
touch_force_var: number;
pointer_nonmouse: boolean;
scrolls: number;
keys: number;
}

/**
* Minimum pointer samples before a session is summarized at all.
*
* Above the collector's own 20-sample floor on purpose: below that,
* _detectMicroTremor returns a 0.5 placeholder rather than a measurement, and
* shipping a placeholder the server reads as real tremor would manufacture
* human evidence out of a short mouse twitch.
*/
const MIN_POINTER_SAMPLES = 24;

/** Minimum touch points for the mobile modality (real digitizer data is dense). */
const MIN_TOUCH_POINTS = 8;

/** Minimum observation window; a burst inside a few milliseconds is not a session. */
const MIN_WINDOW_MS = 750;

/**
* How long to keep collecting after the thresholds are first met. More
* interaction means a better-founded summary, and the upgrade is not urgent —
* the session already holds a valid clean token.
*/
const SETTLE_MS = 1500;

function num(v: unknown): number {
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
}

/** The observed pointer window, measured from the samples rather than from
* page load — a visitor who reads for a minute and then moves the mouse has a
* two-second pointer window, not a sixty-second one. */
function pointerWindowMs(collector: BehavioralCollector): number {
const p = collector.mousePositions;
if (!p || p.length < 2) return 0;
return Math.max(0, Math.round(p[p.length - 1].t - p[0].t));
}

/** Whether the session has enough interaction to be worth summarizing. */
export function hasEnoughInteraction(collector: BehavioralCollector): boolean {
const pointerReady =
collector.mousePositions.length >= MIN_POINTER_SAMPLES &&
pointerWindowMs(collector) >= MIN_WINDOW_MS;
const touchReady = collector.touchEvents.length >= MIN_TOUCH_POINTS;
return pointerReady || touchReady;
}

/**
* Reduce a collector to the published aggregate set. Returns null when the
* session has too little interaction to describe — an unscored mint is a clean
* token, which is exactly what a keyboard-only or assistive-technology session
* should get rather than a low score it never earned.
*/
export function summarizeBehavior(collector: BehavioralCollector): BehaviorAggregate | null {
if (!hasEnoughInteraction(collector)) return null;

const a = collector.analyze();
const click = collector.clickData;

// Every field below is a scalar count, duration, variance or ratio. If a
// future signal cannot be described that way, it does not belong on this wire.
return {
samples: num(a.totalPoints),
duration_ms: pointerWindowMs(collector),
tremor: num(a.microTremorScore),
straight_ratio: num(a.straightLineRatio),
velocity_var: num(a.velocityVariance),
delta_var: num(a.eventDeltaVariance),
dir_changes: num(a.directionChanges),
micro_moves: num(a.microMovements),
hold_ms: num(click?.holdDuration),
touch_points: num(a.touchTotalPoints),
touch_force_var: num(a.touchForceVariance),
pointer_nonmouse: a.pointerHasNonMouseType === true,
scrolls: num(a.scrollEvents),
keys: num(a.keyEvents),
};
}

/**
* Watch the session until it has enough interaction to summarize, then hand the
* aggregate over ONCE and stop listening.
*
* All listeners are passive (they never delay scrolling or input) and are
* removed as soon as the summary is delivered, so the steady-state cost of a
* cleared session is zero. A session that never interacts never fires, never
* posts, and keeps the clean token it already has.
*
* Returns a cancel function for callers that unmount.
*/
export function watchInteraction(onReady: (b: BehaviorAggregate) => void): () => void {
if (typeof document === 'undefined') return () => {};

const collector = new BehavioralCollector();
let settleTimer: ReturnType<typeof setTimeout> | null = null;
let done = false;

const handlers: Record<string, EventListener> = {
mousemove: (e) => collector.recordMouseMove(e as MouseEvent),
mousedown: (e) => collector.recordMouseDown(e as MouseEvent),
mouseup: (e) => collector.recordMouseUp(e as MouseEvent),
scroll: (e) => collector.recordScroll(e),
keydown: (e) => collector.recordKeyEvent(e as KeyboardEvent),
touchstart: (e) => collector.recordTouch(e as TouchEvent),
touchmove: (e) => collector.recordTouch(e as TouchEvent),
pointerdown: (e) => collector.recordPointer(e as PointerEvent),
};

const stop = (): void => {
if (done) return;
done = true;
if (settleTimer) clearTimeout(settleTimer);
for (const [event, handler] of Object.entries(handlers)) {
document.removeEventListener(event, handler);
}
};

const check = (): void => {
if (done || settleTimer) return;
if (!hasEnoughInteraction(collector)) return;
// Thresholds met: collect a little longer, then deliver and detach.
settleTimer = setTimeout(() => {
settleTimer = null;
const summary = summarizeBehavior(collector);
stop();
if (summary) onReady(summary);
}, SETTLE_MS);
};

for (const [event, handler] of Object.entries(handlers)) {
const wrapped: EventListener = (e) => {
handler(e);
check();
};
handlers[event] = wrapped;
document.addEventListener(event, wrapped, { passive: true });
}

return stop;
}
Loading