@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.
- package/dist/{chunk-3ZCIC4MS.js → chunk-2BNPLJSU.js} +3 -3
- package/dist/{chunk-2IMAYSYH.cjs → chunk-47Y537OE.cjs} +19 -19
- package/dist/{chunk-2IMAYSYH.cjs.map → chunk-47Y537OE.cjs.map} +1 -1
- package/dist/{chunk-LMRBXA2F.cjs → chunk-5TVO35AW.cjs} +3 -3
- package/dist/{chunk-LMRBXA2F.cjs.map → chunk-5TVO35AW.cjs.map} +1 -1
- package/dist/{chunk-YSG6JYBP.cjs → chunk-5ZKZK3V2.cjs} +16 -16
- package/dist/{chunk-YSG6JYBP.cjs.map → chunk-5ZKZK3V2.cjs.map} +1 -1
- package/dist/{chunk-VKZDIRXC.cjs → chunk-66CKTUIV.cjs} +3 -3
- package/dist/{chunk-VKZDIRXC.cjs.map → chunk-66CKTUIV.cjs.map} +1 -1
- package/dist/{chunk-OQNIQVET.js → chunk-6QJYO3SB.js} +21 -3
- package/dist/chunk-6QJYO3SB.js.map +1 -0
- package/dist/{chunk-RSVRM35D.js → chunk-6ZC6IC6H.js} +2 -2
- package/dist/{chunk-7Y2MC6LN.cjs → chunk-7D3KGRH5.cjs} +37 -37
- package/dist/{chunk-7Y2MC6LN.cjs.map → chunk-7D3KGRH5.cjs.map} +1 -1
- package/dist/{chunk-R6UKDAGZ.js → chunk-BNAN4WLF.js} +6 -6
- package/dist/{chunk-JS3U2V7J.js → chunk-CNNRCN6S.js} +3 -3
- package/dist/{chunk-P5MRXAXN.cjs → chunk-D5GJWW6Z.cjs} +10 -10
- package/dist/{chunk-P5MRXAXN.cjs.map → chunk-D5GJWW6Z.cjs.map} +1 -1
- package/dist/{chunk-7A7SH2RI.js → chunk-E4W7RVFZ.js} +2 -2
- package/dist/{chunk-IGGHHVOD.js → chunk-EHQNJDP6.js} +3 -3
- package/dist/{chunk-L6C7SYRU.js → chunk-ET5V44BF.js} +4 -4
- package/dist/{chunk-MVR5MU7B.js → chunk-G5EH3A2U.js} +4 -4
- package/dist/{chunk-ZHI7PPI7.cjs → chunk-HV3SJHX2.cjs} +7 -7
- package/dist/{chunk-ZHI7PPI7.cjs.map → chunk-HV3SJHX2.cjs.map} +1 -1
- package/dist/{chunk-5PFN7X2H.cjs → chunk-IHW7HEFU.cjs} +22 -22
- package/dist/{chunk-5PFN7X2H.cjs.map → chunk-IHW7HEFU.cjs.map} +1 -1
- package/dist/{chunk-QCWTNMWZ.js → chunk-KELXUVJW.js} +25 -6
- package/dist/{chunk-QCWTNMWZ.js.map → chunk-KELXUVJW.js.map} +1 -1
- package/dist/{chunk-A3WAKKCV.cjs → chunk-LC4AKB7U.cjs} +3 -3
- package/dist/{chunk-A3WAKKCV.cjs.map → chunk-LC4AKB7U.cjs.map} +1 -1
- package/dist/{chunk-II52DW7X.js → chunk-OZGDWUVN.js} +2 -2
- package/dist/{chunk-3CWVBREW.cjs → chunk-P2MTEESX.cjs} +64 -46
- package/dist/chunk-P2MTEESX.cjs.map +1 -0
- package/dist/{chunk-GWDOWAHU.cjs → chunk-PLBXVJIN.cjs} +3 -3
- package/dist/{chunk-GWDOWAHU.cjs.map → chunk-PLBXVJIN.cjs.map} +1 -1
- package/dist/{chunk-6JZXYDWC.js → chunk-PVZZGQMZ.js} +5 -5
- package/dist/{chunk-W6PFJ6RY.cjs → chunk-QXEXMPXV.cjs} +7 -7
- package/dist/{chunk-W6PFJ6RY.cjs.map → chunk-QXEXMPXV.cjs.map} +1 -1
- package/dist/{chunk-VNYEX66A.js → chunk-SVARR3OQ.js} +6 -6
- package/dist/{chunk-ZIF4IJ5L.cjs → chunk-VKYND7JR.cjs} +53 -53
- package/dist/{chunk-ZIF4IJ5L.cjs.map → chunk-VKYND7JR.cjs.map} +1 -1
- package/dist/{chunk-IZBQBUDP.js → chunk-X3VKHMHA.js} +3 -3
- package/dist/{chunk-RXMY3LTP.js → chunk-X4M3KSFH.js} +2 -2
- package/dist/{chunk-5LFT3RJQ.cjs → chunk-Y24CV7JN.cjs} +17 -17
- package/dist/{chunk-5LFT3RJQ.cjs.map → chunk-Y24CV7JN.cjs.map} +1 -1
- package/dist/{chunk-GC3TT3UF.cjs → chunk-YNBE6G5I.cjs} +26 -7
- package/dist/chunk-YNBE6G5I.cjs.map +1 -0
- package/dist/{chunk-KXKMVZUG.js → chunk-YW2JM4EC.js} +2 -2
- package/dist/{chunk-LE4RPH3E.cjs → chunk-YYO5RZJU.cjs} +36 -36
- package/dist/{chunk-LE4RPH3E.cjs.map → chunk-YYO5RZJU.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +9 -9
- package/dist/components/case-studies/index.js +3 -3
- package/dist/components/contact/index.cjs +4 -4
- package/dist/components/contact/index.js +3 -3
- package/dist/components/docs/index.cjs +7 -7
- package/dist/components/docs/index.js +6 -6
- package/dist/components/embeds/index.cjs +4 -4
- package/dist/components/embeds/index.js +3 -3
- package/dist/components/faq/index.cjs +4 -4
- package/dist/components/faq/index.js +3 -3
- package/dist/components/features/index.cjs +3 -3
- package/dist/components/features/index.js +2 -2
- package/dist/components/help-center-pages/index.cjs +23 -23
- package/dist/components/help-center-pages/index.js +14 -14
- package/dist/components/index.cjs +118 -118
- package/dist/components/index.js +13 -13
- package/dist/components/meeting-scheduler/index.cjs +19 -19
- package/dist/components/meeting-scheduler/index.cjs.map +1 -1
- package/dist/components/meeting-scheduler/index.js +3 -3
- package/dist/components/meeting-scheduler/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +6 -6
- package/dist/components/navigation/index.js +5 -5
- package/dist/components/onboarding-guides/index.cjs +6 -6
- package/dist/components/onboarding-guides/index.js +5 -5
- package/dist/components/tickets/index.cjs +7 -7
- package/dist/components/tickets/index.js +6 -6
- package/dist/components/ui/honeypot-field.d.ts +11 -0
- package/dist/components/ui/honeypot-field.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +3 -3
- package/dist/components/ui/index.js +2 -2
- package/dist/hooks/index.cjs +2 -2
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/use-humanity-signals.d.ts +1 -1
- package/dist/hooks/use-meeting-booking.d.ts +3 -3
- package/dist/hooks/use-meeting-booking.d.ts.map +1 -1
- package/dist/index.cjs +9 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +8 -6
- package/dist/index.js.map +1 -1
- package/dist/utils/humanity-signals.cjs +24 -5
- package/dist/utils/humanity-signals.cjs.map +1 -1
- package/dist/utils/humanity-signals.d.ts +27 -4
- package/dist/utils/humanity-signals.d.ts.map +1 -1
- package/dist/utils/humanity-signals.js +24 -6
- package/dist/utils/humanity-signals.js.map +1 -1
- package/dist/utils/index.cjs +24 -5
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.js +24 -6
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/ui/honeypot-field.tsx +27 -1
- package/src/hooks/use-meeting-booking.ts +7 -5
- package/src/utils/__tests__/hubspot-meetings-convention.test.ts +4 -2
- package/src/utils/__tests__/humanity-signals.test.ts +75 -0
- package/src/utils/humanity-signals.ts +56 -8
- package/dist/chunk-3CWVBREW.cjs.map +0 -1
- package/dist/chunk-GC3TT3UF.cjs.map +0 -1
- package/dist/chunk-OQNIQVET.js.map +0 -1
- /package/dist/{chunk-3ZCIC4MS.js.map → chunk-2BNPLJSU.js.map} +0 -0
- /package/dist/{chunk-RSVRM35D.js.map → chunk-6ZC6IC6H.js.map} +0 -0
- /package/dist/{chunk-R6UKDAGZ.js.map → chunk-BNAN4WLF.js.map} +0 -0
- /package/dist/{chunk-JS3U2V7J.js.map → chunk-CNNRCN6S.js.map} +0 -0
- /package/dist/{chunk-7A7SH2RI.js.map → chunk-E4W7RVFZ.js.map} +0 -0
- /package/dist/{chunk-IGGHHVOD.js.map → chunk-EHQNJDP6.js.map} +0 -0
- /package/dist/{chunk-L6C7SYRU.js.map → chunk-ET5V44BF.js.map} +0 -0
- /package/dist/{chunk-MVR5MU7B.js.map → chunk-G5EH3A2U.js.map} +0 -0
- /package/dist/{chunk-II52DW7X.js.map → chunk-OZGDWUVN.js.map} +0 -0
- /package/dist/{chunk-6JZXYDWC.js.map → chunk-PVZZGQMZ.js.map} +0 -0
- /package/dist/{chunk-VNYEX66A.js.map → chunk-SVARR3OQ.js.map} +0 -0
- /package/dist/{chunk-IZBQBUDP.js.map → chunk-X3VKHMHA.js.map} +0 -0
- /package/dist/{chunk-RXMY3LTP.js.map → chunk-X4M3KSFH.js.map} +0 -0
- /package/dist/{chunk-KXKMVZUG.js.map → chunk-YW2JM4EC.js.map} +0 -0
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
/**
|
|
64
|
-
|
|
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
|
|
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(
|
|
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.
|
|
17
|
-
export const HONEYPOT_FIELD = '
|
|
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
|
-
|
|
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
|
-
|
|
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 → []). */
|