npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@surbee/cipher

v0.4.0

Published

Survey response validation from Surbee: detect bots, fraud, AI-generated and copied answers, and low-effort responses

Readme

@surbee/cipher

Survey response validation from Surbee. Cipher detects bots, fraud, AI-generated or copied answers, and low-effort responses, and scores each response wherever you collect it, not just inside Surbee.

Every check runs on Surbee's servers using the same engine that scores surveys hosted on Surbee. The SDK collects signals and sends them; no detection logic, weights or thresholds ship to your client.

npm install @surbee/cipher

Requires Node 18 or later on the server, and a modern browser for CipherTracker.

Quick start

import { Cipher } from '@surbee/cipher';

const cipher = new Cipher({
  apiKey: process.env.CIPHER_API_KEY, // cipher_sk_...
  tier: 3,                            // 1 to 5, higher runs more checks
});

const result = await cipher.validate({
  responses: [
    { questionId: 'recommend', question: 'Would you recommend us?', answer: 'Yes', questionType: 'boolean' },
    { questionId: 'why', question: 'What stood out?', answer: 'It saved me an hour a week on reporting.' },
  ],
  behavioralMetrics: tracker.getMetrics(), // from CipherTracker, optional but recommended
  deviceInfo: tracker.getDeviceInfo(),     // from CipherTracker, optional but recommended
  context: { ipAddress: respondentIp },    // enables the network checks at tiers 4 and 5
});

result.score;          // 0 to 1, higher is better quality
result.recommendation; // 'keep' | 'review' | 'discard'
result.flags;          // plain-language reasons for anything that fired
result.perQuestion;    // what Cipher found on each question

Keep your API key on the server. Collect signals in the browser with CipherTracker, send them to your backend with the answers, and call cipher.validate() there.

Collecting signals with CipherTracker

CipherTracker records how the respondent interacted with the form: pointer movement, typing rhythm, focus, scrolling, and how each written answer was produced. It never records the characters someone types, only timing and the kind of edit (typed, deleted, pasted).

import { CipherTracker } from '@surbee/cipher';

const tracker = new CipherTracker();
tracker.start(); // call in the browser, for example from a React effect

tracker.questionStart('why'); // when a question is shown
// ...the respondent answers...
tracker.questionEnd('why');   // when they move on

const payload = {
  behavioralMetrics: tracker.getMetrics(),
  deviceInfo: tracker.getDeviceInfo(),
};

tracker.stop(); // on unmount

Writing analysis needs to know which question a text box belongs to. Either call questionStart(questionId) as each question appears, or put data-question-id="why" on the element that wraps each question. Use the same questionId in responses when you validate.

Without tracker data Cipher still scores the answers and timing, but checks that need interaction data are reported as skipped rather than guessed.

What Cipher checks

| Tier | Name | Checks | Adds | | ---- | -------- | ------ | -------------------------------------------------------------------- | | 1 | Basic | 6 | Timing and effort heuristics | | 2 | Standard | 15 | Device fingerprinting and automation detection | | 3 | Enhanced | 24 | AI text analysis, contradictions, text forensics, writing process | | 4 | Advanced | 32 | Full behavioral analysis, IP reputation, quality and semantic checks | | 5 | Maximum | 42 | Every check at full depth, including statistical text analysis |

Highlights from tier 3 up:

  • Writing process. How each long answer was produced: typed, composed and revised, pasted, copy-typed from another source, or appearing with no input behind it.
  • Text forensics. Invisible characters that typing does not produce, and chatbot phrasing or markdown left in an answer.
  • AI text analysis. Whether written answers read as generated, copied, off topic or low effort.

cipher.getTierInfo(tier) and cipher.getAllTiers() return the exact checks for each tier. The list is generated from the server's own configuration, so it always matches what runs.

Result shape

{
  score: number,                       // 0 to 1, higher is better
  passed: boolean,                     // score at or above thresholds.fail
  recommendation: 'keep' | 'review' | 'discard',
  confidence: number,                  // 0 to 1
  flags: string[],                     // plain-language reasons
  summary: { verdict, issues, positives, suggestion },
  checks: Array<{
    checkId: string,
    passed: boolean,
    score: number,                     // 0 to 1, higher is more suspicious
    details?: string,
    skipped?: boolean,                 // the input lacked what this check needs
    data?: Record<string, unknown>,
  }>,
  perQuestion: Record<string, {
    writing?: 'typed' | 'composed' | 'pasted' | 'transcribed' | 'untyped',
    forensics?: string[],
    relevance?: 'answers' | 'partial' | 'off_topic' | 'gibberish',
    aiProbability?: number | null,
    analysisMs: number,                // engine time spent on this question
  }>,
  meta: {
    tier, processingTimeMs, analysisMs, checksRun, checksPassed, requestId, timestamp,
  },
}

perQuestion is keyed by the questionId you send, or q1, q2 and so on in the order of responses when you do not send one.

Thresholds

score is mapped to a recommendation using two thresholds:

new Cipher({ apiKey, thresholds: { fail: 0.4, review: 0.7 } }); // the defaults

At or above review is keep, between fail and review is review, and below fail is discard.

Batches

const batch = await cipher.validateBatch({
  submissions: [submissionA, submissionB, submissionC],
  crossAnalysis: true, // on by default at tier 5
});

batch.results;         // one result per submission, in order
batch.summary;         // { total, passed, review, failed, avgScore }
batch.fraudIndicators; // shared answers, coordinated timing, shared devices

Up to 500 submissions per call. Cross-analysis compares submissions with each other to catch coordinated fraud that no single response shows.

Account status

const status = await cipher.checkStatus();
// { valid, credits, unlimited, tier, scopes, rateLimit: { remaining, resetAt } }

checkStatus() is free and does not count against your credits.

Errors

Failed calls reject with { code, message, details }:

| Code | Meaning | | ---------------------- | --------------------------------------------- | | INVALID_API_KEY | The key is missing, malformed or inactive | | INSUFFICIENT_CREDITS | Not enough API credit for this request | | RATE_LIMITED | Too many requests this minute | | INVALID_INPUT | The request body was not valid | | TIER_NOT_AVAILABLE | Your key cannot use the requested tier | | SERVER_ERROR | Something went wrong on Surbee's side | | NETWORK_ERROR | The request did not reach Surbee |

Configuration

new Cipher({
  apiKey: 'cipher_sk_...',  // required
  tier: 3,                  // default 3
  thresholds: { fail: 0.4, review: 0.7 },
  debug: false,             // log each request
  endpoint: 'https://www.surbee.dev/v1/cipher', // the default
});

If you set endpoint yourself, use the www host. The bare surbee.dev domain redirects to it, and browsers and Node drop the Authorization header when following a redirect to another host, so requests to the bare domain fail with INVALID_API_KEY.

Upgrading from 0.3

  • The default endpoint moved to https://www.surbee.dev/v1/cipher. Requests from 0.3 to the bare domain lose their API key on the redirect; upgrading fixes this.
  • behavioralMetrics.duration is now in milliseconds. 0.3's tracker sent seconds, which made every session look rushed.
  • deviceInfo.automationDetected no longer treats a browser with no plugins as automated, which flagged phones. It now comes with automationSignals.
  • New: perQuestion, meta.analysisMs, checks[].skipped, writing analysis in CipherTracker, and checkStatus() backed by a real endpoint.

License

MIT