@ingram-tech/bot-protection 0.3.2 → 0.4.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/README.md CHANGED
@@ -17,7 +17,10 @@ bun add @ingram-tech/bot-protection
17
17
  bun add botid
18
18
  ```
19
19
 
20
- Set `BOT_PROTECTION_SECRET` (e.g. `openssl rand -hex 32`).
20
+ Set `BOT_PROTECTION_SECRET` (e.g. `openssl rand -hex 32`). To rotate it, set a
21
+ comma-separated list (`new,old`): tokens sign with the first secret and verify
22
+ against all of them, so in-flight forms keep working; drop the old one after
23
+ the token window (1h) has passed.
21
24
 
22
25
  ## Use
23
26
 
@@ -27,6 +30,12 @@ Set `BOT_PROTECTION_SECRET` (e.g. `openssl rand -hex 32`).
27
30
  import { createFormToken } from "@ingram-tech/bot-protection";
28
31
  import { HoneypotField } from "@ingram-tech/bot-protection/honeypot";
29
32
 
33
+ // The page that mints the token MUST render per-request. On a statically
34
+ // prerendered (or ISR/cached) page the timestamp is the BUILD time: once the
35
+ // deploy is older than the token window (default 1h), every real submission
36
+ // verifies as "expired" and is silently dropped.
37
+ export const dynamic = "force-dynamic";
38
+
30
39
  export default function ContactPage() {
31
40
  const token = createFormToken(); // server component / server-side
32
41
  return (
@@ -39,6 +48,9 @@ export default function ContactPage() {
39
48
  }
40
49
  ```
41
50
 
51
+ Prefer keeping the page static? Mint the token from a route handler instead
52
+ and fetch it client-side — that's exactly what the `/react` hook does (below).
53
+
42
54
  **Verify on submit** (API route or server action):
43
55
 
