/** * Humanity signals — invisible bot-protection primitives shared by the lib's * public forms (client) and the hub's per-route `verifyHuman` gate (server). * * PURE + React-free on purpose: this module is a tsup SERVER entry (no * "use client" banner) so the hub can import it server-side without pulling a * client-reference boundary — same pattern as `schemas/contact-schema` and * `components/features/mux-origins`. * * Two origin-independent signals travel in the POST body: a honeypot (a hidden * field real users never fill) and timing (ms from form mount to submit). * `evaluateHumanitySignals` is the SINGLE source of truth for the block/allow * decision — the hub imports + calls it rather than re-implementing the rules. * * FALSE-POSITIVE HISTORY (2026-08-27): the honeypot was named * `contact_url_confirm`, and browser/password-manager autofill (which ignores * `autocomplete="off"` and matches "url"/"confirm" name heuristics) filled it * for REAL users — every production `BOT_DETECTED` in the log window was a * legitimate Chrome user whose autofill tripped the decoy. Three layers now * prevent a recurrence; keep all three when touching this system: * 1. The field name avoids every autofill-heuristic token (name/email/ * phone/url/website/confirm/company/address/code/…). * 2. `HoneypotField` renders `readOnly`-until-focus + password-manager * ignore attributes — autofill skips read-only inputs. * 3. `evaluateHumanitySignals` forgives a filled decoy whose value was * COPIED from another field in the same body (the autofill signature — * a human, not a bot). See `findHoneypotCopySource` for the match rules. * * ACCEPTED TRADEOFF of layer 3 (do not "fix" by removing the forgiveness): a * bot that fills EVERY field with one identical value now passes the honeypot * check. That bot class always had a strictly easier evasion — send the decoy * empty — so no new attacker capability is admitted; it remains covered by the * timing check, per-IP rate limits, route Zod validation, and first-party * BotID. Every forgiven allow is warn-logged by the hub gate for monitoring. */ /** Hidden honeypot field name. Deliberately free of autofill-heuristic tokens (see module doc). */ export declare const HONEYPOT_FIELD = "form_extra_note"; /** Client-measured ms between form mount and submit. */ export declare const ELAPSED_MS_FIELD = "form_elapsed_ms"; /** Default minimum fill time (ms). A submit faster than this is treated as a bot. */ export declare const DEFAULT_MIN_FILL_MS = 700; /** * Every humanity-signal key that rides in a public form's POST body. * Server-side handlers that forward form payloads upstream (HubSpot booking, * CRM pushes, …) MUST strip by THIS array — never hand-typed strings — so a * field rename here propagates everywhere and the honeypot value can never * silently leak into an upstream record. */ export declare const HUMANITY_SIGNAL_KEYS: readonly ["form_extra_note", "form_elapsed_ms"]; /** Is this body key one of the humanity-signal wire fields? */ export declare const isHumanitySignalKey: (key: string) => boolean; /** Keyed wire object produced by `useHumanitySignals().getSignals()` and spread into the POST body. */ export type HumanitySignals = Record; /** * Diagnostics every verdict carries so callers LOG what this module already * computed instead of re-deriving it (a re-derived predicate silently diverges * the day the rules here change): * - `honeypotLength`: decoy length — never the typed value (log-safe by * construction). * - `timingAffirmed`: the submission POSITIVELY proved human timing (a PRESENT * elapsed-ms at/above the floor — merely-missing timing does not affirm). * The hub gate keys its BotID form-downgrade on this. */ export type HumanityVerdictDiagnostics = { honeypotLength: number; timingAffirmed: boolean; }; /** Result of {@link evaluateHumanitySignals}. */ export type HumanityVerdict = HumanityVerdictDiagnostics & ({ ok: true; /** Present when a filled decoy was forgiven as autofill; `sourceField` names the body field it was copied from. */ note?: 'honeypot_autofill'; sourceField?: string; } | { ok: false; reason: 'honeypot' | 'too_fast'; }); /** Tolerant reader — never throws; missing/garbage timing → null. */ export declare function extractHumanitySignals(body: unknown): { honeypot: string; elapsedMs: number | null; }; /** * Find the body field the decoy value was COPIED from — the autofill * signature: browsers and password-manager extensions fill the hidden input * with the same datum they put in a visible field (email, phone, …) — a human * with autofill, not a bot. Scans top-level string values, string arrays, and * one nested level (the booking form's custom `formFields` object). Matches * normalized equality, plus digits-only equality for phone-sized values. * * Returns the matched field's path (`email`, `formFields.phone`, `tags[]`) — * the SSOT for both the verdict and the hub gate's `sameAs` log diagnostic — * or `null` when nothing matches. */ export declare function findHoneypotCopySource(body: unknown, honeypot: string): string | null; /** * SINGLE decision fn for honeypot + timing (the hub's `verifyHuman` imports + calls this): * - honeypot non-empty → bot (real users never fill the off-screen field) — UNLESS the * value was copied from another field in the body (autofill reached the decoy → human; * the verdict carries `note: 'honeypot_autofill'` + the `sourceField` so callers log it) * - elapsed below `minFillMs` → bot (humans take time; a MISSING timing value never * blocks — and the too-fast check still applies to autofill-forgiven submissions) */ export declare function evaluateHumanitySignals(body: unknown, opts: { minFillMs: number; }): HumanityVerdict; /** Parse a comma-separated env string → trimmed, non-empty entries (undefined → []). */ export declare const splitCsvEnv: (s?: string) => string[]; //# sourceMappingURL=humanity-signals.d.ts.map