{"version":3,"sources":["../../src/utils/humanity-signals.ts"],"names":[],"mappings":"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","file":"humanity-signals.cjs","sourcesContent":["/**\n * Humanity signals — invisible bot-protection primitives shared by the lib's\n * public forms (client) and the hub's per-route `verifyHuman` gate (server).\n *\n * PURE + React-free on purpose: this module is a tsup SERVER entry (no\n * \"use client\" banner) so the hub can import it server-side without pulling a\n * client-reference boundary — same pattern as `schemas/contact-schema` and\n * `components/features/mux-origins`.\n *\n * Two origin-independent signals travel in the POST body: a honeypot (a hidden\n * field real users never fill) and timing (ms from form mount to submit).\n * `evaluateHumanitySignals` is the SINGLE source of truth for the block/allow\n * decision — the hub imports + calls it rather than re-implementing the rules.\n *\n * FALSE-POSITIVE HISTORY (2026-08-27): the honeypot was named\n * `contact_url_confirm`, and browser/password-manager autofill (which ignores\n * `autocomplete=\"off\"` and matches \"url\"/\"confirm\" name heuristics) filled it\n * for REAL users — every production `BOT_DETECTED` in the log window was a\n * legitimate Chrome user whose autofill tripped the decoy. Three layers now\n * prevent a recurrence; keep all three when touching this system:\n *   1. The field name avoids every autofill-heuristic token (name/email/\n *      phone/url/website/confirm/company/address/code/…).\n *   2. `HoneypotField` renders `readOnly`-until-focus + password-manager\n *      ignore attributes — autofill skips read-only inputs.\n *   3. `evaluateHumanitySignals` forgives a filled decoy whose value was\n *      COPIED from another field in the same body (the autofill signature —\n *      a human, not a bot). See `findHoneypotCopySource` for the match rules.\n *\n * ACCEPTED TRADEOFF of layer 3 (do not \"fix\" by removing the forgiveness): a\n * bot that fills EVERY field with one identical value now passes the honeypot\n * check. That bot class always had a strictly easier evasion — send the decoy\n * empty — so no new attacker capability is admitted; it remains covered by the\n * timing check, per-IP rate limits, route Zod validation, and first-party\n * BotID. Every forgiven allow is warn-logged by the hub gate for monitoring.\n */\n\n/** Hidden honeypot field name. Deliberately free of autofill-heuristic tokens (see module doc). */\nexport const HONEYPOT_FIELD = 'form_extra_note';\n/** Client-measured ms between form mount and submit. */\nexport const ELAPSED_MS_FIELD = 'form_elapsed_ms';\n/** Default minimum fill time (ms). A submit faster than this is treated as a bot. */\nexport const DEFAULT_MIN_FILL_MS = 700;\n\n/**\n * Every humanity-signal key that rides in a public form's POST body.\n * Server-side handlers that forward form payloads upstream (HubSpot booking,\n * CRM pushes, …) MUST strip by THIS array — never hand-typed strings — so a\n * field rename here propagates everywhere and the honeypot value can never\n * silently leak into an upstream record.\n */\nexport const HUMANITY_SIGNAL_KEYS = [HONEYPOT_FIELD, ELAPSED_MS_FIELD] as const;\n\n/** Is this body key one of the humanity-signal wire fields? */\nexport const isHumanitySignalKey = (key: string): boolean => (HUMANITY_SIGNAL_KEYS as readonly string[]).includes(key);\n\n/** Keyed wire object produced by `useHumanitySignals().getSignals()` and spread into the POST body. */\nexport type HumanitySignals = Record<string, string | number>;\n\n/**\n * Diagnostics every verdict carries so callers LOG what this module already\n * computed instead of re-deriving it (a re-derived predicate silently diverges\n * the day the rules here change):\n * - `honeypotLength`: decoy length — never the typed value (log-safe by\n *   construction).\n * - `timingAffirmed`: the submission POSITIVELY proved human timing (a PRESENT\n *   elapsed-ms at/above the floor — merely-missing timing does not affirm).\n *   The hub gate keys its BotID form-downgrade on this.\n */\nexport type HumanityVerdictDiagnostics = {\n  honeypotLength: number;\n  timingAffirmed: boolean;\n};\n\n/** Result of {@link evaluateHumanitySignals}. */\nexport type HumanityVerdict = HumanityVerdictDiagnostics &\n  (\n    | {\n        ok: true;\n        /** Present when a filled decoy was forgiven as autofill; `sourceField` names the body field it was copied from. */\n        note?: 'honeypot_autofill';\n        sourceField?: string;\n      }\n    | { ok: false; reason: 'honeypot' | 'too_fast' }\n  );\n\n/** Tolerant reader — never throws; missing/garbage timing → null. */\nexport function extractHumanitySignals(body: unknown): { honeypot: string; elapsedMs: number | null } {\n  const b = (body ?? {}) as Record<string, unknown>;\n  const rawHp = b[HONEYPOT_FIELD];\n  // A legit client always sends a STRING here (getSignals → ref.value ?? ''),\n  // so ANY present non-string value is a bot dodging the empty-check — coerce\n  // to a NON-EMPTY string so it still trips (JSON.stringify keeps `[]`/`{}`\n  // non-empty where String() would collapse them to '').\n  // null/undefined → '' = the correct \"field absent / unfilled\" allow case.\n  const honeypot = rawHp == null ? '' : typeof rawHp === 'string' ? rawHp : (JSON.stringify(rawHp) ?? String(rawHp));\n  const rawMs = b[ELAPSED_MS_FIELD];\n  const elapsedMs = typeof rawMs === 'number' && Number.isFinite(rawMs) ? rawMs : null;\n  return { honeypot, elapsedMs };\n}\n\n/**\n * Comparison normalization for the copy-match: autofill may fill the decoy\n * with a differently-FORMATTED rendition of the value the client posts (the\n * waitlist normalizes phones to E.164 before POST while a manager fills the\n * stored \"(555) 123-4567\"), so exact equality misses real humans. Normalized\n * equality still requires the decoy to mirror a real field's CONTENT, which a\n * bot gains nothing from — it could always send the decoy empty instead.\n */\nconst normalizeForCopyMatch = (s: string): string => s.normalize('NFKC').trim().toLowerCase();\nconst digitsOf = (s: string): string => s.replace(/\\D/g, '');\n\n/** Minimum normalized length for a copy-match — a 1-char echo is coincidence, not autofill. */\nconst MIN_COPY_MATCH_LENGTH = 2;\n/** Digits-only phone matching needs a real phone-sized run to be meaningful. */\nconst MIN_PHONE_DIGITS = 7;\n\n/**\n * Phone-sized digit runs match when one ENDS WITH the other: a manager fills\n * the stored national format (\"(555) 123-4567\") while the client posts E.164\n * (\"+15551234567\") — same phone, differing only by the country-code prefix.\n */\nconst phoneDigitsMatch = (a: string, b: string): boolean =>\n  a.length >= MIN_PHONE_DIGITS && b.length >= MIN_PHONE_DIGITS && (a.endsWith(b) || b.endsWith(a));\n\n/**\n * Find the body field the decoy value was COPIED from — the autofill\n * signature: browsers and password-manager extensions fill the hidden input\n * with the same datum they put in a visible field (email, phone, …) — a human\n * with autofill, not a bot. Scans top-level string values, string arrays, and\n * one nested level (the booking form's custom `formFields` object). Matches\n * normalized equality, plus digits-only equality for phone-sized values.\n *\n * Returns the matched field's path (`email`, `formFields.phone`, `tags[]`) —\n * the SSOT for both the verdict and the hub gate's `sameAs` log diagnostic —\n * or `null` when nothing matches.\n */\nexport function findHoneypotCopySource(body: unknown, honeypot: string): string | null {\n  const hpNorm = normalizeForCopyMatch(honeypot);\n  const hpDigits = digitsOf(honeypot);\n  const phoneCandidate = hpDigits.length >= MIN_PHONE_DIGITS;\n  if (hpNorm.length < MIN_COPY_MATCH_LENGTH && !phoneCandidate) return null;\n\n  const matches = (value: unknown): boolean => {\n    if (typeof value !== 'string' || value.trim() === '') return false;\n    // No length re-check: the top guard already rejected sub-minimum values\n    // (phoneCandidate implies ≥7 chars survive normalization — digits do).\n    if (normalizeForCopyMatch(value) === hpNorm) return true;\n    return phoneCandidate && phoneDigitsMatch(digitsOf(value), hpDigits);\n  };\n  const matchInArray = (value: unknown): boolean => Array.isArray(value) && value.some(matches);\n\n  const b = (body ?? {}) as Record<string, unknown>;\n  for (const [key, value] of Object.entries(b)) {\n    if (isHumanitySignalKey(key)) continue;\n    if (matches(value)) return key;\n    if (matchInArray(value)) return `${key}[]`;\n    if (value && typeof value === 'object' && !Array.isArray(value)) {\n      for (const [nestedKey, nested] of Object.entries(value as Record<string, unknown>)) {\n        if (matches(nested)) return `${key}.${nestedKey}`;\n        if (matchInArray(nested)) return `${key}.${nestedKey}[]`;\n      }\n    }\n  }\n  return null;\n}\n\n/**\n * SINGLE decision fn for honeypot + timing (the hub's `verifyHuman` imports + calls this):\n * - honeypot non-empty → bot (real users never fill the off-screen field) — UNLESS the\n *   value was copied from another field in the body (autofill reached the decoy → human;\n *   the verdict carries `note: 'honeypot_autofill'` + the `sourceField` so callers log it)\n * - elapsed below `minFillMs` → bot (humans take time; a MISSING timing value never\n *   blocks — and the too-fast check still applies to autofill-forgiven submissions)\n */\nexport function evaluateHumanitySignals(body: unknown, opts: { minFillMs: number }): HumanityVerdict {\n  const { honeypot, elapsedMs } = extractHumanitySignals(body);\n  const diagnostics: HumanityVerdictDiagnostics = {\n    honeypotLength: honeypot.length,\n    timingAffirmed: elapsedMs !== null && elapsedMs >= opts.minFillMs,\n  };\n  const filled = honeypot.trim() !== '';\n  const sourceField = filled ? findHoneypotCopySource(body, honeypot) : null;\n  if (filled && sourceField === null) return { ...diagnostics, ok: false, reason: 'honeypot' };\n  if (elapsedMs !== null && elapsedMs < opts.minFillMs) return { ...diagnostics, ok: false, reason: 'too_fast' };\n  return sourceField !== null\n    ? { ...diagnostics, ok: true, note: 'honeypot_autofill', sourceField }\n    : { ...diagnostics, ok: true };\n}\n\n/** Parse a comma-separated env string → trimmed, non-empty entries (undefined → []). */\nexport const splitCsvEnv = (s?: string): string[] =>\n  s\n    ?.split(',')\n    .map(t => t.trim())\n    .filter(Boolean) ?? [];\n"]}