@prosopo/procaptcha-common 2.9.23 → 2.10.18

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 (54) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +22 -15
  2. package/.turbo/turbo-build$colon$tsc.log +22 -16
  3. package/.turbo/turbo-build.log +22 -16
  4. package/CHANGELOG.md +355 -0
  5. package/dist/cjs/elements/window.cjs +7 -0
  6. package/dist/cjs/index.cjs +5 -0
  7. package/dist/cjs/reactComponents/Checkbox.cjs +11 -7
  8. package/dist/cjs/reactComponents/Honeypot.cjs +67 -0
  9. package/dist/cjs/reactComponents/TestModeBanner.cjs +47 -0
  10. package/dist/elements/window.d.ts +1 -0
  11. package/dist/elements/window.d.ts.map +1 -1
  12. package/dist/elements/window.js +8 -1
  13. package/dist/elements/window.js.map +1 -1
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +6 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/reactComponents/Checkbox.d.ts.map +1 -1
  19. package/dist/reactComponents/Checkbox.js +11 -7
  20. package/dist/reactComponents/Checkbox.js.map +1 -1
  21. package/dist/reactComponents/Honeypot.d.ts +6 -0
  22. package/dist/reactComponents/Honeypot.d.ts.map +1 -0
  23. package/dist/reactComponents/Honeypot.js +67 -0
  24. package/dist/reactComponents/Honeypot.js.map +1 -0
  25. package/dist/reactComponents/TestModeBanner.d.ts +7 -0
  26. package/dist/reactComponents/TestModeBanner.d.ts.map +1 -0
  27. package/dist/reactComponents/TestModeBanner.js +47 -0
  28. package/dist/reactComponents/TestModeBanner.js.map +1 -0
  29. package/dist/tests/window.test.js +22 -1
  30. package/dist/tests/window.test.js.map +1 -1
  31. package/package.json +11 -8
  32. package/src/callbacks/defaultCallbacks.ts +197 -0
  33. package/src/callbacks/defaultEvents.ts +21 -0
  34. package/src/elements/form.ts +41 -0
  35. package/src/elements/window.ts +39 -0
  36. package/src/extensionLoader.ts +17 -0
  37. package/src/index.ts +24 -0
  38. package/src/providers.ts +49 -0
  39. package/src/reactComponents/Checkbox.tsx +203 -0
  40. package/src/reactComponents/Honeypot.tsx +133 -0
  41. package/src/reactComponents/Reload.tsx +99 -0
  42. package/src/reactComponents/TestModeBanner.tsx +75 -0
  43. package/src/state/builder.ts +137 -0
  44. package/src/tests/defaultCallbacks.test.ts +372 -0
  45. package/src/tests/defaultEvents.test.ts +80 -0
  46. package/src/tests/extensionLoader.test.ts +41 -0
  47. package/src/tests/form.test.ts +154 -0
  48. package/src/tests/providers.test.ts +175 -0
  49. package/src/tests/state-builder.test.ts +264 -0
  50. package/src/tests/window.test.ts +137 -0
  51. package/tsconfig.cjs.json +32 -0
  52. package/tsconfig.json +33 -0
  53. package/tsconfig.tsbuildinfo +1 -0
  54. package/tsconfig.types.json +9 -0