44
56
  ```ts
@@ -71,7 +83,9 @@ token; its `POST` verifies:
71
83
  import { HoneypotInput, useBotProtection } from "@ingram-tech/bot-protection/react";
72
84
 
73
85
  export function ContactForm() {
74
- const { honeypotRef, botFields } = useBotProtection("/api/contact");
86
+ // `ready` flips true once the token fetch resolves — gate the submit button
87
+ // on it if you don't want early submissions treated as bot-ish.
88
+ const { honeypotRef, botFields, ready } = useBotProtection("/api/contact");
75
89
 
76
90
  async function onSubmit(values: FormValues) {
77
91
  await fetch("/api/contact", {
@@ -93,6 +107,10 @@ export function ContactForm() {
93
107
  // app/api/contact/route.ts
94
108
  import { createFormToken, verifyHuman } from "@ingram-tech/bot-protection";
95
109
 
110
+ // Same freshness rule as above: the GET must not be cached, or the token
111
+ // ages with the cache entry and eventually expires every submission.
112
+ export const dynamic = "force-dynamic";
113
+
96
114
  export const GET = () => Response.json({ token: createFormToken() });
97
115
 
98
116
  export async function POST(request: Request) {
package/dist/botid.d.ts CHANGED
@@ -7,6 +7,11 @@
7
7
  * - if it isn't installed (or the call throws off-Vercel/in dev), we degrade
8
8
  * to "not a bot" rather than blocking real users.
9
9
  *
10
+ * The degrade is logged once per process: bundler file-tracing can exclude
11
+ * `botid/server` from a deployed function even when the package is installed
12
+ * (nothing imports it statically), and a silently disabled layer 3 is
13
+ * otherwise indistinguishable from "no bots today".
14
+ *
10
15
  * App-side wiring (per site, not in this package): add `withBotId(nextConfig)`
11
16
  * in next.config and `initBotId({ protect: [...] })` in instrumentation-client.
12
17
  */
@@ -1 +1 @@
1
- {"version":3,"file":"botid.d.ts","sourceRoot":"","sources":["../src/botid.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,eAAO,MAAM,QAAQ,QAAa,OAAO,CAAC;IAAE,KAAK,EAAE,OAAO,CAAA;CAAE,CAW3D,CAAC"}
1
+ {"version":3,"file":"botid.d.ts","sourceRoot":"","sources":["../src/botid.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAYH,eAAO,MAAM,QAAQ,QAAa,OAAO,CAAC;IAAE,KAAK,EAAE,OAAO,CAAA;CAAE,CAmB3D,CAAC"}
package/dist/botid.js CHANGED
@@ -7,17 +7,37 @@
7
7
  * - if it isn't installed (or the call throws off-Vercel/in dev), we degrade
8
8
  * to "not a bot" rather than blocking real users.
9
9
  *
10
+ * The degrade is logged once per process: bundler file-tracing can exclude
11
+ * `botid/server` from a deployed function even when the package is installed
12
+ * (nothing imports it statically), and a silently disabled layer 3 is
13
+ * otherwise indistinguishable from "no bots today".
14
+ *
10
15
  * App-side wiring (per site, not in this package): add `withBotId(nextConfig)`
11
16
  * in next.config and `initBotId({ protect: [...] })` in instrumentation-client.
12
17
  */
18
+ let warnedUnavailable = false;
19
+ const warnOnce = (detail) => {
20
+ if (warnedUnavailable)
21
+ return;
22
+ warnedUnavailable = true;
23
+ console.warn(`@ingram-tech/bot-protection: BotID layer unavailable, degrading to "not a bot" (${detail}).`);
24
+ };
13
25
  export const checkBot = async () => {
14
26
  try {
15
27
  // Non-literal specifier: keeps tsc from requiring `botid` at build time.
16
28
  const specifier = "botid/server";
29
+ // Shape-checked below rather than trusted — the cast only names what we
30
+ // probe for.
17
31
  const mod = (await import(specifier));
18
- return await mod.checkBotId();
32
+ if (typeof mod.checkBotId !== "function") {
33
+ warnOnce("botid/server has no checkBotId export");
34
+ return { isBot: false };
35
+ }
36
+ const result = await mod.checkBotId();
37
+ return { isBot: result?.isBot === true };
19
38
  }
20
- catch {
39
+ catch (error) {
40
+ warnOnce(error instanceof Error ? error.message : String(error));
21
41
  return { isBot: false };
22
42
  }
23
43
  };
package/dist/botid.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"botid.js","sourceRoot":"","sources":["../src/botid.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,MAAM,CAAC,MAAM,QAAQ,GAAG,KAAK,IAAiC,EAAE;IAC/D,IAAI,CAAC;QACJ,yEAAyE;QACzE,MAAM,SAAS,GAAG,cAAc,CAAC;QACjC,MAAM,GAAG,GAAG,CAAC,MAAM,MAAM,CAAC,SAAS,CAAC,CAEnC,CAAC;QACF,OAAO,MAAM,GAAG,CAAC,UAAU,EAAE,CAAC;IAC/B,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IACzB,CAAC;AACF,CAAC,CAAC"}
1
+ {"version":3,"file":"botid.js","sourceRoot":"","sources":["../src/botid.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,IAAI,iBAAiB,GAAG,KAAK,CAAC;AAE9B,MAAM,QAAQ,GAAG,CAAC,MAAc,EAAQ,EAAE;IACzC,IAAI,iBAAiB;QAAE,OAAO;IAC9B,iBAAiB,GAAG,IAAI,CAAC;IACzB,OAAO,CAAC,IAAI,CACX,mFAAmF,MAAM,IAAI,CAC7F,CAAC;AACH,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,QAAQ,GAAG,KAAK,IAAiC,EAAE;IAC/D,IAAI,CAAC;QACJ,yEAAyE;QACzE,MAAM,SAAS,GAAG,cAAc,CAAC;QACjC,wEAAwE;QACxE,aAAa;QACb,MAAM,GAAG,GAAG,CAAC,MAAM,MAAM,CAAC,SAAS,CAAC,CAEnC,CAAC;QACF,IAAI,OAAO,GAAG,CAAC,UAAU,KAAK,UAAU,EAAE,CAAC;YAC1C,QAAQ,CAAC,uCAAuC,CAAC,CAAC;YAClD,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;QACzB,CAAC;QACD,MAAM,MAAM,GAAG,MAAM,GAAG,CAAC,UAAU,EAAE,CAAC;QACtC,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,KAAK,IAAI,EAAE,CAAC;IAC1C,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,QAAQ,CAAC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;QACjE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IACzB,CAAC;AACF,CAAC,CAAC"}
@@ -0,0 +1,9 @@
1
+ import type { CSSProperties } from "react";
2
+ /**
3
+ * Shared visually-hidden style for the honeypot trap: pushed off-screen but
4
+ * still present and fillable in the DOM, so bots that fill every field give
5
+ * themselves away while real users never see it. Kept in one place so the SSR
6
+ * ({@link HoneypotField}) and client ({@link HoneypotInput}) traps can't drift.
7
+ */
8
+ export declare const VISUALLY_HIDDEN: CSSProperties;
9
+ //# sourceMappingURL=hidden-style.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hidden-style.d.ts","sourceRoot":"","sources":["../src/hidden-style.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,OAAO,CAAC;AAE3C;;;;;GAKG;AACH,eAAO,MAAM,eAAe,EAAE,aAO7B,CAAC"}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Shared visually-hidden style for the honeypot trap: pushed off-screen but
3
+ * still present and fillable in the DOM, so bots that fill every field give
4
+ * themselves away while real users never see it. Kept in one place so the SSR
5
+ * ({@link HoneypotField}) and client ({@link HoneypotInput}) traps can't drift.
6
+ */
7
+ export const VISUALLY_HIDDEN = {
8
+ position: "absolute",
9
+ left: "-9999px",
10
+ top: "-9999px",
11
+ width: "1px",
12
+ height: "1px",
13
+ overflow: "hidden",
14
+ };
15
+ //# sourceMappingURL=hidden-style.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hidden-style.js","sourceRoot":"","sources":["../src/hidden-style.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,eAAe,GAAkB;IAC7C,QAAQ,EAAE,UAAU;IACpB,IAAI,EAAE,SAAS;IACf,GAAG,EAAE,SAAS;IACd,KAAK,EAAE,KAAK;IACZ,MAAM,EAAE,KAAK;IACb,QAAQ,EAAE,QAAQ;CAClB,CAAC"}
@@ -1,3 +1,17 @@
1
+ /**
2
+ * Visually-hidden honeypot + signed timing token, dropped inside any <form>.
3
+ *
4
+ * Presentational only (no client JS), so it renders fine in server or client
5
+ * components. Pass a `token` minted server-side with `createFormToken()`.
6
+ *
7
+ * Real users never see or fill the honeypot (off-screen, aria-hidden,
8
+ * tabIndex -1, autocomplete off); bots that fill every field give themselves
9
+ * away.
10
+ *
11
+ * Pass `field` to use a custom honeypot name — it must match the
12
+ * `honeypotField` you pass to `verifyHuman()`, or the trap reads the wrong
13
+ * field and fails open.
14
+ */
1
15
  export declare const HoneypotField: ({ token, field, }: {
2
16
  token: string;
3
17
  field?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"honeypot.d.ts","sourceRoot":"","sources":["../src/honeypot.tsx"],"names":[],"mappings":"AA2BA,eAAO,MAAM,aAAa,GAAI,mBAG3B;IACF,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CACf,gCAoBA,CAAC"}
1
+ {"version":3,"file":"honeypot.d.ts","sourceRoot":"","sources":["../src/honeypot.tsx"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;GAaG;AAEH,eAAO,MAAM,aAAa,sBAGvB;IACF,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CACf,gCAoBA,CAAC"}
package/dist/honeypot.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { HONEYPOT_FIELD, TOKEN_FIELD } from "./fields.js";
3
+ import { VISUALLY_HIDDEN } from "./hidden-style.js";
3
4
  /**
4
5
  * Visually-hidden honeypot + signed timing token, dropped inside any <form>.
5
6
  *
@@ -14,13 +15,5 @@ import { HONEYPOT_FIELD, TOKEN_FIELD } from "./fields.js";
14
15
  * `honeypotField` you pass to `verifyHuman()`, or the trap reads the wrong
15
16
  * field and fails open.
16
17
  */
17
- const hidden = {
18
- position: "absolute",
19
- left: "-9999px",
20
- top: "-9999px",
21
- width: "1px",
22
- height: "1px",
23
- overflow: "hidden",
24
- };
25
- export const HoneypotField = ({ token, field = HONEYPOT_FIELD, }) => (_jsxs("div", { "aria-hidden": "true", style: hidden, children: [_jsxs("label", { children: ["Leave this field empty", _jsx("input", { type: "text", name: field, tabIndex: -1, autoComplete: "off", defaultValue: "", "data-1p-ignore": true, "data-lpignore": "true", "data-form-type": "other" })] }), _jsx("input", { type: "hidden", name: TOKEN_FIELD, defaultValue: token })] }));
18
+ export const HoneypotField = ({ token, field = HONEYPOT_FIELD, }) => (_jsxs("div", { "aria-hidden": "true", style: VISUALLY_HIDDEN, children: [_jsxs("label", { children: ["Leave this field empty", _jsx("input", { type: "text", name: field, tabIndex: -1, autoComplete: "off", defaultValue: "", "data-1p-ignore": true, "data-lpignore": "true", "data-form-type": "other" })] }), _jsx("input", { type: "hidden", name: TOKEN_FIELD, defaultValue: token })] }));
26
19
  //# sourceMappingURL=honeypot.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"honeypot.js","sourceRoot":"","sources":["../src/honeypot.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE1D;;;;;;;;;;;;;GAaG;AAEH,MAAM,MAAM,GAAkB;IAC7B,QAAQ,EAAE,UAAU;IACpB,IAAI,EAAE,SAAS;IACf,GAAG,EAAE,SAAS;IACd,KAAK,EAAE,KAAK;IACZ,MAAM,EAAE,KAAK;IACb,QAAQ,EAAE,QAAQ;CAClB,CAAC;AAEF,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,EAC7B,KAAK,EACL,KAAK,GAAG,cAAc,GAItB,EAAE,EAAE,CAAC,CACL,8BAAiB,MAAM,EAAC,KAAK,EAAE,MAAM,aACpC,sDAGC,gBACC,IAAI,EAAC,MAAM,EACX,IAAI,EAAE,KAAK,EACX,QAAQ,EAAE,CAAC,CAAC,EACZ,YAAY,EAAC,KAAK,EAClB,YAAY,EAAC,EAAE,2CAID,MAAM,oBACL,OAAO,GACrB,IACK,EACR,gBAAO,IAAI,EAAC,QAAQ,EAAC,IAAI,EAAE,WAAW,EAAE,YAAY,EAAE,KAAK,GAAI,IAC1D,CACN,CAAC"}
1
+ {"version":3,"file":"honeypot.js","sourceRoot":"","sources":["../src/honeypot.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1D,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAEpD;;;;;;;;;;;;;GAaG;AAEH,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,EAC7B,KAAK,EACL,KAAK,GAAG,cAAc,GAItB,EAAE,EAAE,CAAC,CACL,8BAAiB,MAAM,EAAC,KAAK,EAAE,eAAe,aAC7C,sDAGC,gBACC,IAAI,EAAC,MAAM,EACX,IAAI,EAAE,KAAK,EACX,QAAQ,EAAE,CAAC,CAAC,EACZ,YAAY,EAAC,KAAK,EAClB,YAAY,EAAC,EAAE,2CAID,MAAM,oBACL,OAAO,GACrB,IACK,EACR,gBAAO,IAAI,EAAC,QAAQ,EAAC,IAAI,EAAE,WAAW,EAAE,YAAY,EAAE,KAAK,GAAI,IAC1D,CACN,CAAC"}
package/dist/keys.d.ts CHANGED
@@ -4,8 +4,16 @@
4
4
  * BOT_PROTECTION_SECRET — secret used to HMAC-sign the timing token so bots
5
5
  * can't forge a "this form was rendered long enough ago" claim. Generate with
6
6
  * e.g. `openssl rand -hex 32`.
7
+ *
8
+ * Rotation: a comma-separated list is accepted — tokens are signed with the
9
+ * FIRST secret and verified against ALL of them, so `new,old` rotates without
10
+ * invalidating up-to-an-hour-old in-flight forms (which would silently drop
11
+ * every open form's submission).
7
12
  */
13
+ /** The signing secret (first of the list). */
8
14
  export declare const getSecret: () => string;
15
+ /** All accepted verification secrets, newest first. */
16
+ export declare const getSecrets: () => string[];
9
17
  /** Whether the signed-timing layer is configured. */
10
18
  export declare const isConfigured: () => boolean;
11
19
  //# sourceMappingURL=keys.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"keys.d.ts","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,eAAO,MAAM,SAAS,QAAO,MAQ5B,CAAC;AAEF,qDAAqD;AACrD,eAAO,MAAM,YAAY,QAAO,OAAqD,CAAC"}
1
+ {"version":3,"file":"keys.d.ts","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAQH,8CAA8C;AAC9C,eAAO,MAAM,SAAS,QAAO,MAQ5B,CAAC;AAEF,uDAAuD;AACvD,eAAO,MAAM,UAAU,QAAO,MAAM,EACW,CAAC;AAEhD,qDAAqD;AACrD,eAAO,MAAM,YAAY,QAAO,OAAqD,CAAC"}
package/dist/keys.js CHANGED
@@ -4,14 +4,26 @@
4
4
  * BOT_PROTECTION_SECRET — secret used to HMAC-sign the timing token so bots
5
5
  * can't forge a "this form was rendered long enough ago" claim. Generate with
6
6
  * e.g. `openssl rand -hex 32`.
7
+ *
8
+ * Rotation: a comma-separated list is accepted — tokens are signed with the
9
+ * FIRST secret and verified against ALL of them, so `new,old` rotates without
10
+ * invalidating up-to-an-hour-old in-flight forms (which would silently drop
11
+ * every open form's submission).
7
12
  */
13
+ const parse = (raw) => raw
14
+ .split(",")
15
+ .map((s) => s.trim())
16
+ .filter((s) => s.length > 0);
17
+ /** The signing secret (first of the list). */
8
18
  export const getSecret = () => {
9
- const secret = process.env.BOT_PROTECTION_SECRET;
19
+ const secret = getSecrets()[0];
10
20
  if (!secret) {
11
21
  throw new Error("@ingram-tech/bot-protection: BOT_PROTECTION_SECRET is not configured");
12
22
  }
13
23
  return secret;
14
24
  };
25
+ /** All accepted verification secrets, newest first. */
26
+ export const getSecrets = () => parse(process.env.BOT_PROTECTION_SECRET ?? "");
15
27
  /** Whether the signed-timing layer is configured. */
16
28
  export const isConfigured = () => Boolean(process.env.BOT_PROTECTION_SECRET);
17
29
  //# sourceMappingURL=keys.js.map
package/dist/keys.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"keys.js","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,MAAM,CAAC,MAAM,SAAS,GAAG,GAAW,EAAE;IACrC,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,qBAAqB,CAAC;IACjD,IAAI,CAAC,MAAM,EAAE,CAAC;QACb,MAAM,IAAI,KAAK,CACd,sEAAsE,CACtE,CAAC;IACH,CAAC;IACD,OAAO,MAAM,CAAC;AACf,CAAC,CAAC;AAEF,qDAAqD;AACrD,MAAM,CAAC,MAAM,YAAY,GAAG,GAAY,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,qBAAqB,CAAC,CAAC"}
1
+ {"version":3,"file":"keys.js","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,MAAM,KAAK,GAAG,CAAC,GAAW,EAAY,EAAE,CACvC,GAAG;KACD,KAAK,CAAC,GAAG,CAAC;KACV,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;KACpB,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;AAE/B,8CAA8C;AAC9C,MAAM,CAAC,MAAM,SAAS,GAAG,GAAW,EAAE;IACrC,MAAM,MAAM,GAAG,UAAU,EAAE,CAAC,CAAC,CAAC,CAAC;IAC/B,IAAI,CAAC,MAAM,EAAE,CAAC;QACb,MAAM,IAAI,KAAK,CACd,sEAAsE,CACtE,CAAC;IACH,CAAC;IACD,OAAO,MAAM,CAAC;AACf,CAAC,CAAC;AAEF,uDAAuD;AACvD,MAAM,CAAC,MAAM,UAAU,GAAG,GAAa,EAAE,CACxC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,qBAAqB,IAAI,EAAE,CAAC,CAAC;AAEhD,qDAAqD;AACrD,MAAM,CAAC,MAAM,YAAY,GAAG,GAAY,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,qBAAqB,CAAC,CAAC"}
package/dist/react.d.ts CHANGED
@@ -10,22 +10,32 @@ import { type RefObject } from "react";
10
10
  * Pair with {@link HoneypotInput}, which renders the trap and wires the ref.
11
11
  *
12
12
  * @param tokenEndpoint - GET route that mints the token (e.g. "/api/contact").
13
+ * @param options.honeypotField - Override the trap field name; must match the
14
+ * server's `verifyHuman({ honeypotField })` and the `<HoneypotInput name>`.
13
15
  *
14
16
  * @example
15
- * const { honeypotRef, botFields } = useBotProtection("/api/contact");
17
+ * const { honeypotRef, botFields, ready } = useBotProtection("/api/contact");
16
18
  * // ...in the form: <HoneypotInput inputRef={honeypotRef} />
17
19
  * // ...on submit: body: JSON.stringify({ ...values, ...botFields() })
20
+ * // Optionally gate the submit button on `ready` — an empty token is
21
+ * // silently dropped server-side, so submitting before the token resolves
22
+ * // loses the message.
18
23
  */
19
- export declare function useBotProtection(tokenEndpoint: string): {
24
+ export declare function useBotProtection(tokenEndpoint: string, options?: {
25
+ honeypotField?: string;
26
+ }): {
20
27
  honeypotRef: RefObject<HTMLInputElement | null>;
21
28
  botFields: () => Record<string, string>;
29
+ ready: boolean;
22
30
  };
23
31
  /**
24
32
  * Visually-hidden honeypot input for {@link useBotProtection}. Real users never
25
33
  * see or fill it (off-screen, aria-hidden, tabIndex -1, autofill opted out);
26
34
  * bots that fill every field give themselves away.
27
35
  */
28
- export declare function HoneypotInput({ inputRef, }: {
36
+ export declare function HoneypotInput({ inputRef, name, }: {
29
37
  inputRef: RefObject<HTMLInputElement | null>;
38
+ /** Must match `useBotProtection`'s `honeypotField` when overridden. */
39
+ name?: string;
30
40
  }): import("react").JSX.Element;
31
41
  //# sourceMappingURL=react.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,SAAS,EAA+B,MAAM,OAAO,CAAC;AAGpE;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,gBAAgB,CAAC,aAAa,EAAE,MAAM;;qBAW/B,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;EAM5C;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,EAC7B,QAAQ,GACR,EAAE;IACF,QAAQ,EAAE,SAAS,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;CAC7C,+BAuBA"}
1
+ {"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,SAAS,EAA+B,MAAM,OAAO,CAAC;AAIpE;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,gBAAgB,CAC/B,aAAa,EAAE,MAAM,EACrB,OAAO,CAAC,EAAE;IAAE,aAAa,CAAC,EAAE,MAAM,CAAA;CAAE;;qBAsCd,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;;EAQ5C;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,EAC7B,QAAQ,EACR,IAAqB,GACrB,EAAE;IACF,QAAQ,EAAE,SAAS,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;IAC7C,uEAAuE;IACvE,IAAI,CAAC,EAAE,MAAM,CAAC;CACd,+BAiBA"}
package/dist/react.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { useEffect, useRef, useState } from "react";
4
4
  import { HONEYPOT_FIELD, TOKEN_FIELD } from "./fields.js";
5
+ import { VISUALLY_HIDDEN } from "./hidden-style.js";
5
6
  /**
6
7
  * Client-side bot protection for forms that POST JSON to your own route.
7
8
  *
@@ -13,39 +14,67 @@ import { HONEYPOT_FIELD, TOKEN_FIELD } from "./fields.js";
13
14
  * Pair with {@link HoneypotInput}, which renders the trap and wires the ref.
14
15
  *
15
16
  * @param tokenEndpoint - GET route that mints the token (e.g. "/api/contact").
17
+ * @param options.honeypotField - Override the trap field name; must match the
18
+ * server's `verifyHuman({ honeypotField })` and the `<HoneypotInput name>`.
16
19
  *
17
20
  * @example
18
- * const { honeypotRef, botFields } = useBotProtection("/api/contact");
21
+ * const { honeypotRef, botFields, ready } = useBotProtection("/api/contact");
19
22
  * // ...in the form: <HoneypotInput inputRef={honeypotRef} />
20
23
  * // ...on submit: body: JSON.stringify({ ...values, ...botFields() })
24
+ * // Optionally gate the submit button on `ready` — an empty token is
25
+ * // silently dropped server-side, so submitting before the token resolves
26
+ * // loses the message.
21
27
  */
22
- export function useBotProtection(tokenEndpoint) {
28
+ export function useBotProtection(tokenEndpoint, options) {
23
29
  const [token, setToken] = useState("");
30
+ const honeypotField = options?.honeypotField ?? HONEYPOT_FIELD;
24
31
  const honeypotRef = useRef(null);
25
32
  useEffect(() => {
26
- fetch(tokenEndpoint)
27
- .then((res) => res.json())
28
- .then((data) => setToken(data.token ?? ""))
29
- .catch(() => { });
33
+ let cancelled = false;
34
+ const load = async () => {
35
+ // One retry: a transiently failed fetch would otherwise leave the token
36
+ // empty forever, and the server then silently drops a REAL user's
37
+ // submission — the exact failure mode this package promises to avoid.
38
+ for (let attempt = 0; attempt < 2 && !cancelled; attempt++) {
39
+ try {
40
+ const res = await fetch(tokenEndpoint);
41
+ if (!res.ok)
42
+ throw new Error(`HTTP ${res.status}`);
43
+ const data = await res.json();
44
+ if (typeof data === "object" &&
45
+ data !== null &&
46
+ "token" in data &&
47
+ typeof data.token === "string" &&
48
+ data.token !== "") {
49
+ if (!cancelled)
50
+ setToken(data.token);
51
+ return;
52
+ }
53
+ }
54
+ catch {
55
+ // fall through to retry
56
+ }
57
+ }
58
+ };
59
+ void load();
60
+ return () => {
61
+ cancelled = true;
62
+ };
30
63
  }, [tokenEndpoint]);
31
64
  const botFields = () => ({
32
- [HONEYPOT_FIELD]: honeypotRef.current?.value ?? "",
65
+ [honeypotField]: honeypotRef.current?.value ?? "",
33
66
  [TOKEN_FIELD]: token,
34
67
  });
35
- return { honeypotRef, botFields };
68
+ // `ready` = the timing token resolved. Not required — the server treats a
69
+ // missing token as bot-ish — but lets forms wait instead of losing input.
70
+ return { honeypotRef, botFields, ready: token !== "" };
36
71
  }
37
72
  /**
38
73
  * Visually-hidden honeypot input for {@link useBotProtection}. Real users never
39
74
  * see or fill it (off-screen, aria-hidden, tabIndex -1, autofill opted out);
40
75
  * bots that fill every field give themselves away.
41
76
  */
42
- export function HoneypotInput({ inputRef, }) {
43
- return (_jsx("input", { ref: inputRef, type: "text", name: HONEYPOT_FIELD, tabIndex: -1, autoComplete: "off", "aria-hidden": "true", defaultValue: "", "data-1p-ignore": true, "data-lpignore": "true", "data-form-type": "other", style: {
44
- position: "absolute",
45
- left: "-9999px",
46
- width: "1px",
47
- height: "1px",
48
- overflow: "hidden",
49
- } }));
77
+ export function HoneypotInput({ inputRef, name = HONEYPOT_FIELD, }) {
78
+ return (_jsx("input", { ref: inputRef, type: "text", name: name, tabIndex: -1, autoComplete: "off", "aria-hidden": "true", defaultValue: "", "data-1p-ignore": true, "data-lpignore": "true", "data-form-type": "other", style: VISUALLY_HIDDEN }));
50
79
  }
51
80
  //# sourceMappingURL=react.js.map
package/dist/react.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"react.js","sourceRoot":"","sources":["../src/react.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AAEb,OAAO,EAAkB,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AACpE,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE1D;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,gBAAgB,CAAC,aAAqB;IACrD,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAC,EAAE,CAAC,CAAC;IACvC,MAAM,WAAW,GAAG,MAAM,CAAmB,IAAI,CAAC,CAAC;IAEnD,SAAS,CAAC,GAAG,EAAE;QACd,KAAK,CAAC,aAAa,CAAC;aAClB,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;aACzB,IAAI,CAAC,CAAC,IAAwB,EAAE,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC;aAC9D,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;IACnB,CAAC,EAAE,CAAC,aAAa,CAAC,CAAC,CAAC;IAEpB,MAAM,SAAS,GAAG,GAA2B,EAAE,CAAC,CAAC;QAChD,CAAC,cAAc,CAAC,EAAE,WAAW,CAAC,OAAO,EAAE,KAAK,IAAI,EAAE;QAClD,CAAC,WAAW,CAAC,EAAE,KAAK;KACpB,CAAC,CAAC;IAEH,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,CAAC;AACnC,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAAC,EAC7B,QAAQ,GAGR;IACA,OAAO,CACN,gBACC,GAAG,EAAE,QAAQ,EACb,IAAI,EAAC,MAAM,EACX,IAAI,EAAE,cAAc,EACpB,QAAQ,EAAE,CAAC,CAAC,EACZ,YAAY,EAAC,KAAK,iBACN,MAAM,EAClB,YAAY,EAAC,EAAE,2CAGD,MAAM,oBACL,OAAO,EACtB,KAAK,EAAE;YACN,QAAQ,EAAE,UAAU;YACpB,IAAI,EAAE,SAAS;YACf,KAAK,EAAE,KAAK;YACZ,MAAM,EAAE,KAAK;YACb,QAAQ,EAAE,QAAQ;SAClB,GACA,CACF,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"react.js","sourceRoot":"","sources":["../src/react.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AAEb,OAAO,EAAkB,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AACpE,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1D,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAEpD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,UAAU,gBAAgB,CAC/B,aAAqB,EACrB,OAAoC;IAEpC,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAC,EAAE,CAAC,CAAC;IACvC,MAAM,aAAa,GAAG,OAAO,EAAE,aAAa,IAAI,cAAc,CAAC;IAC/D,MAAM,WAAW,GAAG,MAAM,CAAmB,IAAI,CAAC,CAAC;IAEnD,SAAS,CAAC,GAAG,EAAE;QACd,IAAI,SAAS,GAAG,KAAK,CAAC;QACtB,MAAM,IAAI,GAAG,KAAK,IAAmB,EAAE;YACtC,wEAAwE;YACxE,kEAAkE;YAClE,sEAAsE;YACtE,KAAK,IAAI,OAAO,GAAG,CAAC,EAAE,OAAO,GAAG,CAAC,IAAI,CAAC,SAAS,EAAE,OAAO,EAAE,EAAE,CAAC;gBAC5D,IAAI,CAAC;oBACJ,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,aAAa,CAAC,CAAC;oBACvC,IAAI,CAAC,GAAG,CAAC,EAAE;wBAAE,MAAM,IAAI,KAAK,CAAC,QAAQ,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;oBACnD,MAAM,IAAI,GAAY,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;oBACvC,IACC,OAAO,IAAI,KAAK,QAAQ;wBACxB,IAAI,KAAK,IAAI;wBACb,OAAO,IAAI,IAAI;wBACf,OAAO,IAAI,CAAC,KAAK,KAAK,QAAQ;wBAC9B,IAAI,CAAC,KAAK,KAAK,EAAE,EAChB,CAAC;wBACF,IAAI,CAAC,SAAS;4BAAE,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;wBACrC,OAAO;oBACR,CAAC;gBACF,CAAC;gBAAC,MAAM,CAAC;oBACR,wBAAwB;gBACzB,CAAC;YACF,CAAC;QACF,CAAC,CAAC;QACF,KAAK,IAAI,EAAE,CAAC;QACZ,OAAO,GAAG,EAAE;YACX,SAAS,GAAG,IAAI,CAAC;QAClB,CAAC,CAAC;IACH,CAAC,EAAE,CAAC,aAAa,CAAC,CAAC,CAAC;IAEpB,MAAM,SAAS,GAAG,GAA2B,EAAE,CAAC,CAAC;QAChD,CAAC,aAAa,CAAC,EAAE,WAAW,CAAC,OAAO,EAAE,KAAK,IAAI,EAAE;QACjD,CAAC,WAAW,CAAC,EAAE,KAAK;KACpB,CAAC,CAAC;IAEH,0EAA0E;IAC1E,0EAA0E;IAC1E,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,KAAK,EAAE,KAAK,KAAK,EAAE,EAAE,CAAC;AACxD,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAAC,EAC7B,QAAQ,EACR,IAAI,GAAG,cAAc,GAKrB;IACA,OAAO,CACN,gBACC,GAAG,EAAE,QAAQ,EACb,IAAI,EAAC,MAAM,EACX,IAAI,EAAE,IAAI,EACV,QAAQ,EAAE,CAAC,CAAC,EACZ,YAAY,EAAC,KAAK,iBACN,MAAM,EAClB,YAAY,EAAC,EAAE,2CAGD,MAAM,oBACL,OAAO,EACtB,KAAK,EAAE,eAAe,GACrB,CACF,CAAC;AACH,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"token.d.ts","sourceRoot":"","sources":["../src/token.ts"],"names":[],"mappings":"AAmBA;;;;GAIG;AACH,eAAO,MAAM,eAAe,QAAO,MASlC,CAAC;AAEF,MAAM,WAAW,UAAU;IAC1B,+EAA+E;IAC/E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6EAA6E;IAC7E,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,WAAW;IAC3B,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,6EAA6E;AAC7E,eAAO,MAAM,eAAe,GAC3B,OAAO,MAAM,GAAG,SAAS,GAAG,IAAI,EAChC,mBAA0C,UAAe,KACvD,WAqBF,CAAC"}
1
+ {"version":3,"file":"token.d.ts","sourceRoot":"","sources":["../src/token.ts"],"names":[],"mappings":"AAuBA;;;;GAIG;AACH,eAAO,MAAM,eAAe,QAAO,MAYlC,CAAC;AAEF,MAAM,WAAW,UAAU;IAC1B,+EAA+E;IAC/E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6EAA6E;IAC7E,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,WAAW;IAC3B,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,6EAA6E;AAC7E,eAAO,MAAM,eAAe,UACpB,MAAM,GAAG,SAAS,GAAG,IAAI,qBACU,UAAU,KAClD,WA0BF,CAAC"}
package/dist/token.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHmac, timingSafeEqual } from "node:crypto";
2
- import { getSecret, isConfigured } from "./keys.js";
2
+ import { getSecret, getSecrets, isConfigured } from "./keys.js";
3
3
  /**
4
4
  * Signed timing token: `<timestamp>.<hmac>`. The timestamp records when the
5
5
  * form was rendered; the HMAC (keyed by BOT_PROTECTION_SECRET) prevents a bot
@@ -13,6 +13,9 @@ import { getSecret, isConfigured } from "./keys.js";
13
13
  * the token to a server-stored, one-time value (e.g. a session/CSRF nonce).
14
14
  */
15
15
  const sign = (ts, secret) => createHmac("sha256", secret).update(ts).digest("hex");
16
+ // Warn at most once: createFormToken runs on every form render, so an
17
+ // unconfigured secret would otherwise spam the logs on every request.
18
+ let warnedUnconfigured = false;
16
19
  /**
17
20
  * Mint a token. Call server-side when rendering the form. If
18
21
  * BOT_PROTECTION_SECRET isn't configured, returns "" (the timing layer is
@@ -20,7 +23,10 @@ const sign = (ts, secret) => createHmac("sha256", secret).update(ts).digest("hex
20
23
  */
21
24
  export const createFormToken = () => {
22
25
  if (!isConfigured()) {
23
- console.warn("@ingram-tech/bot-protection: BOT_PROTECTION_SECRET not set — timing token disabled.");
26
+ if (!warnedUnconfigured) {
27
+ warnedUnconfigured = true;
28
+ console.warn("@ingram-tech/bot-protection: BOT_PROTECTION_SECRET not set — timing token disabled.");
29
+ }
24
30
  return "";
25
31
  }
26
32
  const ts = Date.now().toString();
@@ -35,10 +41,15 @@ export const verifyFormToken = (token, { minMs = 2000, maxMs = 60 * 60 * 1000 }
35
41
  return { ok: false, reason: "malformed-token" };
36
42
  const ts = token.slice(0, dot);
37
43
  const sig = token.slice(dot + 1);
38
- const expected = sign(ts, getSecret());
44
+ // Verify against every configured secret (signing uses the first) so a
45
+ // rotation doesn't invalidate in-flight forms. Every candidate is compared
46
+ // timing-safely; the count of secrets is not attacker-relevant.
39
47
  const a = Buffer.from(sig);
40
- const b = Buffer.from(expected);
41
- if (a.length !== b.length || !timingSafeEqual(a, b)) {
48
+ const valid = getSecrets().some((secret) => {
49
+ const b = Buffer.from(sign(ts, secret));
50
+ return a.length === b.length && timingSafeEqual(a, b);
51
+ });
52
+ if (!valid) {
42
53
  return { ok: false, reason: "bad-signature" };
43
54
  }
44
55
  const elapsed = Date.now() - Number(ts);
package/dist/token.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"token.js","sourceRoot":"","sources":["../src/token.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC1D,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAEpD;;;;;;;;;;;GAWG;AAEH,MAAM,IAAI,GAAG,CAAC,EAAU,EAAE,MAAc,EAAU,EAAE,CACnD,UAAU,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAEvD;;;;GAIG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,GAAW,EAAE;IAC3C,IAAI,CAAC,YAAY,EAAE,EAAE,CAAC;QACrB,OAAO,CAAC,IAAI,CACX,qFAAqF,CACrF,CAAC;QACF,OAAO,EAAE,CAAC;IACX,CAAC;IACD,MAAM,EAAE,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE,CAAC;IACjC,OAAO,GAAG,EAAE,IAAI,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,CAAC,EAAE,CAAC;AACzC,CAAC,CAAC;AAcF,6EAA6E;AAC7E,MAAM,CAAC,MAAM,eAAe,GAAG,CAC9B,KAAgC,EAChC,EAAE,KAAK,GAAG,IAAI,EAAE,KAAK,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,KAAiB,EAAE,EAC3C,EAAE;IAChB,IAAI,CAAC,KAAK;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe,EAAE,CAAC;IAE1D,MAAM,GAAG,GAAG,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IAC/B,IAAI,GAAG,IAAI,CAAC;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,iBAAiB,EAAE,CAAC;IAC9D,MAAM,EAAE,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IAC/B,MAAM,GAAG,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC;IAEjC,MAAM,QAAQ,GAAG,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,CAAC,CAAC;IACvC,MAAM,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC3B,MAAM,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAChC,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM,IAAI,CAAC,eAAe,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC;QACrD,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe,EAAE,CAAC;IAC/C,CAAC;IAED,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,EAAE,CAAC,CAAC;IACxC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe,EAAE,CAAC;IAC7E,IAAI,OAAO,GAAG,KAAK;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;IAC9D,IAAI,OAAO,GAAG,KAAK;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;IAE7D,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC;AACrB,CAAC,CAAC"}
1
+ {"version":3,"file":"token.js","sourceRoot":"","sources":["../src/token.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,UAAU,EAAmB,MAAM,YAAY,CAAC;AAE9D,MAAM,WAAW,YAAY;IAC5B,EAAE,EAAE,OAAO,CAAC;IACZ,iEAAiE;IACjE,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC7B,0DAA0D;IAC1D,QAAQ,EAAE,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC7C,0CAA0C;IAC1C,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,oEAAoE;IACpE,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACvB;AAUD;;;;;;;;;GASG;AACH,eAAO,MAAM,WAAW,GAAU,SAAS,aAAa,KAAG,OAAO,CAAC,YAAY,CAsB9E,CAAC"}
1
+ {"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,UAAU,EAAmB,MAAM,YAAY,CAAC;AAE9D,MAAM,WAAW,YAAY;IAC5B,EAAE,EAAE,OAAO,CAAC;IACZ,iEAAiE;IACjE,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC7B,0DAA0D;IAC1D,QAAQ,EAAE,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC7C,0CAA0C;IAC1C,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,oEAAoE;IACpE,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACvB;AAOD;;;;;;;;;GASG;AACH,eAAO,MAAM,WAAW,YAAmB,aAAa,KAAG,OAAO,CAAC,YAAY,CAyB9E,CAAC"}
package/dist/verify.js CHANGED
@@ -3,9 +3,7 @@ import { HONEYPOT_FIELD, TOKEN_FIELD } from "./fields.js";
3
3
  import { isConfigured } from "./keys.js";
4
4
  import { verifyFormToken } from "./token.js";
5
5
  const getField = (formData, name) => {
6
- const value = formData instanceof FormData
7
- ? formData.get(name)
8
- : formData[name];
6
+ const value = formData instanceof FormData ? formData.get(name) : formData[name];
9
7
  return typeof value === "string" ? value : "";
10
8
  };
11
9
  /**
@@ -20,6 +18,9 @@ const getField = (formData, name) => {
20
18
  */
21
19
  export const verifyHuman = async (options) => {
22
20
  const honeypotField = options.honeypotField ?? HONEYPOT_FIELD;
21
+ // trim(): whitespace-only values pass the trap on purpose — some browser
22
+ // autofill/extension quirks insert stray spaces, and per the "never punish
23
+ // real users" ethos that's a weaker trap, not a false positive.
23
24
  if (getField(options.formData, honeypotField).trim() !== "") {
24
25
  return { ok: false, reason: "honeypot" };
25
26
  }
@@ -1 +1 @@
1
- {"version":3,"file":"verify.js","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1D,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,EAAmB,eAAe,EAAE,MAAM,YAAY,CAAC;AAsB9D,MAAM,QAAQ,GAAG,CAAC,QAAmC,EAAE,IAAY,EAAU,EAAE;IAC9E,MAAM,KAAK,GACV,QAAQ,YAAY,QAAQ;QAC3B,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC;QACpB,CAAC,CAAE,QAAoC,CAAC,IAAI,CAAC,CAAC;IAChD,OAAO,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;AAC/C,CAAC,CAAC;AAEF;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,KAAK,EAAE,OAAsB,EAAyB,EAAE;IAClF,MAAM,aAAa,GAAG,OAAO,CAAC,aAAa,IAAI,cAAc,CAAC;IAC9D,IAAI,QAAQ,CAAC,OAAO,CAAC,QAAQ,EAAE,aAAa,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QAC7D,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;IAC1C,CAAC;IAED,wEAAwE;IACxE,+EAA+E;IAC/E,IAAI,YAAY,EAAE,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,eAAe,CAC5B,QAAQ,CAAC,OAAO,CAAC,QAAQ,EAAE,WAAW,CAAC,EACvC,OAAO,CAAC,MAAM,CACd,CAAC;QACF,IAAI,CAAC,KAAK,CAAC,EAAE;YAAE,OAAO,KAAK,CAAC;IAC7B,CAAC;IAED,IAAI,OAAO,CAAC,KAAK,KAAK,KAAK,EAAE,CAAC;QAC7B,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,QAAQ,EAAE,CAAC;QACnC,IAAI,KAAK;YAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;IAClD,CAAC;IAED,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC;AACrB,CAAC,CAAC"}
1
+ {"version":3,"file":"verify.js","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1D,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,EAAmB,eAAe,EAAE,MAAM,YAAY,CAAC;AAsB9D,MAAM,QAAQ,GAAG,CAAC,QAAmC,EAAE,IAAY,EAAU,EAAE;IAC9E,MAAM,KAAK,GAAG,QAAQ,YAAY,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;IACjF,OAAO,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;AAC/C,CAAC,CAAC;AAEF;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,KAAK,EAAE,OAAsB,EAAyB,EAAE;IAClF,MAAM,aAAa,GAAG,OAAO,CAAC,aAAa,IAAI,cAAc,CAAC;IAC9D,yEAAyE;IACzE,2EAA2E;IAC3E,gEAAgE;IAChE,IAAI,QAAQ,CAAC,OAAO,CAAC,QAAQ,EAAE,aAAa,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QAC7D,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;IAC1C,CAAC;IAED,wEAAwE;IACxE,+EAA+E;IAC/E,IAAI,YAAY,EAAE,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,eAAe,CAC5B,QAAQ,CAAC,OAAO,CAAC,QAAQ,EAAE,WAAW,CAAC,EACvC,OAAO,CAAC,MAAM,CACd,CAAC;QACF,IAAI,CAAC,KAAK,CAAC,EAAE;YAAE,OAAO,KAAK,CAAC;IAC7B,CAAC;IAED,IAAI,OAAO,CAAC,KAAK,KAAK,KAAK,EAAE,CAAC;QAC7B,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,QAAQ,EAAE,CAAC;QACnC,IAAI,KAAK;YAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;IAClD,CAAC;IAED,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC;AACrB,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingram-tech/bot-protection",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
4
4
  "description": "Invisible, layered bot protection for forms — honeypot + signed timing token + Vercel BotID.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -39,12 +39,12 @@
39
39
  "test": "vitest run"
40
40
  },
41
41
  "devDependencies": {
42
- "@ingram-tech/nk-dev": "0.1.0",
43
- "@types/node": "^25.0.0",
44
- "@types/react": "^19.0.0",
45
- "react": "^19.0.0",
46
- "typescript": "^6.0.3",
47
- "vitest": "^4.1.6"
42
+ "@ingram-tech/nk-dev": "0.5.0",
43
+ "@types/node": "^26.1.1",
44
+ "@types/react": "^19.2.17",
45
+ "react": "^19.2.7",
46
+ "typescript": "^7.0.2",
47
+ "vitest": "^4.1.10"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "botid": ">=1.0.0",