@surbee/cipher 0.1.0 → 0.2.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.
package/dist/index.mjs CHANGED
@@ -1,64 +1,625 @@
1
- // src/cipher.ts
2
- var DEFAULT_CONFIG = {
3
- tier: 3,
4
- thresholds: {
5
- fail: 0.4,
6
- review: 0.7
1
+ import {
2
+ runBehavioralChecks,
3
+ runContentChecks,
4
+ runDeviceChecks,
5
+ runTimingChecks
6
+ } from "./chunk-P2MIOVFQ.mjs";
7
+
8
+ // src/tiers.ts
9
+ var CHECKS = {
10
+ // ============================================
11
+ // TIER 1 - Basic Behavioral (Offline)
12
+ // ============================================
13
+ rapid_completion: {
14
+ id: "rapid_completion",
15
+ name: "Rapid Completion",
16
+ description: "Detects impossibly fast survey completion",
17
+ tier: 1,
18
+ category: "behavioral",
19
+ offline: true
7
20
  },
8
- debug: false,
9
- endpoint: "https://api.surbee.com/v1/cipher"
21
+ uniform_timing: {
22
+ id: "uniform_timing",
23
+ name: "Uniform Timing",
24
+ description: "Detects robotic consistent response times",
25
+ tier: 1,
26
+ category: "behavioral",
27
+ offline: true
28
+ },
29
+ low_interaction: {
30
+ id: "low_interaction",
31
+ name: "Low Interaction",
32
+ description: "Detects minimal mouse/keyboard activity",
33
+ tier: 1,
34
+ category: "behavioral",
35
+ offline: true
36
+ },
37
+ straight_line_answers: {
38
+ id: "straight_line_answers",
39
+ name: "Straight-Lining",
40
+ description: "Detects selecting same option repeatedly",
41
+ tier: 1,
42
+ category: "content",
43
+ offline: true
44
+ },
45
+ impossibly_fast: {
46
+ id: "impossibly_fast",
47
+ name: "Speed Reading",
48
+ description: "Detects reading faster than humanly possible",
49
+ tier: 1,
50
+ category: "timing",
51
+ offline: true
52
+ },
53
+ minimal_effort: {
54
+ id: "minimal_effort",
55
+ name: "Minimal Effort",
56
+ description: "Detects very short or low-quality text responses",
57
+ tier: 1,
58
+ category: "content",
59
+ offline: true
60
+ },
61
+ // ============================================
62
+ // TIER 2 - Device/Automation (Offline)
63
+ // ============================================
64
+ excessive_paste: {
65
+ id: "excessive_paste",
66
+ name: "Excessive Paste",
67
+ description: "Detects heavy copy-paste behavior",
68
+ tier: 2,
69
+ category: "behavioral",
70
+ offline: true
71
+ },
72
+ pointer_spikes: {
73
+ id: "pointer_spikes",
74
+ name: "Pointer Velocity Spikes",
75
+ description: "Detects unnatural mouse movement patterns",
76
+ tier: 2,
77
+ category: "behavioral",
78
+ offline: true
79
+ },
80
+ webdriver_detected: {
81
+ id: "webdriver_detected",
82
+ name: "WebDriver Detection",
83
+ description: "Detects Selenium/automation tools",
84
+ tier: 2,
85
+ category: "device",
86
+ offline: true
87
+ },
88
+ automation_detected: {
89
+ id: "automation_detected",
90
+ name: "Automation Detection",
91
+ description: "Detects headless browsers and bots",
92
+ tier: 2,
93
+ category: "device",
94
+ offline: true
95
+ },
96
+ no_plugins: {
97
+ id: "no_plugins",
98
+ name: "Missing Plugins",
99
+ description: "Detects suspicious browser configurations",
100
+ tier: 2,
101
+ category: "device",
102
+ offline: true
103
+ },
104
+ suspicious_user_agent: {
105
+ id: "suspicious_user_agent",
106
+ name: "Suspicious User Agent",
107
+ description: "Detects bot-like user agent strings",
108
+ tier: 2,
109
+ category: "device",
110
+ offline: true
111
+ },
112
+ device_fingerprint_mismatch: {
113
+ id: "device_fingerprint_mismatch",
114
+ name: "Device Mismatch",
115
+ description: "Detects inconsistent device characteristics",
116
+ tier: 2,
117
+ category: "device",
118
+ offline: true
119
+ },
120
+ screen_anomaly: {
121
+ id: "screen_anomaly",
122
+ name: "Screen Anomaly",
123
+ description: "Detects impossible screen dimensions",
124
+ tier: 2,
125
+ category: "device",
126
+ offline: true
127
+ },
128
+ suspicious_pauses: {
129
+ id: "suspicious_pauses",
130
+ name: "Suspicious Pauses",
131
+ description: "Detects unusual gaps in activity",
132
+ tier: 2,
133
+ category: "timing",
134
+ offline: true
135
+ },
136
+ // ============================================
137
+ // TIER 3 - Enhanced Behavioral + Light AI
138
+ // ============================================
139
+ robotic_typing: {
140
+ id: "robotic_typing",
141
+ name: "Robotic Typing",
142
+ description: "Detects uniform keystroke timing",
143
+ tier: 3,
144
+ category: "behavioral",
145
+ offline: true
146
+ },
147
+ mouse_teleporting: {
148
+ id: "mouse_teleporting",
149
+ name: "Mouse Teleporting",
150
+ description: "Detects large instant mouse jumps",
151
+ tier: 3,
152
+ category: "behavioral",
153
+ offline: true
154
+ },
155
+ no_corrections: {
156
+ id: "no_corrections",
157
+ name: "No Corrections",
158
+ description: "Detects perfect typing with no backspaces",
159
+ tier: 3,
160
+ category: "behavioral",
161
+ offline: true
162
+ },
163
+ excessive_tab_switching: {
164
+ id: "excessive_tab_switching",
165
+ name: "Tab Switching",
166
+ description: "Detects frequent tab/window changes",
167
+ tier: 3,
168
+ category: "content",
169
+ offline: true
170
+ },
171
+ window_focus_loss: {
172
+ id: "window_focus_loss",
173
+ name: "Focus Loss",
174
+ description: "Detects extended periods away from survey",
175
+ tier: 3,
176
+ category: "content",
177
+ offline: true
178
+ },
179
+ ai_content_basic: {
180
+ id: "ai_content_basic",
181
+ name: "AI Content (Basic)",
182
+ description: "Light AI-generated text detection",
183
+ tier: 3,
184
+ category: "ai",
185
+ offline: false
186
+ },
187
+ contradiction_basic: {
188
+ id: "contradiction_basic",
189
+ name: "Contradiction (Basic)",
190
+ description: "Basic response consistency check",
191
+ tier: 3,
192
+ category: "ai",
193
+ offline: false
194
+ },
195
+ // ============================================
196
+ // TIER 4 - Advanced Analysis
197
+ // ============================================
198
+ hover_behavior: {
199
+ id: "hover_behavior",
200
+ name: "Hover Patterns",
201
+ description: "Analyzes mouse hover behavior before clicks",
202
+ tier: 4,
203
+ category: "behavioral",
204
+ offline: true
205
+ },
206
+ scroll_patterns: {
207
+ id: "scroll_patterns",
208
+ name: "Scroll Patterns",
209
+ description: "Analyzes reading/scrolling behavior",
210
+ tier: 4,
211
+ category: "behavioral",
212
+ offline: true
213
+ },
214
+ mouse_acceleration: {
215
+ id: "mouse_acceleration",
216
+ name: "Mouse Acceleration",
217
+ description: "Analyzes natural mouse acceleration",
218
+ tier: 4,
219
+ category: "behavioral",
220
+ offline: true
221
+ },
222
+ vpn_detection: {
223
+ id: "vpn_detection",
224
+ name: "VPN Detection",
225
+ description: "Detects VPN/proxy usage",
226
+ tier: 4,
227
+ category: "network",
228
+ offline: false
229
+ },
230
+ datacenter_ip: {
231
+ id: "datacenter_ip",
232
+ name: "Datacenter IP",
233
+ description: "Detects cloud/datacenter IPs",
234
+ tier: 4,
235
+ category: "network",
236
+ offline: false
237
+ },
238
+ plagiarism_basic: {
239
+ id: "plagiarism_basic",
240
+ name: "Plagiarism (Basic)",
241
+ description: "Quick check for copied content",
242
+ tier: 4,
243
+ category: "ai",
244
+ offline: false
245
+ },
246
+ quality_assessment: {
247
+ id: "quality_assessment",
248
+ name: "Quality Assessment",
249
+ description: "AI assessment of response quality",
250
+ tier: 4,
251
+ category: "ai",
252
+ offline: false
253
+ },
254
+ semantic_analysis: {
255
+ id: "semantic_analysis",
256
+ name: "Semantic Analysis",
257
+ description: "AI analysis of response meaning",
258
+ tier: 4,
259
+ category: "ai",
260
+ offline: false
261
+ },
262
+ // ============================================
263
+ // TIER 5 - Maximum (Opus 4.5)
264
+ // ============================================
265
+ ai_content_full: {
266
+ id: "ai_content_full",
267
+ name: "AI Content (Full)",
268
+ description: "Comprehensive AI-generated text detection",
269
+ tier: 5,
270
+ category: "ai",
271
+ offline: false
272
+ },
273
+ contradiction_full: {
274
+ id: "contradiction_full",
275
+ name: "Contradiction (Full)",
276
+ description: "Deep semantic contradiction analysis",
277
+ tier: 5,
278
+ category: "ai",
279
+ offline: false
280
+ },
281
+ plagiarism_full: {
282
+ id: "plagiarism_full",
283
+ name: "Plagiarism (Full)",
284
+ description: "Comprehensive plagiarism detection",
285
+ tier: 5,
286
+ category: "ai",
287
+ offline: false
288
+ },
289
+ fraud_ring_detection: {
290
+ id: "fraud_ring_detection",
291
+ name: "Fraud Ring",
292
+ description: "Detects coordinated fraud attempts",
293
+ tier: 5,
294
+ category: "ai",
295
+ offline: false
296
+ },
297
+ answer_sharing: {
298
+ id: "answer_sharing",
299
+ name: "Answer Sharing",
300
+ description: "Detects identical answers across respondents",
301
+ tier: 5,
302
+ category: "ai",
303
+ offline: false
304
+ },
305
+ coordinated_timing: {
306
+ id: "coordinated_timing",
307
+ name: "Coordinated Timing",
308
+ description: "Detects synchronized submissions",
309
+ tier: 5,
310
+ category: "ai",
311
+ offline: false
312
+ },
313
+ device_sharing: {
314
+ id: "device_sharing",
315
+ name: "Device Sharing",
316
+ description: "Detects same device across respondents",
317
+ tier: 5,
318
+ category: "ai",
319
+ offline: false
320
+ },
321
+ tor_detection: {
322
+ id: "tor_detection",
323
+ name: "Tor Detection",
324
+ description: "Detects Tor exit node IPs",
325
+ tier: 5,
326
+ category: "network",
327
+ offline: false
328
+ },
329
+ proxy_detection: {
330
+ id: "proxy_detection",
331
+ name: "Proxy Detection",
332
+ description: "Detects proxy server usage",
333
+ tier: 5,
334
+ category: "network",
335
+ offline: false
336
+ },
337
+ timezone_validation: {
338
+ id: "timezone_validation",
339
+ name: "Timezone Validation",
340
+ description: "Validates timezone consistency",
341
+ tier: 5,
342
+ category: "network",
343
+ offline: true
344
+ },
345
+ baseline_deviation: {
346
+ id: "baseline_deviation",
347
+ name: "Baseline Deviation",
348
+ description: "Compares against established behavioral baseline",
349
+ tier: 5,
350
+ category: "ai",
351
+ offline: false
352
+ },
353
+ perplexity_analysis: {
354
+ id: "perplexity_analysis",
355
+ name: "Perplexity Analysis",
356
+ description: "Statistical text predictability analysis",
357
+ tier: 5,
358
+ category: "ai",
359
+ offline: false
360
+ },
361
+ burstiness_analysis: {
362
+ id: "burstiness_analysis",
363
+ name: "Burstiness Analysis",
364
+ description: "Sentence length variation analysis",
365
+ tier: 5,
366
+ category: "ai",
367
+ offline: false
368
+ }
10
369
  };
11
- var TIER_INFO = {
370
+ var TIERS = {
12
371
  1: {
13
372
  name: "Basic",
14
- description: "Essential fraud detection with behavioral heuristics",
15
- checksCount: 6
373
+ description: "Essential fraud detection with behavioral heuristics. Free, fully offline.",
374
+ checks: {
375
+ behavioral: ["rapid_completion", "uniform_timing", "low_interaction"],
376
+ timing: ["impossibly_fast"],
377
+ device: [],
378
+ content: ["straight_line_answers", "minimal_effort"],
379
+ network: [],
380
+ ai: []
381
+ },
382
+ aiModel: null,
383
+ offline: true,
384
+ estimatedCostPerResponse: 0
16
385
  },
17
386
  2: {
18
387
  name: "Standard",
19
- description: "Adds device fingerprinting and automation detection",
20
- checksCount: 15
388
+ description: "Adds device fingerprinting and automation detection. Free, fully offline.",
389
+ checks: {
390
+ behavioral: ["rapid_completion", "uniform_timing", "low_interaction", "excessive_paste", "pointer_spikes"],
391
+ timing: ["impossibly_fast", "suspicious_pauses"],
392
+ device: ["webdriver_detected", "automation_detected", "no_plugins", "suspicious_user_agent", "device_fingerprint_mismatch", "screen_anomaly"],
393
+ content: ["straight_line_answers", "minimal_effort"],
394
+ network: [],
395
+ ai: []
396
+ },
397
+ aiModel: null,
398
+ offline: true,
399
+ estimatedCostPerResponse: 0
21
400
  },
22
401
  3: {
23
402
  name: "Enhanced",
24
- description: "Adds AI-powered content quality analysis",
25
- checksCount: 22
403
+ description: "Adds Claude Sonnet 4.5 for content quality analysis. ~$0.003 per response.",
404
+ checks: {
405
+ behavioral: ["rapid_completion", "uniform_timing", "low_interaction", "excessive_paste", "pointer_spikes", "robotic_typing", "mouse_teleporting", "no_corrections"],
406
+ timing: ["impossibly_fast", "suspicious_pauses"],
407
+ device: ["webdriver_detected", "automation_detected", "no_plugins", "suspicious_user_agent", "device_fingerprint_mismatch", "screen_anomaly"],
408
+ content: ["straight_line_answers", "minimal_effort", "excessive_tab_switching", "window_focus_loss"],
409
+ network: [],
410
+ ai: ["ai_content_basic", "contradiction_basic"]
411
+ },
412
+ aiModel: "claude-sonnet-4-5-20250514",
413
+ offline: false,
414
+ estimatedCostPerResponse: 3e-3
26
415
  },
27
416
  4: {
28
417
  name: "Advanced",
29
- description: "Full behavioral analysis with network validation",
30
- checksCount: 30
418
+ description: "Full behavioral analysis + semantic AI. ~$0.01 per response.",
419
+ checks: {
420
+ behavioral: ["rapid_completion", "uniform_timing", "low_interaction", "excessive_paste", "pointer_spikes", "robotic_typing", "mouse_teleporting", "no_corrections", "hover_behavior", "scroll_patterns", "mouse_acceleration"],
421
+ timing: ["impossibly_fast", "suspicious_pauses"],
422
+ device: ["webdriver_detected", "automation_detected", "no_plugins", "suspicious_user_agent", "device_fingerprint_mismatch", "screen_anomaly"],
423
+ content: ["straight_line_answers", "minimal_effort", "excessive_tab_switching", "window_focus_loss"],
424
+ network: ["vpn_detection", "datacenter_ip"],
425
+ ai: ["ai_content_basic", "contradiction_basic", "plagiarism_basic", "quality_assessment", "semantic_analysis"]
426
+ },
427
+ aiModel: "claude-sonnet-4-5-20250514",
428
+ offline: false,
429
+ estimatedCostPerResponse: 0.01
31
430
  },
32
431
  5: {
33
432
  name: "Maximum",
34
- description: "All 43 checks with cross-respondent fraud detection",
35
- checksCount: 43
433
+ description: "All 40+ checks with Claude Opus 4.5. Maximum accuracy. ~$0.05 per response.",
434
+ checks: {
435
+ behavioral: ["rapid_completion", "uniform_timing", "low_interaction", "excessive_paste", "pointer_spikes", "robotic_typing", "mouse_teleporting", "no_corrections", "hover_behavior", "scroll_patterns", "mouse_acceleration"],
436
+ timing: ["impossibly_fast", "suspicious_pauses"],
437
+ device: ["webdriver_detected", "automation_detected", "no_plugins", "suspicious_user_agent", "device_fingerprint_mismatch", "screen_anomaly"],
438
+ content: ["straight_line_answers", "minimal_effort", "excessive_tab_switching", "window_focus_loss"],
439
+ network: ["vpn_detection", "datacenter_ip", "tor_detection", "proxy_detection", "timezone_validation"],
440
+ ai: ["ai_content_full", "contradiction_full", "plagiarism_full", "quality_assessment", "semantic_analysis", "fraud_ring_detection", "answer_sharing", "coordinated_timing", "device_sharing", "baseline_deviation", "perplexity_analysis", "burstiness_analysis"]
441
+ },
442
+ aiModel: "claude-opus-4-5-20250514",
443
+ offline: false,
444
+ estimatedCostPerResponse: 0.05
36
445
  }
37
446
  };
447
+ function getChecksForTier(tier) {
448
+ const config = TIERS[tier];
449
+ return [
450
+ ...config.checks.behavioral,
451
+ ...config.checks.timing,
452
+ ...config.checks.device,
453
+ ...config.checks.content,
454
+ ...config.checks.network,
455
+ ...config.checks.ai
456
+ ];
457
+ }
458
+ function getOfflineChecksForTier(tier) {
459
+ return getChecksForTier(tier).filter((id) => CHECKS[id].offline);
460
+ }
461
+ function estimateCost(tier, responses) {
462
+ return TIERS[tier].estimatedCostPerResponse * responses;
463
+ }
464
+
465
+ // src/scoring.ts
466
+ function checkName(checkId) {
467
+ return CHECKS[checkId]?.name ?? checkId;
468
+ }
469
+ function clamp01(n) {
470
+ return Math.max(0, Math.min(1, n));
471
+ }
472
+ function noisyOr(scores) {
473
+ return 1 - scores.reduce((product, s) => product * (1 - clamp01(s)), 1);
474
+ }
475
+ function offlineRequestId() {
476
+ return `req_local_${Math.random().toString(36).slice(2, 12)}`;
477
+ }
478
+ function buildSummary(checks, score, recommendation) {
479
+ const failed = checks.filter((c) => !c.passed);
480
+ let verdict;
481
+ if (score >= 0.85) verdict = "High-quality legitimate response";
482
+ else if (score >= 0.7) verdict = "Likely legitimate with minor concerns";
483
+ else if (score >= 0.5) verdict = "Borderline quality \u2014 manual review recommended";
484
+ else if (score >= 0.3) verdict = "Multiple issues detected \u2014 likely low quality";
485
+ else verdict = "Suspected fraudulent or bot response";
486
+ const issues = failed.map((c) => c.details ?? checkName(c.checkId));
487
+ const positives = [];
488
+ const failedIds = new Set(failed.map((c) => c.checkId));
489
+ if (![...failedIds].some((id) => id.includes("timing") || id === "rapid_completion" || id === "impossibly_fast")) {
490
+ positives.push("Response timing appears natural");
491
+ }
492
+ if (![...failedIds].some((id) => id.includes("automation") || id === "webdriver_detected")) {
493
+ positives.push("No automation tools detected");
494
+ }
495
+ if (![...failedIds].some((id) => id.includes("ai_content"))) {
496
+ positives.push("Content appears human-written");
497
+ }
498
+ if (!failedIds.has("minimal_effort")) {
499
+ positives.push("Response shows reasonable effort");
500
+ }
501
+ let suggestion;
502
+ if (recommendation === "keep") suggestion = "Response can be accepted as-is";
503
+ else if (recommendation === "review") suggestion = "Consider manual review before accepting";
504
+ else suggestion = "Response should be rejected or flagged for investigation";
505
+ return { verdict, issues, positives, suggestion };
506
+ }
507
+ function buildResult(checks, tier, thresholds, processingTimeMs) {
508
+ const failedChecks = checks.filter((c) => !c.passed);
509
+ const passedChecks = checks.filter((c) => c.passed);
510
+ const hardSuspicion = noisyOr(failedChecks.map((c) => c.score));
511
+ const softMean = checks.length > 0 ? passedChecks.reduce((sum, c) => sum + c.score, 0) / checks.length : 0;
512
+ const softSuspicion = Math.min(0.35, softMean);
513
+ const suspicion = 1 - (1 - hardSuspicion) * (1 - softSuspicion);
514
+ const score = clamp01(1 - suspicion);
515
+ const passed = score >= thresholds.fail;
516
+ let recommendation;
517
+ if (score >= thresholds.review) recommendation = "keep";
518
+ else if (score >= thresholds.fail) recommendation = "review";
519
+ else recommendation = "discard";
520
+ const flags = failedChecks.map((c) => checkName(c.checkId));
521
+ const summary = buildSummary(checks, score, recommendation);
522
+ const strongestSignal = checks.reduce((max, c) => Math.max(max, c.score), 0);
523
+ const confidence = clamp01(0.5 + checks.length * 0.015 + strongestSignal * 0.2);
524
+ return {
525
+ score,
526
+ passed,
527
+ recommendation,
528
+ confidence,
529
+ flags,
530
+ summary,
531
+ checks,
532
+ meta: {
533
+ tier,
534
+ processingTimeMs,
535
+ checksRun: checks.length,
536
+ checksPassed: passedChecks.length,
537
+ requestId: offlineRequestId(),
538
+ timestamp: Date.now()
539
+ }
540
+ };
541
+ }
542
+ function buildBatchResult(results, submissions, crossAnalysis) {
543
+ const total = results.length;
544
+ const passed = results.filter((r) => r.recommendation === "keep").length;
545
+ const review = results.filter((r) => r.recommendation === "review").length;
546
+ const failed = results.filter((r) => r.recommendation === "discard").length;
547
+ const avgScore = total > 0 ? results.reduce((sum, r) => sum + r.score, 0) / total : 0;
548
+ const batch = {
549
+ results,
550
+ summary: { total, passed, review, failed, avgScore }
551
+ };
552
+ if (crossAnalysis) {
553
+ batch.fraudIndicators = detectFraudIndicators(submissions);
554
+ }
555
+ return batch;
556
+ }
557
+ function detectFraudIndicators(submissions) {
558
+ const seen = /* @__PURE__ */ new Map();
559
+ const duplicateAnswers = [];
560
+ submissions.forEach((sub, index) => {
561
+ const key = sub.responses.map((r) => `${r.question}=${r.answer}`).join("|");
562
+ if (seen.has(key)) {
563
+ const id = sub.context?.surveyId ?? `submission_${index}`;
564
+ duplicateAnswers.push(id);
565
+ } else {
566
+ seen.set(key, index);
567
+ }
568
+ });
569
+ return {
570
+ duplicateAnswers,
571
+ coordinatedTiming: false,
572
+ deviceSharing: false,
573
+ fraudRingScore: clamp01(duplicateAnswers.length / Math.max(1, submissions.length))
574
+ };
575
+ }
576
+
577
+ // src/cipher.ts
578
+ var DEFAULT_CONFIG = {
579
+ tier: 3,
580
+ thresholds: {
581
+ fail: 0.4,
582
+ review: 0.7
583
+ },
584
+ debug: false,
585
+ offline: false,
586
+ endpoint: "https://api.surbee.com/v1/cipher"
587
+ };
38
588
  var Cipher = class {
39
589
  config;
40
- constructor(config) {
41
- if (!config.apiKey) {
42
- throw new Error("API key is required. Get one from Settings > API Keys in your Surbee dashboard.");
43
- }
44
- if (!config.apiKey.startsWith("cipher_sk_") && !config.apiKey.startsWith("cipher_pk_")) {
45
- throw new Error("Invalid API key format. Keys should start with cipher_sk_ or cipher_pk_");
590
+ constructor(config = {}) {
591
+ const offline = config.offline ?? DEFAULT_CONFIG.offline;
592
+ if (config.apiKey) {
593
+ if (!config.apiKey.startsWith("cipher_sk_") && !config.apiKey.startsWith("cipher_pk_")) {
594
+ throw new Error("Invalid API key format. Keys should start with cipher_sk_ or cipher_pk_");
595
+ }
596
+ } else if (!offline) {
597
+ throw new Error(
598
+ "API key is required for online validation. Pass { offline: true } to run tier 1\u20132 checks locally, or get a key from Settings > API Keys in your Surbee dashboard."
599
+ );
46
600
  }
47
601
  this.config = {
48
- apiKey: config.apiKey,
602
+ apiKey: config.apiKey ?? "",
49
603
  tier: config.tier ?? DEFAULT_CONFIG.tier,
50
604
  thresholds: {
51
605
  fail: config.thresholds?.fail ?? DEFAULT_CONFIG.thresholds.fail,
52
606
  review: config.thresholds?.review ?? DEFAULT_CONFIG.thresholds.review
53
607
  },
54
608
  debug: config.debug ?? DEFAULT_CONFIG.debug,
609
+ offline,
55
610
  endpoint: config.endpoint ?? DEFAULT_CONFIG.endpoint
56
611
  };
57
612
  }
58
613
  /**
59
- * Validate a single response
614
+ * Validate a single response.
615
+ *
616
+ * Runs locally when `offline: true`, otherwise calls Surbee's validation
617
+ * engine (which can run the AI-powered checks for tiers 3–5).
60
618
  */
61
619
  async validate(input) {
620
+ if (this.config.offline) {
621
+ return this.validateSync(input);
622
+ }
62
623
  const response = await this.request("/validate", {
63
624
  tier: this.config.tier,
64
625
  thresholds: this.config.thresholds,
@@ -67,32 +628,80 @@ var Cipher = class {
67
628
  return response;
68
629
  }
69
630
  /**
70
- * Validate multiple responses in batch
631
+ * Validate a single response synchronously, fully on-device.
632
+ *
633
+ * Only the offline checks for the configured tier are evaluated; AI-powered
634
+ * checks (tiers 3+) are skipped. No API key or network access required.
635
+ */
636
+ validateSync(input) {
637
+ const start = Date.now();
638
+ const checks = this.runOfflineChecks(input);
639
+ return buildResult(checks, this.config.tier, this.config.thresholds, Date.now() - start);
640
+ }
641
+ /**
642
+ * Validate multiple responses in batch.
643
+ *
644
+ * Runs locally when `offline: true`, otherwise calls Surbee's batch endpoint.
71
645
  */
72
646
  async validateBatch(input) {
647
+ const crossAnalysis = input.crossAnalysis ?? this.config.tier === 5;
648
+ if (this.config.offline) {
649
+ const results = input.submissions.map((submission) => this.validateSync(submission));
650
+ return buildBatchResult(results, input.submissions, crossAnalysis);
651
+ }
73
652
  const response = await this.request("/validate/batch", {
74
653
  tier: this.config.tier,
75
654
  thresholds: this.config.thresholds,
76
655
  submissions: input.submissions,
77
- crossAnalysis: input.crossAnalysis ?? this.config.tier === 5
656
+ crossAnalysis
78
657
  });
79
658
  return response;
80
659
  }
81
660
  /**
82
- * Get tier information
661
+ * Run the offline checks that apply to the configured tier.
662
+ */
663
+ runOfflineChecks(input) {
664
+ const { responses, behavioralMetrics, deviceInfo, context } = input;
665
+ const all = [
666
+ ...runTimingChecks(responses, behavioralMetrics, context),
667
+ ...runBehavioralChecks(behavioralMetrics),
668
+ ...runContentChecks(responses, behavioralMetrics),
669
+ ...runDeviceChecks(deviceInfo)
670
+ ];
671
+ const byId = new Map(all.map((c) => [c.checkId, c]));
672
+ const offlineIds = getOfflineChecksForTier(this.config.tier);
673
+ return offlineIds.map((id) => byId.get(id)).filter((c) => Boolean(c));
674
+ }
675
+ /**
676
+ * Estimate the per-response API cost for a number of responses at the
677
+ * configured tier (tiers 1–2 are free).
678
+ */
679
+ estimateCost(responses = 1) {
680
+ return estimateCost(this.config.tier, responses);
681
+ }
682
+ /**
683
+ * Get tier information, including the list of checks it runs.
83
684
  */
84
685
  getTierInfo(tier) {
85
686
  const t = tier ?? this.config.tier;
86
- return TIER_INFO[t];
687
+ const config = TIERS[t];
688
+ const checks = getChecksForTier(t);
689
+ return {
690
+ tier: t,
691
+ name: config.name,
692
+ description: config.description,
693
+ checks,
694
+ checksCount: checks.length,
695
+ aiModel: config.aiModel,
696
+ offline: config.offline,
697
+ estimatedCostPerResponse: config.estimatedCostPerResponse
698
+ };
87
699
  }
88
700
  /**
89
701
  * Get all available tiers
90
702
  */
91
703
  getAllTiers() {
92
- return Object.entries(TIER_INFO).map(([tier, info]) => ({
93
- tier: Number(tier),
94
- ...info
95
- }));
704
+ return [1, 2, 3, 4, 5].map((tier) => this.getTierInfo(tier));
96
705
  }
97
706
  /**
98
707
  * Check API key validity and credits
@@ -105,6 +714,11 @@ var Cipher = class {
105
714
  * Make API request to Surbee
106
715
  */
107
716
  async request(path, body) {
717
+ if (!this.config.apiKey) {
718
+ throw this.createError(401, {
719
+ message: "This Cipher instance has no API key. Provide one to use online validation, or call validateSync() for offline checks."
720
+ });
721
+ }
108
722
  const url = `${this.config.endpoint}${path}`;
109
723
  if (this.config.debug) {
110
724
  console.log(`[Cipher] POST ${url}`);