@@ -0,0 +1,133 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ import {
16
+ type CSSProperties,
17
+ forwardRef,
18
+ useEffect,
19
+ useId,
20
+ useMemo,
21
+ useRef,
22
+ useState,
23
+ } from "react";
24
+ import { createPortal } from "react-dom";
25
+
26
+ interface HoneypotProps {
27
+ encodedQuestion: string;
28
+ }
29
+
30
+ const offscreenStyle: CSSProperties = {
31
+ position: "absolute",
32
+ left: "-9999px",
33
+ top: "-9999px",
34
+ width: "1px",
35
+ height: "1px",
36
+ overflow: "hidden",
37
+ opacity: 0,
38
+ };
39
+
40
+ // Server wraps morse/semaphore in base64 (utf-8) for the wire. Strip the
41
+ // base64 layer here so the rendered label is the raw morse/semaphore an agent
42
+ // can recognise and engage with.
43
+ const decodeBase64Utf8 = (b64: string): string => {
44
+ const binary = atob(b64);
45
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
46
+ return new TextDecoder().decode(bytes);
47
+ };
48
+
49
+ // Locate the dapp's enclosing <form> by stepping out of the widget's shadow
50
+ // root into light DOM, then walking up via closest(). Returns null when the
51
+ // widget isn't embedded inside a form.
52
+ const findAncestorForm = (anchor: Element): HTMLFormElement | null => {
53
+ const root = anchor.getRootNode();
54
+ const lightDomEntry = root instanceof ShadowRoot ? root.host : anchor;
55
+ return lightDomEntry instanceof Element
56
+ ? lightDomEntry.closest("form")
57
+ : null;
58
+ };
59
+
60
+ // Honeypot must live in light DOM, not in the widget's shadow root: if it
61
+ // rendered there a bot would have to traverse `.shadowRoot` to reach it, and
62
+ // @prosopo/catcher patches that getter to detect (and restart on) automated
63
+ // access — wiping the value the bot just wrote before it can submit.
64
+ //
65
+ // Within light DOM we prefer the enclosing <form> so bots scraping
66
+ // `form.querySelectorAll('input')` discover the bait naturally; document.body
67
+ // is the fallback for widgets mounted outside any form.
68
+ //
69
+ // The decoded question is rendered as the input's <label>, not as its value.
70
+ // Naive form-fillers leave the empty input alone — no signal, no false
71
+ // positives. Agents that read the DOM as a prompt may decode the label and
72
+ // write an answer into the empty field; that response rides up as
73
+ // clientMetaData.hp.
74
+ export const Honeypot = forwardRef<HTMLInputElement, HoneypotProps>(
75
+ ({ encodedQuestion }, ref) => {
76
+ const id = useId();
77
+ // Opaque non-existent form id. Setting `form="..."` on an input with a
78
+ // value that doesn't match any form's id disassociates the input from
79
+ // every form: the browser excludes it from the parent form's submission
80
+ // set and from `form.elements`, while leaving it discoverable via
81
+ // `form.querySelectorAll('input')`. Built from useId so it's unique per
82
+ // Honeypot instance and guaranteed not to collide with the input's own id.
83
+ const detachedFormId = `${id}-d`;
84
+ const question = useMemo(
85
+ () => decodeBase64Utf8(encodedQuestion),
86
+ [encodedQuestion],
87
+ );
88
+ const anchorRef = useRef<HTMLSpanElement>(null);
89
+ const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
90
+
91
+ useEffect(() => {
92
+ const anchor = anchorRef.current;
93
+ if (!anchor) return;
94
+ setPortalTarget(findAncestorForm(anchor) ?? document.body);
95
+ }, []);
96
+
97
+ if (typeof document === "undefined") return null;
98
+
99
+ // Transient anchor while we resolve the portal target. Sits in the React
100
+ // tree (inside the shadow root) just long enough for the effect above to
101
+ // walk out to light DOM and find the form.
102
+ if (!portalTarget) {
103
+ return (
104
+ <span ref={anchorRef} aria-hidden="true" style={{ display: "none" }} />
105
+ );
106
+ }
107
+
108
+ // Input is nested inside the label (implicit association) instead of
109
+ // htmlFor-linked — biome's noLabelWithoutControl rule only recognises
110
+ // the descendant form of association at static-analysis time.
111
+ return createPortal(
112
+ <div aria-hidden="true" style={offscreenStyle}>
113
+ <label>
114
+ {question}
115
+ <input
116
+ ref={ref}
117
+ id={id}
118
+ form={detachedFormId}
119
+ type="text"
120
+ name="email_confirm"
121
+ defaultValue=""
122
+ tabIndex={-1}
123
+ autoComplete="off"
124
+ aria-hidden="true"
125
+ />
126
+ </label>
127
+ </div>,
128
+ portalTarget,
129
+ );
130
+ },
131
+ );
132
+
133
+ Honeypot.displayName = "Honeypot";
@@ -0,0 +1,99 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ import { darkTheme, lightTheme } from "@prosopo/widget-skeleton";
16
+ import { type ButtonHTMLAttributes, type FC, useMemo, useState } from "react";
17
+
18
+ interface ReloadButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
19
+ themeColor: "light" | "dark";
20
+ onReload: () => void;
21
+ }
22
+
23
+ const buttonStyleBase = {
24
+ border: "none",
25
+ paddingTop: "6px",
26
+ paddingBottom: "6px",
27
+ cursor: "pointer",
28
+ height: "39px",
29
+ width: "39px",
30
+ borderRadius: "50%",
31
+ display: "flex",
32
+ };
33
+
34
+ export const ReloadButton: FC<ReloadButtonProps> = ({
35
+ themeColor,
36
+ onReload,
37
+ }: ReloadButtonProps) => {
38
+ const theme = useMemo(
39
+ () => (themeColor === "light" ? lightTheme : darkTheme),
40
+ [themeColor],
41
+ );
42
+ const [hover, setHover] = useState(false);
43
+ const buttonStyle = useMemo(() => {
44
+ const baseStyle = {
45
+ ...buttonStyleBase,
46
+ backgroundColor: theme.palette.background.default,
47
+ color: hover
48
+ ? theme.palette.primary.contrastText
49
+ : theme.palette.background.contrastText,
50
+ border: `1px solid ${theme.palette.grey[500]}`,
51
+ borderRadius: "50%",
52
+ transition: "background-color 0.3s",
53
+ boxShadow: `0px 1px 3px 0px ${theme.palette.grey[500]}`,
54
+ justifyContent: "center",
55
+ alignItems: "center",
56
+ margin: "0 auto",
57
+ };
58
+ return {
59
+ ...baseStyle,
60
+ backgroundColor: hover
61
+ ? theme.palette.grey[700]
62
+ : theme.palette.background.default,
63
+ };
64
+ }, [hover, theme]);
65
+ return (
66
+ <button
67
+ className="reload-button"
68
+ aria-label="Reload"
69
+ type="button"
70
+ style={buttonStyle}
71
+ onMouseEnter={() => setHover(true)}
72
+ onMouseLeave={() => setHover(false)}
73
+ onClick={(e) => {
74
+ e.preventDefault();
75
+ onReload();
76
+ }}
77
+ >
78
+ <svg
79
+ width="16px"
80
+ height="16px"
81
+ viewBox="0 0 16 16"
82
+ version="1.1"
83
+ xmlns="http://www.w3.org/2000/svg"
84
+ xmlnsXlink="http://www.w3.org/1999/xlink"
85
+ style={{ display: "flex" }}
86
+ >
87
+ <title>reload</title>
88
+ <path
89
+ shapeRendering="optimizeQuality"
90
+ fill={
91
+ hover ? theme.palette.primary.contrastText : theme.palette.grey[700]
92
+ }
93
+ transform={"scale(0.0416)"}
94
+ d="M234.666667,149.333333 L234.666667,106.666667 L314.564847,106.664112 C287.579138,67.9778918 242.745446,42.6666667 192,42.6666667 C109.525477,42.6666667 42.6666667,109.525477 42.6666667,192 C42.6666667,274.474523 109.525477,341.333333 192,341.333333 C268.201293,341.333333 331.072074,284.258623 340.195444,210.526102 L382.537159,215.817985 C370.807686,310.617565 289.973536,384 192,384 C85.961328,384 1.42108547e-14,298.038672 1.42108547e-14,192 C1.42108547e-14,85.961328 85.961328,1.42108547e-14 192,1.42108547e-14 C252.316171,1.42108547e-14 306.136355,27.8126321 341.335366,71.3127128 L341.333333,1.42108547e-14 L384,1.42108547e-14 L384,149.333333 L234.666667,149.333333 Z"
95
+ />
96
+ </svg>
97
+ </button>
98
+ );
99
+ };
@@ -0,0 +1,75 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ /** @jsxImportSource @emotion/react */
16
+
17
+ import { TestSiteKeyMode, getTestSiteKeyMode } from "@prosopo/types";
18
+ import { type FC, useEffect } from "react";
19
+
20
+ interface TestModeBannerProps {
21
+ siteKey: string;
22
+ }
23
+
24
+ // Renders a prominent warning when the widget is configured with one of the
25
+ // reserved CI test site keys (always-pass / always-fail). Renders nothing for a
26
+ // normal site key. This is the user-facing safeguard that stops a test key from
27
+ // being shipped to production unnoticed.
28
+ export const TestModeBanner: FC<TestModeBannerProps> = ({
29
+ siteKey,
30
+ }: TestModeBannerProps) => {
31
+ const mode: TestSiteKeyMode | null = getTestSiteKeyMode(siteKey);
32
+
33
+ useEffect(() => {
34
+ if (mode !== null) {
35
+ console.warn(
36
+ `[Procaptcha] WARNING: site key "${siteKey}" is a TEST key that ALWAYS ${
37
+ mode === TestSiteKeyMode.Pass ? "PASSES" : "FAILS"
38
+ }. Never use it in production.`,
39
+ );
40
+ }
41
+ }, [mode, siteKey]);
42
+
43
+ if (mode === null) {
44
+ return null;
45
+ }
46
+
47
+ const action =
48
+ mode === TestSiteKeyMode.Pass ? "ALWAYS PASSES" : "ALWAYS FAILS";
49
+
50
+ return (
51
+ // biome-ignore lint/a11y/useSemanticElements: the "alert" role has no native HTML element equivalent
52
+ <div
53
+ role="alert"
54
+ data-cy="test-mode-banner"
55
+ css={{
56
+ width: "100%",
57
+ boxSizing: "border-box",
58
+ padding: "6px 10px",
59
+ backgroundColor: "#fff3cd",
60
+ color: "#664d03",
61
+ border: "1px solid #ffe69c",
62
+ borderRadius: "4px",
63
+ fontSize: "11px",
64
+ lineHeight: "1.3",
65
+ fontFamily:
66
+ "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
67
+ textAlign: "center",
68
+ }}
69
+ >
70
+ {`⚠ Test mode: this site key ${action}. Do not use in production.`}
71
+ </div>
72
+ );
73
+ };
74
+
75
+ export default TestModeBanner;
@@ -0,0 +1,137 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ import type {
15
+ Account,
16
+ CaptchaResponseBody,
17
+ ProcaptchaApiInterface,
18
+ ProcaptchaState,
19
+ ProcaptchaStateUpdateFn,
20
+ TCaptchaSubmitResult,
21
+ } from "@prosopo/types";
22
+
23
+ type useRefType = <T>(defaultValue: T) => { current: T };
24
+ type useStateType = <T>(defaultValue: T) => [T, (value: T) => void];
25
+
26
+ export const buildUpdateState =
27
+ (state: ProcaptchaState, onStateUpdate: ProcaptchaStateUpdateFn) =>
28
+ (nextState: Partial<ProcaptchaState>) => {
29
+ // mutate the current state. Note that this is in order of properties in the nextState object.
30
+ // e.g. given {b: 2, c: 3, a: 1}, b will be set, then c, then a. This is because JS stores fields in insertion order by default, unless you override it with a class or such by changing the key enumeration order.
31
+ Object.assign(state, nextState);
32
+ // then call the update function for the frontend to do the same
33
+ onStateUpdate(nextState);
34
+ };
35
+
36
+ /**
37
+ * Wrap a ref to be the same format as useState.
38
+ * @param useRef the useRef function from react
39
+ * @param defaultValue the default value if the state is not already initialised
40
+ * @returns a ref in the same format as a state, e.g. [value, setValue]
41
+ */
42
+ const useRefAsState = <T>(
43
+ useRef: useRefType,
44
+ defaultValue: T,
45
+ ): [T, (value: T) => void] => {
46
+ const ref = useRef<T>(defaultValue);
47
+ const setter = (value: T) => {
48
+ ref.current = value;
49
+ };
50
+ const value: T = ref.current;
51
+ return [value, setter];
52
+ };
53
+
54
+ export const useProcaptcha = (
55
+ useState: useStateType,
56
+ useRef: useRefType,
57
+ ): [ProcaptchaState, ProcaptchaStateUpdateFn] => {
58
+ const [isHuman, setIsHuman] = useState(false);
59
+ const [index, setIndex] = useState(0);
60
+ const [solutions, setSolutions] = useState(
61
+ [] as [string, number, number][][],
62
+ );
63
+ const [captchaApi, setCaptchaApi] = useRefAsState<
64
+ ProcaptchaApiInterface | undefined
65
+ >(useRef, undefined);
66
+ const [showModal, setShowModal] = useState(false);
67
+ const [challenge, setChallenge] = useState<CaptchaResponseBody | undefined>(
68
+ undefined,
69
+ );
70
+ const [loading, setLoading] = useState(false);
71
+ const [account, setAccount] = useState<Account | undefined>(undefined);
72
+ const [dappAccount, setDappAccount] = useState<string | undefined>(undefined);
73
+ const [submission, setSubmission] = useRefAsState<
74
+ TCaptchaSubmitResult | undefined
75
+ >(useRef, undefined);
76
+ const [timeout, setTimeout] = useRefAsState<NodeJS.Timeout | undefined>(
77
+ useRef,
78
+ undefined,
79
+ );
80
+ const [successfullChallengeTimeout, setSuccessfullChallengeTimeout] =
81
+ useRefAsState<NodeJS.Timeout | undefined>(useRef, undefined);
82
+ const [sendData, setSendData] = useState(false);
83
+ const [attemptCount, setAttemptCount] = useState(0);
84
+ const [error, setError] = useState<
85
+ { message: string; key: string } | undefined
86
+ >(undefined);
87
+ const [sessionId, setSessionId] = useState<string | undefined>(undefined);
88
+ return [
89
+ // the state
90
+ {
91
+ isHuman,
92
+ index,
93
+ solutions,
94
+ captchaApi,
95
+ showModal,
96
+ challenge,
97
+ loading,
98
+ account,
99
+ dappAccount,
100
+ submission,
101
+ timeout,
102
+ successfullChallengeTimeout,
103
+ sendData,
104
+ attemptCount,
105
+ error,
106
+ sessionId,
107
+ },
108
+ // and method to update the state
109
+ (nextState: Partial<ProcaptchaState>) => {
110
+ if (nextState.account !== undefined) setAccount(nextState.account);
111
+ if (nextState.isHuman !== undefined) setIsHuman(nextState.isHuman);
112
+ if (nextState.index !== undefined) setIndex(nextState.index);
113
+ // force a copy of the array to ensure a re-render
114
+ // nutshell: react doesn't look inside an array for changes, hence changes to the array need to result in a fresh array
115
+ if (nextState.solutions !== undefined)
116
+ setSolutions(nextState.solutions.slice());
117
+ if (nextState.captchaApi !== undefined)
118
+ setCaptchaApi(nextState.captchaApi);
119
+ if (nextState.showModal !== undefined) setShowModal(nextState.showModal);
120
+ if (nextState.challenge !== undefined) setChallenge(nextState.challenge);
121
+ if (nextState.loading !== undefined) setLoading(nextState.loading);
122
+ if (nextState.showModal !== undefined) setShowModal(nextState.showModal);
123
+ if (nextState.dappAccount !== undefined)
124
+ setDappAccount(nextState.dappAccount);
125
+ if (nextState.submission !== undefined)
126
+ setSubmission(nextState.submission);
127
+ if (nextState.timeout !== undefined) setTimeout(nextState.timeout);
128
+ if (nextState.successfullChallengeTimeout !== undefined)
129
+ setSuccessfullChallengeTimeout(nextState.timeout);
130
+ if (nextState.sendData !== undefined) setSendData(nextState.sendData);
131
+ if (nextState.attemptCount !== undefined)
132
+ setAttemptCount(nextState.attemptCount);
133
+ if (nextState.error !== undefined) setError(nextState.error);
134
+ if (nextState.sessionId !== undefined) setSessionId(nextState.sessionId);
135
+ },
136
+ ];
137
+ };