@toffee-at/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,202 @@
1
+ import type { DetectorResult, DetectionOutput, RiskTier } from './types';
2
+ import { detectUserAgent } from './user-agent';
3
+ import { detectHeadless } from './headless';
4
+ import { detectAutomation } from './automation';
5
+ import { detectBehavioral } from './behavioral';
6
+ import { detectNavigator } from './navigator';
7
+ import { detectFingerprint } from './fingerprint';
8
+
9
+ export type { DetectorResult, DetectionOutput } from './types';
10
+
11
+ /**
12
+ * Per-detector LLR ranges.
13
+ *
14
+ * Key insight: a rawScore of 0 means different things for different detectors.
15
+ * - behavioral 0 = "observed human mouse/scroll/keyboard" → strong human evidence (LLR = -3.0)
16
+ * - user-agent 0 = "no known bot UA match" → weak signal (many stealthy bots also score 0)
17
+ * - headless 0 = "no headless indicators" → mild human evidence
18
+ *
19
+ * [llrAtZero, llrAt100]
20
+ */
21
+ const DETECTOR_LLR_RANGE: Record<string, [number, number]> = {
22
+ 'user-agent': [-0.1, 5.0], // UA match is definitive, but absence means almost nothing
23
+ 'headless': [-0.2, 4.0], // Headless signals are strong, absence is mild
24
+ 'automation': [-0.1, 5.0], // Globals are definitive, absence means nothing
25
+ 'navigator': [-0.1, 3.5], // Consistency checks — clean is expected
26
+ 'fingerprint': [-0.1, 3.5], // Canvas/WebGL — clean is expected
27
+ 'behavioral': [-2.5, 6.0], // The most important detector for extension-based agents
28
+ };
29
+
30
+ function rawScoreToLLR(detector: string, rawScore: number): number {
31
+ const [llrZero, llrHundred] = DETECTOR_LLR_RANGE[detector] ?? [-0.5, 4.0];
32
+ // Linear interpolation from [0 → llrZero] to [100 → llrHundred]
33
+ return llrZero + (rawScore / 100) * (llrHundred - llrZero);
34
+ }
35
+
36
+ /**
37
+ * Log-odds Bayesian fusion.
38
+ * Combines independent evidence from multiple detectors into a single probability.
39
+ */
40
+ function bayesianFusion(results: DetectorResult[]): DetectionOutput {
41
+ // Prior: assume 15% bot traffic (slightly higher than average given the agent era)
42
+ const PRIOR_LOG_ODDS = Math.log(0.15 / 0.85); // ≈ -1.735
43
+
44
+ let logOddsPosterior = PRIOR_LOG_ODDS;
45
+
46
+ const signalDetails = results.map((r) => {
47
+ const llr = rawScoreToLLR(r.detector, r.rawScore);
48
+ logOddsPosterior += llr;
49
+ return {
50
+ detector: r.detector,
51
+ fired: r.rawScore > 0,
52
+ llr,
53
+ rawScore: r.rawScore,
54
+ evidence: r.signals,
55
+ };
56
+ });
57
+
58
+ // Convert log-odds to probability
59
+ let probability = 1 / (1 + Math.exp(-logOddsPosterior));
60
+
61
+ // Apply boosting rules for high-confidence combinations
62
+ const allSignals = results.flatMap((r) => r.signals);
63
+
64
+ // Webdriver + no mouse → almost certainly agent
65
+ const hasWebdriver = allSignals.includes('webdriver-flag') || allSignals.includes('webdriver');
66
+ const noMouse = allSignals.includes('no-mouse-events');
67
+ const noScroll = allSignals.includes('no-scroll-events');
68
+
69
+ if (hasWebdriver && noMouse) {
70
+ probability = Math.max(probability, 0.90);
71
+ } else if (hasWebdriver && noScroll) {
72
+ probability = Math.max(probability, 0.80);
73
+ }
74
+
75
+ // Extension-based agent detection: behavioral pattern combos
76
+ const hasTeleportClicks = allSignals.some((s) => s.startsWith('teleport-clicks:'));
77
+ const hasMinimalApproach = allSignals.some((s) => s.startsWith('minimal-approach-path:'));
78
+ const hasLowEntropy = allSignals.some((s) => s.startsWith('low-trajectory-entropy:'));
79
+ const hasMediumEntropy = allSignals.some((s) => s.startsWith('medium-trajectory-entropy:'));
80
+ const hasNoJitter = allSignals.includes('no-micro-jitter');
81
+ const hasSparseEvents = allSignals.some((s) => s.startsWith('very-sparse-mouse-events:') || s.startsWith('sparse-mouse-events:'));
82
+ const hasTeleportation = allSignals.some((s) => s.startsWith('mouse-teleportation:'));
83
+ const hasLowPointsPerClick = allSignals.some((s) => s.startsWith('low-points-per-click:'));
84
+
85
+ // Teleportation + sparse events = classic extension-based agent pattern
86
+ if (hasTeleportation && hasSparseEvents) {
87
+ probability = Math.max(probability, 0.85);
88
+ }
89
+ if (hasTeleportClicks && hasNoJitter) {
90
+ probability = Math.max(probability, 0.85);
91
+ }
92
+ if (hasTeleportClicks && (hasLowEntropy || hasMediumEntropy)) {
93
+ probability = Math.max(probability, 0.80);
94
+ }
95
+ if (hasMinimalApproach && hasNoJitter) {
96
+ probability = Math.max(probability, 0.80);
97
+ }
98
+ // Sparse events alone with low entropy is very suspicious
99
+ if (hasSparseEvents && (hasLowEntropy || hasMediumEntropy) && hasTeleportation) {
100
+ probability = Math.max(probability, 0.90);
101
+ }
102
+ // Low points per click + sparse events = definitive agent pattern
103
+ if (hasLowPointsPerClick && hasSparseEvents) {
104
+ probability = Math.max(probability, 0.85);
105
+ }
106
+
107
+ // Software renderer is a strong headless signal
108
+ const hasSoftwareRenderer = allSignals.some((s) => s.startsWith('software-renderer:'));
109
+ if (hasSoftwareRenderer) {
110
+ probability = Math.max(probability, 0.80);
111
+ }
112
+
113
+ // Clamp probability
114
+ probability = Math.max(0, Math.min(1, probability));
115
+
116
+ // Determine risk tier
117
+ let riskTier: RiskTier;
118
+ if (probability >= 0.95) riskTier = 'definite-bot';
119
+ else if (probability >= 0.80) riskTier = 'likely-bot';
120
+ else if (probability >= 0.50) riskTier = 'suspicious';
121
+ else if (probability >= 0.20) riskTier = 'likely-human';
122
+ else riskTier = 'definite-human';
123
+
124
+ // Legacy score (0-100) for backwards compatibility
125
+ const legacyScore = Math.round(probability * 100);
126
+
127
+ const isAgent = probability >= 0.50;
128
+
129
+ return {
130
+ score: legacyScore,
131
+ probability,
132
+ riskTier,
133
+ isAgent,
134
+ results,
135
+ signals: signalDetails,
136
+ classification: {
137
+ classification: isAgent ? 'bot' : 'human',
138
+ probabilities: { human: 1 - probability, bot: probability, agent: 0 },
139
+ source: 'heuristic',
140
+ },
141
+ };
142
+ }
143
+
144
+ export function runSync(): DetectionOutput {
145
+ const results = [
146
+ detectUserAgent(),
147
+ detectHeadless(),
148
+ detectAutomation(),
149
+ detectNavigator(),
150
+ detectFingerprint(),
151
+ ];
152
+ return bayesianFusion(results);
153
+ }
154
+
155
+ export async function runFull(): Promise<DetectionOutput> {
156
+ const syncResults = [
157
+ detectUserAgent(),
158
+ detectHeadless(),
159
+ detectAutomation(),
160
+ detectNavigator(),
161
+ detectFingerprint(),
162
+ ];
163
+ const behavioral = await detectBehavioral();
164
+ return bayesianFusion([...syncResults, behavioral]);
165
+ }
166
+
167
+ export function applyModelClassification(
168
+ output: DetectionOutput,
169
+ modelResult: { classification: string; probabilities: { human: number; bot: number; agent: number } },
170
+ ): DetectionOutput {
171
+ const prob = 1 - modelResult.probabilities.human;
172
+ let riskTier: RiskTier;
173
+ if (prob >= 0.95) riskTier = 'definite-bot';
174
+ else if (prob >= 0.80) riskTier = 'likely-bot';
175
+ else if (prob >= 0.50) riskTier = 'suspicious';
176
+ else if (prob >= 0.20) riskTier = 'likely-human';
177
+ else riskTier = 'definite-human';
178
+
179
+ return {
180
+ ...output,
181
+ probability: prob,
182
+ riskTier,
183
+ isAgent: modelResult.classification !== 'human',
184
+ classification: {
185
+ classification: modelResult.classification as 'human' | 'bot' | 'agent',
186
+ probabilities: modelResult.probabilities,
187
+ source: 'model',
188
+ },
189
+ };
190
+ }
191
+
192
+ /** Run sync detectors + an externally-provided behavioral result (from persistent monitor) */
193
+ export function runWithBehavioral(behavioral: import('./types').DetectorResult): DetectionOutput {
194
+ const syncResults = [
195
+ detectUserAgent(),
196
+ detectHeadless(),
197
+ detectAutomation(),
198
+ detectNavigator(),
199
+ detectFingerprint(),
200
+ ];
201
+ return bayesianFusion([...syncResults, behavioral]);
202
+ }
@@ -0,0 +1,102 @@
1
+ import type { DetectorResult } from './types';
2
+
3
+ /**
4
+ * Navigator/API consistency checks.
5
+ * Catches bots that spoof user-agent but forget to sync Client Hints,
6
+ * platform, and browser-specific APIs.
7
+ */
8
+ export function detectNavigator(): DetectorResult {
9
+ let score = 0;
10
+ const signals: string[] = [];
11
+ const ua = navigator.userAgent;
12
+
13
+ // 1. Client Hints mismatch: UA says one platform but userAgentData says another
14
+ try {
15
+ const uaData = (navigator as any).userAgentData;
16
+ if (uaData) {
17
+ const uaPlatform = uaData.platform?.toLowerCase() || '';
18
+ const claimsMac = /Macintosh|Mac OS X/i.test(ua);
19
+ const claimsWindows = /Windows/i.test(ua);
20
+ const claimsLinux = /Linux/i.test(ua) && !/Android/i.test(ua);
21
+
22
+ if (claimsMac && uaPlatform && uaPlatform !== 'macos' && uaPlatform !== 'mac os x') {
23
+ score += 30;
24
+ signals.push('client-hints-platform-mismatch');
25
+ } else if (claimsWindows && uaPlatform && uaPlatform !== 'windows') {
26
+ score += 30;
27
+ signals.push('client-hints-platform-mismatch');
28
+ } else if (claimsLinux && uaPlatform && uaPlatform !== 'linux') {
29
+ score += 30;
30
+ signals.push('client-hints-platform-mismatch');
31
+ }
32
+ } else if (/Chrome\/\d/.test(ua) && parseInt(ua.match(/Chrome\/(\d+)/)?.[1] || '0') >= 90) {
33
+ // Chrome 90+ should have userAgentData
34
+ score += 15;
35
+ signals.push('missing-user-agent-data');
36
+ }
37
+ } catch {
38
+ // Ignore errors
39
+ }
40
+
41
+ // 2. Hardware concurrency: containerized/headless often has 1 or undefined
42
+ if (navigator.hardwareConcurrency !== undefined && navigator.hardwareConcurrency <= 1) {
43
+ score += 15;
44
+ signals.push('low-hardware-concurrency');
45
+ }
46
+
47
+ // 3. Device memory: missing in many headless setups
48
+ if (/Chrome/.test(ua) && (navigator as any).deviceMemory === undefined) {
49
+ score += 10;
50
+ signals.push('missing-device-memory');
51
+ }
52
+
53
+ // 4. Connection API: real browsers typically have this
54
+ if (/Chrome/.test(ua) && !(navigator as any).connection) {
55
+ score += 10;
56
+ signals.push('missing-connection-api');
57
+ }
58
+
59
+ // 5. Platform inconsistency: UA claims one OS but navigator.platform says another
60
+ try {
61
+ const platform = navigator.platform?.toLowerCase() || '';
62
+ const claimsMac = /Macintosh|Mac OS X/i.test(ua);
63
+ const claimsWindows = /Windows/i.test(ua);
64
+
65
+ if (claimsMac && platform && !platform.includes('mac')) {
66
+ score += 25;
67
+ signals.push('platform-ua-mismatch');
68
+ } else if (claimsWindows && platform && !platform.includes('win')) {
69
+ score += 25;
70
+ signals.push('platform-ua-mismatch');
71
+ }
72
+ } catch {
73
+ // Ignore
74
+ }
75
+
76
+ // 6. Screen/display anomalies (moved here from headless for better grouping)
77
+ // outerHeight === innerHeight means no browser chrome (tabs, bookmarks, address bar)
78
+ if (window.outerHeight > 0 && window.innerHeight > 0 &&
79
+ window.outerHeight === window.innerHeight) {
80
+ score += 10;
81
+ signals.push('no-browser-chrome');
82
+ }
83
+
84
+ // screen.width === screen.availWidth means no OS taskbar
85
+ if (screen.width === screen.availWidth && screen.height === screen.availHeight &&
86
+ screen.width > 0) {
87
+ score += 5;
88
+ signals.push('no-taskbar');
89
+ }
90
+
91
+ // Mobile UA claiming devicePixelRatio === 1 (real mobile devices have DPR >= 2)
92
+ if (/Mobile|Android|iPhone/i.test(ua) && window.devicePixelRatio === 1) {
93
+ score += 15;
94
+ signals.push('mobile-ua-low-dpr');
95
+ }
96
+
97
+ return {
98
+ detector: 'navigator',
99
+ rawScore: Math.min(score, 100),
100
+ signals,
101
+ };
102
+ }
@@ -0,0 +1,52 @@
1
+ import type { ModelClassification } from '@toffee-at/shared';
2
+
3
+ export type DetectorName =
4
+ | 'user-agent'
5
+ | 'headless'
6
+ | 'automation'
7
+ | 'behavioral'
8
+ | 'navigator'
9
+ | 'fingerprint';
10
+
11
+ export interface DetectorResult {
12
+ detector: DetectorName;
13
+ rawScore: number; // 0-100
14
+ signals: string[];
15
+ }
16
+
17
+ export type RiskTier =
18
+ | 'definite-bot' // probability >= 0.95
19
+ | 'likely-bot' // probability >= 0.80
20
+ | 'suspicious' // probability >= 0.50
21
+ | 'likely-human' // probability >= 0.20
22
+ | 'definite-human'; // probability < 0.20
23
+
24
+ export type AgentCategory =
25
+ | 'human'
26
+ | 'ai-assistant'
27
+ | 'ai-crawler'
28
+ | 'ai-agent'
29
+ | 'search-engine'
30
+ | 'social-preview'
31
+ | 'monitoring'
32
+ | 'scraper'
33
+ | 'automation-tool'
34
+ | 'unknown-bot';
35
+
36
+ export interface DetectionOutput {
37
+ score: number; // 0-100 legacy score
38
+ probability: number; // 0.0-1.0
39
+ riskTier: RiskTier;
40
+ isAgent: boolean;
41
+ results: DetectorResult[];
42
+ signals: {
43
+ detector: string;
44
+ fired: boolean;
45
+ llr: number;
46
+ rawScore: number;
47
+ evidence: string[];
48
+ }[];
49
+
50
+ // ML model classification
51
+ classification: ModelClassification;
52
+ }
@@ -0,0 +1,21 @@
1
+ import { KNOWN_AGENTS, matchAgent } from '@toffee-at/shared';
2
+ import type { DetectorResult } from './types';
3
+
4
+ export function detectUserAgent(): DetectorResult {
5
+ const ua = navigator.userAgent;
6
+ const match = matchAgent(ua);
7
+
8
+ if (match) {
9
+ return {
10
+ detector: 'user-agent',
11
+ rawScore: 100,
12
+ signals: [`matched-agent:${match.name}`],
13
+ };
14
+ }
15
+
16
+ return {
17
+ detector: 'user-agent',
18
+ rawScore: 0,
19
+ signals: [],
20
+ };
21
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { createCore, type AgentRadarConfig } from './core';
2
+ import type { DetectionOutput } from './detect/types';
3
+
4
+ let instance: ReturnType<typeof createCore> | null = null;
5
+
6
+ export function init(config: AgentRadarConfig) {
7
+ if (instance) return instance;
8
+ instance = createCore(config);
9
+ instance.start();
10
+ return instance;
11
+ }
12
+
13
+ export function identify(metadata: Record<string, string>) {
14
+ instance?.identify(metadata);
15
+ }
16
+
17
+ export function getDetection(): DetectionOutput | null {
18
+ return instance?.getDetection() ?? null;
19
+ }
20
+
21
+ export type { AgentRadarConfig } from './core';
22
+ export type { DetectionOutput, RiskTier, AgentCategory } from './detect/types';
@@ -0,0 +1,105 @@
1
+ import type { InteractionEvent, TrackingEvent } from './types';
2
+
3
+ const TRACKED_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,a,button,form,input,img,[data-ar-track]';
4
+
5
+ function getSelector(el: Element): string {
6
+ if (el.id) return `#${el.id}`;
7
+ const arTrack = el.getAttribute('data-ar-track');
8
+ if (arTrack) return `[data-ar-track="${arTrack}"]`;
9
+ const tag = el.tagName.toLowerCase();
10
+ const cls = el.className && typeof el.className === 'string'
11
+ ? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}`
12
+ : '';
13
+ return `${tag}${cls}`;
14
+ }
15
+
16
+ function getTextContent(el: Element): string | undefined {
17
+ const text = el.textContent?.trim().slice(0, 100);
18
+ return text || undefined;
19
+ }
20
+
21
+ export function setupElementTracking(onEvent: (event: TrackingEvent) => void): () => void {
22
+ const dwellMap = new Map<Element, number>();
23
+
24
+ // IntersectionObserver for visibility tracking
25
+ const observer = new IntersectionObserver(
26
+ (entries) => {
27
+ for (const entry of entries) {
28
+ if (entry.isIntersecting) {
29
+ dwellMap.set(entry.target, Date.now());
30
+ } else {
31
+ const start = dwellMap.get(entry.target);
32
+ if (start) {
33
+ const event: InteractionEvent = {
34
+ type: 'interaction',
35
+ action: 'view',
36
+ selector: getSelector(entry.target),
37
+ tag: entry.target.tagName.toLowerCase(),
38
+ text: getTextContent(entry.target),
39
+ dwellMs: Date.now() - start,
40
+ url: location.href,
41
+ timestamp: Date.now(),
42
+ };
43
+ onEvent(event);
44
+ dwellMap.delete(entry.target);
45
+ }
46
+ }
47
+ }
48
+ },
49
+ { threshold: 0.5 }
50
+ );
51
+
52
+ // Observe existing elements
53
+ function observeElements() {
54
+ const elements = document.querySelectorAll(TRACKED_SELECTOR);
55
+ for (const el of elements) {
56
+ observer.observe(el);
57
+ }
58
+ }
59
+ observeElements();
60
+
61
+ // MutationObserver to pick up dynamically added elements
62
+ const mutationObserver = new MutationObserver(() => observeElements());
63
+ mutationObserver.observe(document.body, { childList: true, subtree: true });
64
+
65
+ // Event delegation for clicks
66
+ const onClick = (e: Event) => {
67
+ const target = (e.target as Element)?.closest?.(TRACKED_SELECTOR);
68
+ if (!target) return;
69
+ const event: InteractionEvent = {
70
+ type: 'interaction',
71
+ action: 'click',
72
+ selector: getSelector(target),
73
+ tag: target.tagName.toLowerCase(),
74
+ text: getTextContent(target),
75
+ url: location.href,
76
+ timestamp: Date.now(),
77
+ };
78
+ onEvent(event);
79
+ };
80
+
81
+ // Event delegation for input
82
+ const onInput = (e: Event) => {
83
+ const target = e.target as Element;
84
+ if (!target?.matches?.('input,textarea,select,[data-ar-track]')) return;
85
+ const event: InteractionEvent = {
86
+ type: 'interaction',
87
+ action: 'input',
88
+ selector: getSelector(target),
89
+ tag: target.tagName.toLowerCase(),
90
+ url: location.href,
91
+ timestamp: Date.now(),
92
+ };
93
+ onEvent(event);
94
+ };
95
+
96
+ document.body.addEventListener('click', onClick, { passive: true });
97
+ document.body.addEventListener('input', onInput, { passive: true });
98
+
99
+ return () => {
100
+ observer.disconnect();
101
+ mutationObserver.disconnect();
102
+ document.body.removeEventListener('click', onClick);
103
+ document.body.removeEventListener('input', onInput);
104
+ };
105
+ }
@@ -0,0 +1,15 @@
1
+ import type { TrackingEvent } from './types';
2
+ import { setupPageTracking } from './page';
3
+ import { setupElementTracking } from './elements';
4
+
5
+ export type { TrackingEvent, PageviewEvent, InteractionEvent } from './types';
6
+
7
+ export function setupTracking(onEvent: (event: TrackingEvent) => void): () => void {
8
+ const destroyPage = setupPageTracking(onEvent);
9
+ const destroyElements = setupElementTracking(onEvent);
10
+
11
+ return () => {
12
+ destroyPage();
13
+ destroyElements();
14
+ };
15
+ }
@@ -0,0 +1,43 @@
1
+ import type { PageviewEvent, TrackingEvent } from './types';
2
+
3
+ export function setupPageTracking(onEvent: (event: TrackingEvent) => void): () => void {
4
+ let startTime = Date.now();
5
+
6
+ function emitPageview() {
7
+ const event: PageviewEvent = {
8
+ type: 'pageview',
9
+ url: location.href,
10
+ referrer: document.referrer,
11
+ timestamp: Date.now(),
12
+ };
13
+ startTime = Date.now();
14
+ onEvent(event);
15
+ }
16
+
17
+ // Emit initial pageview
18
+ emitPageview();
19
+
20
+ // Listen for popstate (back/forward navigation)
21
+ const onPopState = () => emitPageview();
22
+ window.addEventListener('popstate', onPopState);
23
+
24
+ // Monkey-patch pushState and replaceState for SPA navigation
25
+ const originalPushState = history.pushState.bind(history);
26
+ const originalReplaceState = history.replaceState.bind(history);
27
+
28
+ history.pushState = function (...args: Parameters<typeof history.pushState>) {
29
+ originalPushState(...args);
30
+ emitPageview();
31
+ };
32
+
33
+ history.replaceState = function (...args: Parameters<typeof history.replaceState>) {
34
+ originalReplaceState(...args);
35
+ emitPageview();
36
+ };
37
+
38
+ return () => {
39
+ window.removeEventListener('popstate', onPopState);
40
+ history.pushState = originalPushState;
41
+ history.replaceState = originalReplaceState;
42
+ };
43
+ }
@@ -0,0 +1,19 @@
1
+ function fnv1a(str: string): string {
2
+ let hash = 0x811c9dc5;
3
+ for (let i = 0; i < str.length; i++) {
4
+ hash ^= str.charCodeAt(i);
5
+ hash = (hash * 0x01000193) >>> 0;
6
+ }
7
+ return hash.toString(36);
8
+ }
9
+
10
+ export function createSessionId(): string {
11
+ const parts = [
12
+ navigator.userAgent,
13
+ `${screen.width}x${screen.height}`,
14
+ Intl.DateTimeFormat().resolvedOptions().timeZone,
15
+ navigator.language,
16
+ new Date().toDateString(), // daily rotation
17
+ ];
18
+ return fnv1a(parts.join('|'));
19
+ }
@@ -0,0 +1,19 @@
1
+ export interface PageviewEvent {
2
+ type: 'pageview';
3
+ url: string;
4
+ referrer: string;
5
+ timestamp: number;
6
+ }
7
+
8
+ export interface InteractionEvent {
9
+ type: 'interaction';
10
+ action: 'click' | 'input' | 'view';
11
+ selector: string;
12
+ tag: string;
13
+ text?: string;
14
+ dwellMs?: number;
15
+ url: string;
16
+ timestamp: number;
17
+ }
18
+
19
+ export type TrackingEvent = PageviewEvent | InteractionEvent;