Skip to content

tindalabs/scent

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

112 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Scent

npm version CI License: MIT/BSL-1.1 types Live demo

Probabilistic identity continuity for hostile browser environments.

Scent tracks whether a returning visitor is "likely the same entity" even after cookie deletion, VPN changes, browser updates, or anti-fingerprinting tools — using a drift-tolerant confidence scoring engine, not deterministic hashes.

Live demo — run Scent, Blindspot and Shield together in your browser. (Reload and re-run to watch Scent recognise you across visits.)

import { init } from '@tindalabs/scent-sdk';

const sdk = init({ apiKey: 'your-api-key', persistence: 'balanced' });

const obs = await sdk.observe();
await sdk.flush();

console.log(obs.identity.confidence);  // 0.91
console.log(obs.identity.continuity);  // "confirmed"
console.log(obs.risk.score);           // 0.07

Why not FingerprintJS?

FingerprintJS computes a hash. One signal changes → different hash → different visitor. In the real world users update browsers, switch VPNs, and clear cookies constantly. Scent uses probabilistic similarity scoring: 18 of 20 signals match → confidence 0.93, continuity confirmed.

FingerprintJS Scent
Approach Deterministic hash Probabilistic similarity
Browser update New visitor 0.91 confidence
Cookie deletion New visitor Server-side resurrection
VPN change New visitor Stable signals still match
Explainability Black box Per-signal breakdown
Self-hostable No Yes
Open source No Yes

Don't take our word for it. A reproducible benchmark (bench/, pnpm bench) holds signal collection constant and varies only the matching algorithm: under realistic drift, deterministic re-identification recall is FingerprintJS 45% / ThumbmarkJS 55% / Scent 100% — and it reports Scent's confidence gradient and false-merge rate too, not just the headline. See bench/RESULTS.md.


How it works

  1. sdk.observe() — collects ~50 browser signals (canvas, audio, fonts, hardware, screen, locale, anti-tamper heuristics)
  2. sdk.flush() — sends the snapshot to your Scent server
  3. Identity engine — runs SimHash candidate lookup + weighted Jaccard similarity against your identity store
  4. Confidence score0–1 float with a human-readable continuity band and per-signal explanation

Confidence bands:

  • confirmed (≥ 0.85) — same stable signals, minor drift
  • probable (≥ 0.60) — some signals changed, very likely same entity
  • uncertain (≥ 0.35) — significant drift, worth re-authenticating
  • unknown (< 0.35) — treat as new

Quickstart

1. Start the stack

git clone https://github.com/tindalabs/scent
cd scent
docker compose up

This starts:

  • scent-server on localhost:3000 (identity API)
  • scent-observatory on localhost:4000 (identity UI)
  • PostgreSQL, Redis, OTel Collector, Grafana Tempo

2. Install the SDK

npm install @tindalabs/scent-sdk

3. Instrument your app

import { init } from '@tindalabs/scent-sdk';

const sdk = init({
  apiKey: 'your-api-key',        // from Observatory → Project Settings
  endpoint: 'https://your-scent-server/v1',
  persistence: 'balanced',       // conservative | balanced | aggressive | forensic
});

// On each significant interaction (login, signup, checkout):
const obs = await sdk.observe();
await sdk.flush();

// Use the result
if (obs.identity.continuity === 'unknown' || obs.risk.score > 0.6) {
  // Challenge this user — step-up auth, CAPTCHA, manual review
}

4. Open the Observatory

http://localhost:4000 — browse identities, inspect signal profiles, see drift timelines, review risk flags.


SDK reference

init(options)

Option Type Default Description
apiKey string required Project API key
endpoint string https://api.tindalabs.dev/v1 Scent server URL
persistence PersistencePolicy 'balanced' Storage and collection scope
traceparentProvider () => string | null OTel traceparent hook (see OTel bridge)

sdk.observe()

Collects signals, attempts to recover a prior identity from storage, and returns a ScentObservation. Does not make a network request.

const obs = await sdk.observe();

obs.identity.id           // string — the persistent scent ID
obs.identity.confidence   // 0–1 float
obs.identity.isNew        // boolean — first-ever observation
obs.identity.continuity   // "confirmed" | "probable" | "uncertain" | "unknown"

obs.drift.detected        // boolean
obs.drift.delta           // string[] — signal names that changed
obs.drift.entropy         // float — magnitude of change

obs.risk.score            // 0–1 float (Phase 3, server-resolved)
obs.risk.flags            // string[] — e.g. ["automation_suspected", "vpn_detected"]

sdk.flush()

Sends buffered snapshots to the server. Resolves when the server has ingested and scored them. Safe to call multiple times; no-ops when buffer is empty.

sdk.snapshot()

Returns the current signal state without resolving or persisting identity. Useful for debugging signal collection.

sdk.storageHealth()

Returns which storage layers are available in the current browser session.


OpenTelemetry bridge

