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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ the first consumer-visible behaviour change and will drive the next SDK version
- **Automation-detector wording**: the anti-tamper flag now reads "Anti-tamper signals" (not "Automation detected") when the combined confidence is weak — e.g. devtools open in a dev environment — so a human isn't labelled a bot. The machine-readable `code` (`automation_suspected`) is unchanged; the reason text also drops the `tamper.` prefix for readability.

### Internal
- **Usage metering** ([ADR-0007](docs/adr/0007-usage-metering.md), migration 015): a durable per-organization, per-UTC-calendar-month ledger of identity resolutions — the billable unit — replacing the ephemeral Redis rate-limit windows as the source of truth (slice 1 of Phase 7; Stripe + enforcement come later). The billable unit is one **committed** resolution: `incrementUsage` records it via an atomic UPSERT **inside the resolution transaction** in `resolveSnapshot`, so counting is **exactly-once** (the `event_id` dedup short-circuits before the transaction, so retries/duplicates don't count; a rollback un-counts). `POST /v1/resolve` is a non-persisting preview and intentionally doesn't count. **Soft** limits: `organizations` gains `plan` + `monthly_resolution_limit` (NULL = unlimited, so self-host is metered but never warned); crossing 80% / 100% flips a once-per-period guard and emits a `logger.warn` + `Sentry.captureMessage` (reusing the shipped error tracking; no new infra), but **never blocks traffic**. New `GET /admin/usage` (org-scoped) + an Observatory **Usage** page surface current-period count vs limit and recent history; a `set-org-plan` CLI sets a customer's plan/limit (thin/assisted onboarding). The single counter row per org/month is a documented write hotspot at scale (deferred: Redis hot counter / sharding). Deferred follow-ups: Stripe, public self-serve signup, hard enforcement.
- **Error tracking (Sentry)** ([ADR-0006](docs/adr/0006-observability-sentry.md)): the hosted server was *instrumented but blind* — OpenTelemetry was wired but ships disabled (`OTEL_SDK_DISABLED=true`) and `pino` logs are ephemeral, so prod errors and outages went unseen. Added `@sentry/node` (v10) error capture + alerting for the server and worker. A new `instrument.ts` runs `Sentry.init` (preloaded via `--import` before app modules) and **no-ops without `SENTRY_DSN`** (`${SCENT_SECRET_KEY:-}`-style "env unset = disabled"), so dev/test/self-host stay inert. Express errors are caught via `setupExpressErrorHandler`; BullMQ job failures via explicit `captureException` in the worker `failed` handlers (+ `flush` on shutdown). Privacy posture for this PII-sensitive product: **EU-region project + strict scrubbing** (`sendDefaultPii: false` plus an exported, unit-tested `scrubPii` `beforeSend` that strips request bodies, cookies, the `x-api-key`/`cookie`/`authorization` headers, query string, and client IP). Errors-only by default (`tracesSampleRate` 0, env-overridable). New env `SENTRY_DSN`/`SENTRY_ENVIRONMENT`/`SENTRY_RELEASE`/`SENTRY_TRACES_SAMPLE_RATE` in the deploy `.env.example`, both compose services, and the runbook (with an external `/health` uptime-monitor note). Distributed traces/metrics/off-box logs to a managed backend remain a deferred phase 2 (the OTel wiring already exists).
- **Organizations (multi-tenant) layer** ([ADR-0005](docs/adr/0005-organizations-and-tenancy.md), migrations 013–014): a new `organizations` table is now the tenant boundary above `projects`. The admin `owner` role is **re-scoped from a global superuser to org-scoped** — `canViewProject`/`canManageProject`, the `/admin/*` listing queries, and `requireProjectRead` filter by `organization_id`, and a cross-org project/user id returns `404` (no existence leak). 2FA policy moved from the install-wide `admin_settings.require_2fa` to per-org `organizations.require_2fa`; invites carry the inviter's org so an accepted account joins that company. Migration 013 backfills a single `Default` org for existing installs, so **self-host is unchanged** (one auto-created org); the `NOT NULL` FK backstop lands in migration 014 once every writer is org-aware. `create-admin`/`create-project` take an optional `[orgName]` (shared `findOrCreateOrgByName`). `organization_id` stays off the `/v1` data path (a project key already scopes data) — orgs are an admin/billing concern, the foundational prerequisite for hosted metering/billing. Public self-serve signup is deferred to the billing workstream.
- **Docs: GDPR & consent** ([ADR-0004](docs/adr/0004-consent-and-data-lifecycle.md)): new [GDPR & consent integration guide](docs/integrations/gdpr-consent.md) (controller/processor split, CMP wiring per mode, lawful-basis guidance, data-subject rights, DPA stub); OpenAPI updated with the snapshot consent fields, the `LawfulBasis` schema, and the `DELETE`/`export` identity paths.
Expand Down
12 changes: 6 additions & 6 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ A single demo app running both `@tindalabs/blindspot` and `@tindalabs/scent-sdk`
### Cloud infrastructure

- [x] Multi-tenant architecture: all data scoped by project (every table FK'd to `projects` with `ON DELETE CASCADE`; API key → project_id resolved in auth). API keys are hashed at rest (SHA-256).
- [ ] Usage metering: count identity resolutions per billing period
- [x] Usage metering: durable per-org, per-UTC-month resolution ledger ([ADR-0007](docs/adr/0007-usage-metering.md), migration 015) — exactly-once in `resolveSnapshot`, soft limits, `GET /admin/usage` + Observatory Usage page. Stripe billing still pending.
- [x] API key management UI (create, rotate, revoke) — Observatory **API Keys** page behind multi-user admin auth (bcrypt, server-side sessions, CSRF, per-IP rate limit); rotate/revoke bust the Redis auth cache immediately
- [ ] Billing integration: Stripe (usage-based, metered billing)

Expand Down Expand Up @@ -479,8 +479,8 @@ Full report: `c-level/reports/scent_2026-05-19.md`
- [~] **Simultaneous launch**: GitHub repo is public and npm packages are published (`scent-sdk`/`-engine`/`-otel` @ `0.1.0`, trusted publishing). **Show HN post still pending** — the launch-day momentum play is not yet fired.
- [ ] Launch `tindalabs.dev` landing page — `docs/tindalabs-landing-page-spec.md` has all the copy; HN traffic needs somewhere to land
- [ ] Write and publish "Why FingerprintJS free tier isn't enough — and what we built instead" blog post on Dev.to and Medium (the migration guide is already written; this is the SEO play)
- [ ] Implement usage metering (`resolution_count` increment per project per billing period) — the revenue meter; unblocks Phase 7 Stripe integration
- [~] Implement Phase 7 SaaS: **API key management UI is done** (admin auth — multi-user accounts, sessions, CSRF — + Observatory create/rotate/revoke); Stripe usage-based billing + billing dashboard still pending (gated on usage metering above)
- [x] Implement usage metering — durable per-**org**, per-month resolution ledger ([ADR-0007](docs/adr/0007-usage-metering.md), migration 015), incremented exactly-once inside `resolveSnapshot`; soft warnings at 80%/100% (logger + Sentry), never blocks. The revenue meter; unblocks Stripe.
- [~] Implement Phase 7 SaaS: **API key management UI + usage metering done**; Stripe usage-based billing + billing dashboard still pending (now unblocked by the metering ledger)
- [x] Publish accuracy benchmarks comparing Scent vs. FingerprintJS OSS vs. ThumbmarkJS ✅ — added `bench/` (`@tindalabs/scent-bench`, run via `pnpm bench`): a reproducible, seeded harness that holds signal *collection* constant and varies only the matcher, driving the **real** `@tindalabs/scent-engine`. Models the deterministic libs faithfully (re-identify iff the hashed component set is byte-for-byte identical). Headline ([`bench/RESULTS.md`](bench/RESULTS.md)): **weighted recall FingerprintJS 45% / ThumbmarkJS 55% / Scent 100%** under drift; deterministic IDs collapse to 0% the instant any hashed component changes (browser update, anti-fingerprinting, VPN/timezone, new monitor — Thumbmark survives only the monitor case by dropping screen geometry). Crucially it also reports Scent's **confidence gradient** (mean 1.00→0.80; "confirmed" share drops to 57.9% on browser updates, 15.8% on anti-fingerprinting) and a **false-merge counter-metric** — so it reads as measurement, not a brochure. Methodology + limits documented in `bench/README.md`.
- [x] Publish `tindalabs/scent-server` Docker image so self-hosters can `docker pull` — auto-published on every main push to **both GHCR (`ghcr.io/tindalabs/scent-server`) and Docker Hub (`tindalabs/scent-server`)** via `.github/workflows/publish-docker.yml`
- [ ] Reach out to 10–15 mid-market SaaS founders/CTOs who post publicly about fraud on Twitter/LinkedIn (primary community seeding)
Expand Down Expand Up @@ -554,7 +554,7 @@ Full report: [`c-level/reports/scent_2026-06-14.md`](../c-level/reports/scent_20

**CFO — 7/10 (low burn, clean IP; revenue unbuilt, bus-factor-1)**
- Low fixed-cost floor, near-zero debt liability, all inbound deps permissive, intentional BSL moat on the server.
- [ ] **Convert per-key Redis counters into a metering ledger + ship a thin paid tier** — cheapest path from cost-center to validated revenue; reuses infra already built. (Same as the long-standing "usage metering" item — now framed as the revenue gate.)
- [~] **Convert per-key Redis counters into a metering ledger + ship a thin paid tier** — metering ledger **SHIPPED** ([ADR-0007](docs/adr/0007-usage-metering.md): durable per-org/month Postgres counters, soft limits, Observatory Usage page). The thin paid tier (Stripe) is the remaining half.
- [ ] **De-risk bus factor = 1** — document the engine's weight/threshold *rationale* + bring in a second maintainer. Dominant operational *and* acquisition risk.
- [ ] **Verify MaxMind GeoLite2 *data* EULA** (separate from the npm code license) — load at runtime rather than bundling in the redistributed image.

Expand Down Expand Up @@ -585,7 +585,7 @@ Full report: [`c-level/reports/scent_2026-06-14.md`](../c-level/reports/scent_20
- [ ] Per-admin audit log

**Strategic (1–3 months)**
- [ ] Hosted free tier + metering ledger (reuse per-key Redis counters) — the revenue path
- [~] Hosted free tier + metering ledger — **metering ledger shipped** ([ADR-0007](docs/adr/0007-usage-metering.md)); free-tier enforcement + billing still pending
- [ ] GDPR data-lifecycle layer (consent, retention TTL, IP minimization, export/delete) — converts the #1 liability into the market wedge
- [ ] De-risk bus factor: document engine/threshold rationale + add a second maintainer
- [ ] Postgres partitioning + read replica before scale
Expand Down Expand Up @@ -687,7 +687,7 @@ isolation; metering/billing will anchor on it:
- [x] Per-org 2FA policy (`organizations.require_2fa`) supersedes the install-wide toggle; invites carry the inviter's org so an accepted account joins that company.
- [x] Org-aware provisioning via the bootstrap CLIs (`create-admin`/`create-project` take an optional `[orgName]`, shared `findOrCreateOrgByName`).
- [ ] **Deferred to the billing workstream:** public self-serve signup (`POST /admin/signup`) — coupled to free-tier limits + Stripe.
- [ ] **Deferred:** anchor usage metering + Stripe (Phase 7) on `organizations`; Observatory org-management UI (org name/settings, switcher).
- [~] anchor usage metering + Stripe (Phase 7) on `organizations`: **usage metering shipped** ([ADR-0007](docs/adr/0007-usage-metering.md)); Stripe + the Observatory org-management UI (org name/settings, switcher) still deferred.

**Why it composed:** mirrors how migration 009 backfilled `role` for existing admins —
self-host stays a single auto-created org, so no behaviour change there; the hosted tier
Expand Down
2 changes: 2 additions & 0 deletions apps/observatory/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { DriftTimeline } from './pages/DriftTimeline.js';
import { ClusterDetail } from './pages/ClusterDetail.js';
import { AccountClusters } from './pages/AccountClusters.js';
import { Settings } from './pages/Settings.js';
import { Usage } from './pages/Usage.js';

export function App(): React.ReactElement {
return (
Expand All @@ -25,6 +26,7 @@ export function App(): React.ReactElement {
{/* Admin/account pages don't need a selected project, so they sit outside
the project gate. */}
<Route path="settings" element={<Settings />} />
<Route path="usage" element={<Usage />} />
<Route path="account" element={<Account />} />
<Route element={<RequireOwner />}>
<Route path="users" element={<Users />} />
Expand Down
3 changes: 2 additions & 1 deletion apps/observatory/src/components/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NavLink, Outlet, Navigate, useNavigate, useLocation } from 'react-router-dom';
import { LayoutDashboard, Users, GitBranch, Network, KeyRound, UserCog, CircleUser, LogOut } from 'lucide-react';
import { LayoutDashboard, Users, GitBranch, Network, KeyRound, UserCog, CircleUser, Gauge, LogOut } from 'lucide-react';
import { cn } from '../lib/utils.js';
import { useAuth } from '../contexts/AuthContext.js';
import { useProjects } from '../contexts/ProjectContext.js';
Expand All @@ -9,6 +9,7 @@ const baseNav = [
{ to: '/identities', label: 'Identities', icon: Users, end: false },
{ to: '/accounts', label: 'Account clusters', icon: Network, end: false },
{ to: '/settings', label: 'API keys', icon: KeyRound, end: false },
{ to: '/usage', label: 'Usage', icon: Gauge, end: false },
];

export function Layout(): React.ReactElement {
Expand Down
18 changes: 18 additions & 0 deletions apps/observatory/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,24 @@ export function setRequireTwoFactor(require_2fa: boolean): Promise<{ require_2fa
return adminFetch('/admin/settings', { method: 'PUT', body: JSON.stringify({ require_2fa }) });
}

// --- Usage metering (org-scoped; any admin can read their own org's usage) ----------

export interface UsageData {
plan: string;
// null = unlimited (self-host / un-provisioned).
limit: number | null;
// First day of the current UTC month (YYYY-MM-DD).
periodStart: string;
resolutionsThisPeriod: number;
// resolutionsThisPeriod / limit, or null when unlimited.
pctUsed: number | null;
history: { periodStart: string; resolutions: number }[];
}

export function getUsage(): Promise<UsageData> {
return adminFetch('/admin/usage');
}

// Types mirroring server responses

export interface Identity {
Expand Down
122 changes: 122 additions & 0 deletions apps/observatory/src/pages/Usage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { useQuery } from '@tanstack/react-query';
import { Gauge } from 'lucide-react';
import { getUsage } from '../lib/api.js';
import { Skeleton } from '../components/ui/skeleton.js';

// "2026-06-01" -> "Jun 2026" (rendered in UTC to match the server's period boundary).
function formatMonth(periodStart: string): string {
const [y, m] = periodStart.split('-').map(Number);
return new Date(Date.UTC(y ?? 1970, (m ?? 1) - 1, 1)).toLocaleString(undefined, {
month: 'short',
year: 'numeric',
timeZone: 'UTC',
});
}

const fmt = (n: number): string => n.toLocaleString();

export function Usage(): React.ReactElement {
const { data, isLoading } = useQuery({ queryKey: ['admin-usage'], queryFn: getUsage });

const limit = data?.limit ?? null;
const used = data?.resolutionsThisPeriod ?? 0;
const pct = data?.pctUsed != null ? data.pctUsed : null;
const pctClamped = pct != null ? Math.min(100, Math.round(pct * 100)) : null;
const barColor = pct == null ? 'bg-emerald-500' : pct >= 1 ? 'bg-red-500' : pct >= 0.8 ? 'bg-amber-500' : 'bg-emerald-500';

return (
<div className="mx-auto max-w-3xl space-y-6">
<div>
<h1 className="text-lg font-semibold text-foreground">Usage</h1>
<p className="mt-0.5 text-sm text-muted-foreground">
Identity resolutions for the current billing period (UTC calendar month).
</p>
</div>

{isLoading ? (
<Skeleton className="h-28 w-full" />
) : (
<div className="rounded-lg border border-border bg-card p-5">
<div className="flex items-baseline justify-between">
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">This period</p>
<p className="mt-1 text-2xl font-semibold text-foreground">
{fmt(used)}
<span className="ml-1.5 text-sm font-normal text-muted-foreground">
{limit != null ? `/ ${fmt(limit)} resolutions` : 'resolutions'}
</span>
</p>
</div>
<span className="rounded-full border border-border px-2.5 py-0.5 text-xs capitalize text-muted-foreground">
{data?.plan ?? 'free'} plan
</span>
</div>

{limit != null ? (
<div className="mt-4">
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${pctClamped ?? 0}%` }} />
</div>
<p className="mt-1.5 text-xs text-muted-foreground">
{pctClamped}% used
{pct != null && pct >= 1 && (
<span className="ml-1 text-red-400">— over the soft limit (still serving)</span>
)}
{pct != null && pct >= 0.8 && pct < 1 && (
<span className="ml-1 text-amber-400">— approaching the limit</span>
)}
</p>
</div>
) : (
<p className="mt-4 flex items-center gap-1.5 text-xs text-muted-foreground">
<Gauge size={12} /> Unlimited — no monthly quota set for this organization.
</p>
)}
</div>
)}

<div>
<h2 className="mb-2 text-sm font-medium text-foreground">Recent months</h2>
<div className="overflow-hidden rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-card text-left">
<th className="px-4 py-2.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Month</th>
<th className="px-4 py-2.5 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">
Resolutions
</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={2} className="px-4 py-4">
<Skeleton className="h-5 w-full" />
</td>
</tr>
)}
{!isLoading && (data?.history.length ?? 0) === 0 && (
<tr>
<td colSpan={2} className="px-4 py-6 text-center text-sm text-muted-foreground">
No usage recorded yet.
</td>
</tr>
)}
{data?.history.map((h) => (
<tr key={h.periodStart} className="border-b border-border last:border-0">
<td className="px-4 py-3 text-foreground">{formatMonth(h.periodStart)}</td>
<td className="px-4 py-3 text-right font-mono text-muted-foreground">{fmt(h.resolutions)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>

<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Gauge size={12} /> One resolution = one processed identity observation. Limits are soft — exceeding
them never blocks traffic.
</p>
</div>
);
}
Loading
Loading