@flamingo-stack/openframe-frontend-core 0.0.572 → 0.0.573-1933.5266.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/dist/{chunk-3ZCIC4MS.js → chunk-2BNPLJSU.js} +3 -3
  2. package/dist/{chunk-2IMAYSYH.cjs → chunk-47Y537OE.cjs} +19 -19
  3. package/dist/{chunk-2IMAYSYH.cjs.map → chunk-47Y537OE.cjs.map} +1 -1
  4. package/dist/{chunk-LMRBXA2F.cjs → chunk-5TVO35AW.cjs} +3 -3
  5. package/dist/{chunk-LMRBXA2F.cjs.map → chunk-5TVO35AW.cjs.map} +1 -1
  6. package/dist/{chunk-YSG6JYBP.cjs → chunk-5ZKZK3V2.cjs} +16 -16
  7. package/dist/{chunk-YSG6JYBP.cjs.map → chunk-5ZKZK3V2.cjs.map} +1 -1
  8. package/dist/{chunk-VKZDIRXC.cjs → chunk-66CKTUIV.cjs} +3 -3
  9. package/dist/{chunk-VKZDIRXC.cjs.map → chunk-66CKTUIV.cjs.map} +1 -1
  10. package/dist/{chunk-OQNIQVET.js → chunk-6QJYO3SB.js} +21 -3
  11. package/dist/chunk-6QJYO3SB.js.map +1 -0
  12. package/dist/{chunk-RSVRM35D.js → chunk-6ZC6IC6H.js} +2 -2
  13. package/dist/{chunk-7Y2MC6LN.cjs → chunk-7D3KGRH5.cjs} +37 -37
  14. package/dist/{chunk-7Y2MC6LN.cjs.map → chunk-7D3KGRH5.cjs.map} +1 -1
  15. package/dist/{chunk-R6UKDAGZ.js → chunk-BNAN4WLF.js} +6 -6
  16. package/dist/{chunk-JS3U2V7J.js → chunk-CNNRCN6S.js} +3 -3
  17. package/dist/{chunk-P5MRXAXN.cjs → chunk-D5GJWW6Z.cjs} +10 -10
  18. package/dist/{chunk-P5MRXAXN.cjs.map → chunk-D5GJWW6Z.cjs.map} +1 -1
  19. package/dist/{chunk-7A7SH2RI.js → chunk-E4W7RVFZ.js} +2 -2
  20. package/dist/{chunk-IGGHHVOD.js → chunk-EHQNJDP6.js} +3 -3
  21. package/dist/{chunk-L6C7SYRU.js → chunk-ET5V44BF.js} +4 -4
  22. package/dist/{chunk-MVR5MU7B.js → chunk-G5EH3A2U.js} +4 -4
  23. package/dist/{chunk-ZHI7PPI7.cjs → chunk-HV3SJHX2.cjs} +7 -7
  24. package/dist/{chunk-ZHI7PPI7.cjs.map → chunk-HV3SJHX2.cjs.map} +1 -1
  25. package/dist/{chunk-5PFN7X2H.cjs → chunk-IHW7HEFU.cjs} +22 -22
  26. package/dist/{chunk-5PFN7X2H.cjs.map → chunk-IHW7HEFU.cjs.map} +1 -1
  27. package/dist/{chunk-QCWTNMWZ.js → chunk-KELXUVJW.js} +25 -6
  28. package/dist/{chunk-QCWTNMWZ.js.map → chunk-KELXUVJW.js.map} +1 -1
  29. package/dist/{chunk-A3WAKKCV.cjs → chunk-LC4AKB7U.cjs} +3 -3
  30. package/dist/{chunk-A3WAKKCV.cjs.map → chunk-LC4AKB7U.cjs.map} +1 -1
  31. package/dist/{chunk-II52DW7X.js → chunk-OZGDWUVN.js} +2 -2
  32. package/dist/{chunk-3CWVBREW.cjs → chunk-P2MTEESX.cjs} +64 -46
  33. package/dist/chunk-P2MTEESX.cjs.map +1 -0
  34. package/dist/{chunk-GWDOWAHU.cjs → chunk-PLBXVJIN.cjs} +3 -3
  35. package/dist/{chunk-GWDOWAHU.cjs.map → chunk-PLBXVJIN.cjs.map} +1 -1
  36. package/dist/{chunk-6JZXYDWC.js → chunk-PVZZGQMZ.js} +5 -5
  37. package/dist/{chunk-W6PFJ6RY.cjs → chunk-QXEXMPXV.cjs} +7 -7
  38. package/dist/{chunk-W6PFJ6RY.cjs.map → chunk-QXEXMPXV.cjs.map} +1 -1
  39. package/dist/{chunk-VNYEX66A.js → chunk-SVARR3OQ.js} +6 -6
  40. package/dist/{chunk-ZIF4IJ5L.cjs → chunk-VKYND7JR.cjs} +53 -53
  41. package/dist/{chunk-ZIF4IJ5L.cjs.map → chunk-VKYND7JR.cjs.map} +1 -1
  42. package/dist/{chunk-IZBQBUDP.js → chunk-X3VKHMHA.js} +3 -3
  43. package/dist/{chunk-RXMY3LTP.js → chunk-X4M3KSFH.js} +2 -2
  44. package/dist/{chunk-5LFT3RJQ.cjs → chunk-Y24CV7JN.cjs} +17 -17
  45. package/dist/{chunk-5LFT3RJQ.cjs.map → chunk-Y24CV7JN.cjs.map} +1 -1
  46. package/dist/{chunk-GC3TT3UF.cjs → chunk-YNBE6G5I.cjs} +26 -7
  47. package/dist/chunk-YNBE6G5I.cjs.map +1 -0
  48. package/dist/{chunk-KXKMVZUG.js → chunk-YW2JM4EC.js} +2 -2
  49. package/dist/{chunk-LE4RPH3E.cjs → chunk-YYO5RZJU.cjs} +36 -36
  50. package/dist/{chunk-LE4RPH3E.cjs.map → chunk-YYO5RZJU.cjs.map} +1 -1
  51. package/dist/components/case-studies/index.cjs +9 -9
  52. package/dist/components/case-studies/index.js +3 -3
  53. package/dist/components/contact/index.cjs +4 -4
  54. package/dist/components/contact/index.js +3 -3
  55. package/dist/components/docs/index.cjs +7 -7
  56. package/dist/components/docs/index.js +6 -6
  57. package/dist/components/embeds/index.cjs +4 -4
  58. package/dist/components/embeds/index.js +3 -3
  59. package/dist/components/faq/index.cjs +4 -4
  60. package/dist/components/faq/index.js +3 -3
  61. package/dist/components/features/index.cjs +3 -3
  62. package/dist/components/features/index.js +2 -2
  63. package/dist/components/help-center-pages/index.cjs +23 -23
  64. package/dist/components/help-center-pages/index.js +14 -14
  65. package/dist/components/index.cjs +118 -118
  66. package/dist/components/index.js +13 -13
  67. package/dist/components/meeting-scheduler/index.cjs +19 -19
  68. package/dist/components/meeting-scheduler/index.cjs.map +1 -1
  69. package/dist/components/meeting-scheduler/index.js +3 -3
  70. package/dist/components/meeting-scheduler/index.js.map +1 -1
  71. package/dist/components/navigation/index.cjs +6 -6
  72. package/dist/components/navigation/index.js +5 -5
  73. package/dist/components/onboarding-guides/index.cjs +6 -6
  74. package/dist/components/onboarding-guides/index.js +5 -5
  75. package/dist/components/tickets/index.cjs +7 -7
  76. package/dist/components/tickets/index.js +6 -6
  77. package/dist/components/ui/honeypot-field.d.ts +11 -0
  78. package/dist/components/ui/honeypot-field.d.ts.map +1 -1
  79. package/dist/components/ui/index.cjs +3 -3
  80. package/dist/components/ui/index.js +2 -2
  81. package/dist/hooks/index.cjs +2 -2
  82. package/dist/hooks/index.js +1 -1
  83. package/dist/hooks/use-humanity-signals.d.ts +1 -1
  84. package/dist/hooks/use-meeting-booking.d.ts +3 -3
  85. package/dist/hooks/use-meeting-booking.d.ts.map +1 -1
  86. package/dist/index.cjs +9 -7
  87. package/dist/index.cjs.map +1 -1
  88. package/dist/index.js +8 -6
  89. package/dist/index.js.map +1 -1
  90. package/dist/utils/humanity-signals.cjs +24 -5
  91. package/dist/utils/humanity-signals.cjs.map +1 -1
  92. package/dist/utils/humanity-signals.d.ts +27 -4
  93. package/dist/utils/humanity-signals.d.ts.map +1 -1
  94. package/dist/utils/humanity-signals.js +24 -6
  95. package/dist/utils/humanity-signals.js.map +1 -1
  96. package/dist/utils/index.cjs +24 -5
  97. package/dist/utils/index.cjs.map +1 -1
  98. package/dist/utils/index.js +24 -6
  99. package/dist/utils/index.js.map +1 -1
  100. package/package.json +1 -1
  101. package/src/components/ui/honeypot-field.tsx +27 -1
  102. package/src/hooks/use-meeting-booking.ts +7 -5
  103. package/src/utils/__tests__/hubspot-meetings-convention.test.ts +4 -2
  104. package/src/utils/__tests__/humanity-signals.test.ts +75 -0
  105. package/src/utils/humanity-signals.ts +56 -8
  106. package/dist/chunk-3CWVBREW.cjs.map +0 -1
  107. package/dist/chunk-GC3TT3UF.cjs.map +0 -1
  108. package/dist/chunk-OQNIQVET.js.map +0 -1
  109. /package/dist/{chunk-3ZCIC4MS.js.map → chunk-2BNPLJSU.js.map} +0 -0
  110. /package/dist/{chunk-RSVRM35D.js.map → chunk-6ZC6IC6H.js.map} +0 -0
  111. /package/dist/{chunk-R6UKDAGZ.js.map → chunk-BNAN4WLF.js.map} +0 -0
  112. /package/dist/{chunk-JS3U2V7J.js.map → chunk-CNNRCN6S.js.map} +0 -0
  113. /package/dist/{chunk-7A7SH2RI.js.map → chunk-E4W7RVFZ.js.map} +0 -0
  114. /package/dist/{chunk-IGGHHVOD.js.map → chunk-EHQNJDP6.js.map} +0 -0
  115. /package/dist/{chunk-L6C7SYRU.js.map → chunk-ET5V44BF.js.map} +0 -0
  116. /package/dist/{chunk-MVR5MU7B.js.map → chunk-G5EH3A2U.js.map} +0 -0
  117. /package/dist/{chunk-II52DW7X.js.map → chunk-OZGDWUVN.js.map} +0 -0
  118. /package/dist/{chunk-6JZXYDWC.js.map → chunk-PVZZGQMZ.js.map} +0 -0
  119. /package/dist/{chunk-VNYEX66A.js.map → chunk-SVARR3OQ.js.map} +0 -0
  120. /package/dist/{chunk-IZBQBUDP.js.map → chunk-X3VKHMHA.js.map} +0 -0
  121. /package/dist/{chunk-RXMY3LTP.js.map → chunk-X4M3KSFH.js.map} +0 -0
  122. /package/dist/{chunk-KXKMVZUG.js.map → chunk-YW2JM4EC.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.572",
3
+ "version": "0.0.573-1933.5266.1",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -13,13 +13,39 @@ import { forwardRef } from 'react';
13
13
  * <label> (avoids id coupling); `aria-hidden` + `tabIndex={-1}` keep it out of
14
14
  * the accessibility tree and tab order.
15
15
  *
16
+ * AUTOFILL-PROOFING (load-bearing — real users were 403'd when autofill filled
17
+ * the decoy; see humanity-signals.ts module doc): browsers ignore
18
+ * `autocomplete="off"` for address-like fields, so the input is `readOnly`
19
+ * until focused — autofill and password managers skip read-only inputs, while
20
+ * a DOM-driving bot that focuses the field to type into it lifts the guard and
21
+ * still trips the trap. The `data-*` attributes opt out of the major password
22
+ * managers (1Password, LastPass, Bitwarden, Dashlane) by their documented
23
+ * ignore contracts. React never re-applies the prop after the focus handler
24
+ * flips the DOM property (same-value props aren't re-committed), so the
25
+ * lift-on-focus sticks.
26
+ *
16
27
  * forwardRef so `<HoneypotField {...honeypotInputProps} />` (which carries the
17
28
  * ref from useHumanitySignals) works on both React 18 and 19.
18
29
  */
19
30
  export const HoneypotField = forwardRef<HTMLInputElement, { name: string }>(function HoneypotFieldImpl({ name }, ref) {
20
31
  return (
21
32
  <div aria-hidden="true" className="pointer-events-none absolute -m-px h-px w-px overflow-hidden p-0 opacity-0">
22
- <input ref={ref} type="text" name={name} tabIndex={-1} autoComplete="off" />
33
+ <input
34
+ ref={ref}
35
+ type="text"
36
+ name={name}
37
+ tabIndex={-1}
38
+ autoComplete="off"
39
+ readOnly
40
+ onFocus={e => {
41
+ const input = e.currentTarget;
42
+ input.readOnly = false;
43
+ }}
44
+ data-1p-ignore="true"
45
+ data-lpignore="true"
46
+ data-bwignore="true"
47
+ data-form-type="other"
48
+ />
23
49
  </div>
24
50
  );
25
51
  });
@@ -31,9 +31,9 @@ import { MAX_MONTH_OFFSET } from '../utils/hubspot-meetings-convention';
31
31
  * it keeps rendering (with nothing selectable yet) while only the times column
32
32
  * — the part that genuinely has no answer — shows a placeholder.
33
33
  *
34
- * `staleTime` is deliberately short: HubSpot slots go stale in about a minute,
35
- * and a slot that is gone by the time it is clicked costs a booking. Fresh
36
- * enough to trust, cached enough not to blink.
34
+ * `staleTime` is ZERO: the host serves availability live (no-store), and a
35
+ * cached month re-shown while paging back is organizer-visible staleness. A
36
+ * slot gone by the time it is clicked costs a booking — always refetch.
37
37
  *
38
38
  * Booking: a mutation, guarded so a second submit can't start while one is in
39
39
  * flight (the transport never retries POST — this is the only double-booking
@@ -60,8 +60,10 @@ const KNOWN_CODES: MeetingBookingErrorCode[] = [
60
60
  'MEETING_UNAVAILABLE',
61
61
  ];
62
62
 
63
- /** ~half HubSpot's own slot volatility window — see the docblock. */
64
- const AVAILABILITY_STALE_MS = 30_000;
63
+ /** Always refetch — availability is served live (no-store) by the host; a
64
+ * cached month shown while paging back is exactly the staleness the server
65
+ * layers were stripped to kill (2026-08-27). */
66
+ const AVAILABILITY_STALE_MS = 0;
65
67
 
66
68
  /** Query key for one month of one link. Exported so a host can prefetch or
67
69
  * invalidate a month it knows changed (e.g. after booking elsewhere). */
@@ -14,6 +14,7 @@ import {
14
14
  parseSchedulingLinkName,
15
15
  schedulingAudienceKey,
16
16
  } from '../hubspot-meetings-convention';
17
+ import { HUMANITY_SIGNAL_KEYS } from '../humanity-signals';
17
18
 
18
19
  describe('slug shape validator', () => {
19
20
  it('allows N segments and rejects invalid characters', () => {
@@ -150,11 +151,12 @@ describe('makeBookingSchema × SUPPORTED_FORM_FIELD_TYPES', () => {
150
151
 
151
152
  it('strips unknown keys (humanity signals ride alongside, read raw pre-parse)', () => {
152
153
  const schema = makeBookingSchema([], null);
153
- const parsed = schema.safeParse({ ...base, contact_url_confirm: '', form_elapsed_ms: 4200 });
154
+ const signals = Object.fromEntries(HUMANITY_SIGNAL_KEYS.map(key => [key, key === 'form_elapsed_ms' ? 4200 : '']));
155
+ const parsed = schema.safeParse({ ...base, ...signals });
154
156
  // Throw rather than `if (parsed.success)`: the guard has to narrow the
155
157
  // discriminated union AND fail the test, and a conditional `expect` would
156
158
  // pass silently the day the parse starts failing.
157
159
  if (!parsed.success) throw new Error(`expected a successful parse: ${JSON.stringify(parsed.error.issues)}`);
158
- expect('contact_url_confirm' in parsed.data).toBe(false);
160
+ for (const key of HUMANITY_SIGNAL_KEYS) expect(key in parsed.data).toBe(false);
159
161
  });
160
162
  });
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ DEFAULT_MIN_FILL_MS,
5
+ ELAPSED_MS_FIELD,
6
+ evaluateHumanitySignals,
7
+ extractHumanitySignals,
8
+ HONEYPOT_FIELD,
9
+ LEGACY_HONEYPOT_FIELD,
10
+ } from '../humanity-signals';
11
+
12
+ const opts = { minFillMs: DEFAULT_MIN_FILL_MS };
13
+
14
+ describe('evaluateHumanitySignals', () => {
15
+ it('allows a clean human submission', () => {
16
+ expect(evaluateHumanitySignals({ email: 'a@b.co', [HONEYPOT_FIELD]: '', [ELAPSED_MS_FIELD]: 5000 }, opts)).toEqual({
17
+ ok: true,
18
+ });
19
+ });
20
+
21
+ it('blocks a filled honeypot with a value matching no other field', () => {
22
+ expect(evaluateHumanitySignals({ email: 'a@b.co', [HONEYPOT_FIELD]: 'https://spam.example' }, opts)).toEqual({
23
+ ok: false,
24
+ reason: 'honeypot',
25
+ });
26
+ });
27
+
28
+ it('blocks the legacy honeypot field name too (stale-lib embedder clients stay protected)', () => {
29
+ expect(evaluateHumanitySignals({ email: 'a@b.co', [LEGACY_HONEYPOT_FIELD]: 'filled' }, opts)).toEqual({
30
+ ok: false,
31
+ reason: 'honeypot',
32
+ });
33
+ });
34
+
35
+ it('forgives a honeypot value copied from another field (browser autofill signature)', () => {
36
+ expect(
37
+ evaluateHumanitySignals({ email: 'a@b.co', [HONEYPOT_FIELD]: 'a@b.co', [ELAPSED_MS_FIELD]: 5000 }, opts),
38
+ ).toEqual({ ok: true, note: 'honeypot_autofill' });
39
+ });
40
+
41
+ it('forgives autofill copied from a nested field (booking formFields)', () => {
42
+ expect(
43
+ evaluateHumanitySignals(
44
+ { email: 'a@b.co', formFields: { phone: '+1 555 0100' }, [HONEYPOT_FIELD]: '+1 555 0100' },
45
+ opts,
46
+ ),
47
+ ).toEqual({ ok: true, note: 'honeypot_autofill' });
48
+ });
49
+
50
+ it('still applies the timing check when the honeypot is autofill-forgiven', () => {
51
+ expect(
52
+ evaluateHumanitySignals({ email: 'a@b.co', [HONEYPOT_FIELD]: 'a@b.co', [ELAPSED_MS_FIELD]: 50 }, opts),
53
+ ).toEqual({ ok: false, reason: 'too_fast' });
54
+ });
55
+
56
+ it('a honeypot value matching only another SIGNAL key is not forgiven', () => {
57
+ expect(evaluateHumanitySignals({ [HONEYPOT_FIELD]: 'x', [LEGACY_HONEYPOT_FIELD]: 'x' }, opts)).toEqual({
58
+ ok: false,
59
+ reason: 'honeypot',
60
+ });
61
+ });
62
+
63
+ it('blocks sub-minimum fill time; missing timing never blocks', () => {
64
+ expect(evaluateHumanitySignals({ [ELAPSED_MS_FIELD]: 100 }, opts)).toEqual({ ok: false, reason: 'too_fast' });
65
+ expect(evaluateHumanitySignals({}, opts)).toEqual({ ok: true });
66
+ });
67
+ });
68
+
69
+ describe('extractHumanitySignals', () => {
70
+ it('coerces a non-string honeypot to a tripping string; prefers the current field name', () => {
71
+ expect(extractHumanitySignals({ [HONEYPOT_FIELD]: 123 }).honeypot).toBe('123');
72
+ expect(extractHumanitySignals({ [HONEYPOT_FIELD]: '', [LEGACY_HONEYPOT_FIELD]: 'old' }).honeypot).toBe('');
73
+ expect(extractHumanitySignals({ [LEGACY_HONEYPOT_FIELD]: 'old' }).honeypot).toBe('old');
74
+ });
75
+ });
@@ -11,10 +11,30 @@
11
11
  * field real users never fill) and timing (ms from form mount to submit).
12
12
  * `evaluateHumanitySignals` is the SINGLE source of truth for the block/allow
13
13
  * decision — the hub imports + calls it rather than re-implementing the rules.
14
+ *
15
+ * FALSE-POSITIVE HISTORY (2026-08-27): the honeypot was named
16
+ * `contact_url_confirm`, and browser/password-manager autofill (which ignores
17
+ * `autocomplete="off"` and matches "url"/"confirm" name heuristics) filled it
18
+ * for REAL users — every production `BOT_DETECTED` in the log window was a
19
+ * legitimate Chrome user whose autofill tripped the decoy. Three layers now
20
+ * prevent a recurrence; keep all three when touching this system:
21
+ * 1. The field name avoids every autofill-heuristic token (name/email/
22
+ * phone/url/website/confirm/company/address/code/…).
23
+ * 2. `HoneypotField` renders `readOnly`-until-focus + password-manager
24
+ * ignore attributes — autofill skips read-only inputs.
25
+ * 3. `evaluateHumanitySignals` forgives a filled decoy whose value was
26
+ * COPIED from another field in the same body (the autofill signature —
27
+ * a human, not a bot).
14
28
  */
15
29
 
16
- /** Hidden honeypot field name. Innocuous + autofill-resistant (deliberately NOT name/email). */
17
- export const HONEYPOT_FIELD = 'contact_url_confirm';
30
+ /** Hidden honeypot field name. Deliberately free of autofill-heuristic tokens (see module doc). */
31
+ export const HONEYPOT_FIELD = 'form_extra_note';
32
+ /**
33
+ * Prior honeypot field name — still EVALUATED (a stale-lib embedder's client
34
+ * keeps its bot protection) and still STRIPPED before upstream forwarding.
35
+ * Remove once no deployed embedder ships a lib older than the rename.
36
+ */
37
+ export const LEGACY_HONEYPOT_FIELD = 'contact_url_confirm';
18
38
  /** Client-measured ms between form mount and submit. */
19
39
  export const ELAPSED_MS_FIELD = 'form_elapsed_ms';
20
40
  /** Default minimum fill time (ms). A submit faster than this is treated as a bot. */
@@ -27,18 +47,19 @@ export const DEFAULT_MIN_FILL_MS = 700;
27
47
  * field rename here propagates everywhere and the honeypot value can never
28
48
  * silently leak into an upstream record.
29
49
  */
30
- export const HUMANITY_SIGNAL_KEYS = [HONEYPOT_FIELD, ELAPSED_MS_FIELD] as const;
50
+ export const HUMANITY_SIGNAL_KEYS = [HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELD, ELAPSED_MS_FIELD] as const;
31
51
 
32
52
  /** Keyed wire object produced by `useHumanitySignals().getSignals()` and spread into the POST body. */
33
53
  export type HumanitySignals = Record<string, string | number>;
34
54
 
35
55
  /** Result of {@link evaluateHumanitySignals}. */
36
- export type HumanityVerdict = { ok: true } | { ok: false; reason: 'honeypot' | 'too_fast' };
56
+ export type HumanityVerdict = { ok: true; note?: 'honeypot_autofill' } | { ok: false; reason: 'honeypot' | 'too_fast' };
37
57
 
38
58
  /** Tolerant reader — never throws; missing/garbage timing → null. */
39
59
  export function extractHumanitySignals(body: unknown): { honeypot: string; elapsedMs: number | null } {
40
60
  const b = (body ?? {}) as Record<string, unknown>;
41
- const rawHp = b[HONEYPOT_FIELD];
61
+ // Current field name first; a stale-lib client still posts the legacy name.
62
+ const rawHp = b[HONEYPOT_FIELD] ?? b[LEGACY_HONEYPOT_FIELD];
42
63
  // A legit client always sends a STRING here (getSignals → ref.value ?? ''),
43
64
  // so ANY present non-string value is a bot filling the decoy with a non-string
44
65
  // to dodge the empty-check — coerce to a (non-empty) string so it still trips.
@@ -49,16 +70,43 @@ export function extractHumanitySignals(body: unknown): { honeypot: string; elaps
49
70
  return { honeypot, elapsedMs };
50
71
  }
51
72
 
73
+ /**
74
+ * Was the decoy value COPIED from a real field in the same body? That is the
75
+ * autofill signature: browsers and password-manager extensions fill the hidden
76
+ * input with the same value they put in a visible field (email, phone, …) —
77
+ * a human with autofill, not a bot. Scans top-level string values plus one
78
+ * nested level (the booking form's custom `formFields` object).
79
+ */
80
+ function honeypotCopiedFromAnotherField(body: unknown, honeypot: string): boolean {
81
+ const b = (body ?? {}) as Record<string, unknown>;
82
+ const signalKeys: readonly string[] = HUMANITY_SIGNAL_KEYS;
83
+ for (const [key, value] of Object.entries(b)) {
84
+ if (signalKeys.includes(key)) continue;
85
+ if (typeof value === 'string' && value === honeypot) return true;
86
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
87
+ for (const nested of Object.values(value as Record<string, unknown>)) {
88
+ if (typeof nested === 'string' && nested === honeypot) return true;
89
+ }
90
+ }
91
+ }
92
+ return false;
93
+ }
94
+
52
95
  /**
53
96
  * SINGLE decision fn for honeypot + timing (the hub's `verifyHuman` imports + calls this):
54
- * - honeypot non-empty → bot (real users never fill the off-screen field)
97
+ * - honeypot non-empty → bot (real users never fill the off-screen field) — UNLESS the
98
+ * value was copied from another field in the body (autofill reached the decoy → human;
99
+ * the verdict carries `note: 'honeypot_autofill'` so the caller can log it)
55
100
  * - elapsed below `minFillMs` → bot (humans take time; a MISSING timing value never blocks)
56
101
  */
57
102
  export function evaluateHumanitySignals(body: unknown, opts: { minFillMs: number }): HumanityVerdict {
58
103
  const { honeypot, elapsedMs } = extractHumanitySignals(body);
59
- if (honeypot.trim() !== '') return { ok: false, reason: 'honeypot' };
104
+ const filled = honeypot.trim() !== '';
105
+ if (filled && !honeypotCopiedFromAnotherField(body, honeypot)) {
106
+ return { ok: false, reason: 'honeypot' };
107
+ }
60
108
  if (elapsedMs !== null && elapsedMs < opts.minFillMs) return { ok: false, reason: 'too_fast' };
61
- return { ok: true };
109
+ return filled ? { ok: true, note: 'honeypot_autofill' } : { ok: true };
62
110
  }
63
111
 
64
112
  /** Parse a comma-separated env string → trimmed, non-empty entries (undefined → []). */