If your app uses OpenTelemetry, the @tindalabs/scent-otel bridge attaches identity and risk context to your existing spans:

import { init } from '@tindalabs/scent-sdk';
import { ScentOtelBridge, readTraceparent } from '@tindalabs/scent-otel';

const sdk = init({ apiKey: '...', traceparentProvider: readTraceparent });
const bridge = new ScentOtelBridge(sdk);

const obs = await bridge.observe();  // span annotated with scent.identity.*
await bridge.flush();

Span attributes set: scent.identity.id, scent.identity.confidence, scent.identity.continuity, scent.risk.score, scent.risk.flags.

See OTel bridge guide for full setup including @tindalabs/blindspot integration.


Self-hosting

Environment variables

Copy .env.example and configure:

DATABASE_URL=postgresql://scent:password@localhost:5432/scent
REDIS_URL=redis://localhost:6379
PORT=3000

# OTel (optional)
OTEL_SERVICE_NAME=scent-server
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318

Database migrations

Migrations run automatically on server start. To run them standalone:

pnpm --filter @tindalabs/scent-server migrate

Prebuilt server image

The scent-server image is published on every release to both GitHub Container Registry and Docker Hub, tagged latest and with the commit SHA:

docker pull ghcr.io/tindalabs/scent-server:latest
# or, from Docker Hub
docker pull tindalabs/scent-server:latest

Pull this instead of building from source. The server is licensed under BSL-1.1 (see License); the SDK, engine, and OTel bridge remain MIT.

Production deployment

For a single-VPS production stack — server + async-ingest worker + Postgres + Redis + automatic HTTPS (Caddy), using the prebuilt image — see deploy/:

cd deploy
cp .env.example .env   # set SCENT_DOMAIN, ACME_EMAIL, POSTGRES_PASSWORD
docker compose pull && docker compose up -d

Full runbook (DNS, minting an API key, updates, backups) in deploy/README.md. Sized for a ~€4/mo VPS such as a Hetzner CX22.

The repo-root docker-compose.yml is the local dev stack instead — it builds from source and bundles the Observatory UI plus a Grafana Tempo observability stack and a demo API key.


Documentation


Packages

Package Description
@tindalabs/scent-sdk Browser SDK — signal collection, multi-layer persistence, OTel bridge
@tindalabs/scent-engine Probabilistic matching — SimHash, weighted Jaccard, drift, risk scoring
@tindalabs/scent-otel OTel bridge — attaches scent.* identity attributes to existing spans
@tindalabs/scent-server Node.js API server — identity resolution, drift history, risk assessment

Apps (not published to npm):

App Description
Observatory React UI — identity list, drift timelines, risk dashboard (port 4000)
demo Standalone demo app wiring all packages together (port 5174)

Architecture

@tindalabs/scent-sdk      (browser)
  ├── ~50 signal collectors
  ├── Multi-layer persistence (localStorage, IndexedDB, cookies, ETag)
  └── OTel traceparent bridge

@tindalabs/scent-server   (Node.js)
  ├── POST /v1/events — snapshot ingestion
  ├── SimHash + Jaccard identity engine
  ├── Drift detection + history
  ├── Risk scoring (6 anomaly detectors)
  └── REST query API

@tindalabs/scent-observatory  (React, port 4000)
  ├── Identity list + detail pages
  ├── Drift timeline visualization
  └── Risk dashboard

The Tindalabs stack

Scent is one of three composable browser-layer packages:

Package What it does
@tindalabs/blindspot Privacy-first OTel frontend observability
@tindalabs/shield Tamper detection & active content protection
@tindalabs/scent Probabilistic identity continuity

Integrating Shield signals

Pass @tindalabs/shield assessment results directly into observe() so the server's risk engine sees tamper signals alongside the browser fingerprint:

import { init } from '@tindalabs/scent-sdk';
import { assess } from '@tindalabs/shield';

const scent = init({ apiKey: '...', endpoint: '...' });
const shield = await assess();

const obs = await scent.observe({
  extraSignals: shield.signals,
});
await scent.flush();

The shield.* signals become first-class fields in the stored snapshot and are visible in drift timelines and risk assessments in the Observatory.


License

Open-core licensing:

  • SDK, Engine, OTel bridge (@tindalabs/scent-sdk, @tindalabs/scent-engine, @tindalabs/scent-otel) — MIT. Free to use, embed in apps, modify, and redistribute.
  • Server (@tindalabs/scent-server) — Business Source License 1.1 (BSL-1.1). Free to self-host for non-commercial use; converts to MIT on June 12, 2031. Commercial hosting (SaaS) is restricted until the conversion date.

The licensing split reflects our open-core model: the SDK and matching engine are freely embeddable in your app; self-hosters can run the server on their own infrastructure; the revenue model is the hosted SaaS tier (Phase 7), where we handle Postgres, Redis, worker scaling, Observatory UI, and enterprise features like SSO and audit logs.

Built by tindalabs.dev.

Packages

 
 
 

Contributors

Languages