@owlmeans/web-captcha 0.0.0-stage → 0.1.19
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 +77 -2
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/web-captcha/SKILL.md +80 -0
- package/build/consts.d.ts +16 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +16 -0
- package/build/consts.js.map +1 -0
- package/build/errors.d.ts +7 -0
- package/build/errors.d.ts.map +1 -0
- package/build/errors.js +10 -0
- package/build/errors.js.map +1 -0
- package/build/index.d.ts +5 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +4 -0
- package/build/index.js.map +1 -0
- package/build/loader.d.ts +9 -0
- package/build/loader.d.ts.map +1 -0
- package/build/loader.js +105 -0
- package/build/loader.js.map +1 -0
- package/build/react/types.d.ts +22 -0
- package/build/react/types.d.ts.map +1 -0
- package/build/react/types.js +2 -0
- package/build/react/types.js.map +1 -0
- package/build/react/use-captcha.d.ts +8 -0
- package/build/react/use-captcha.d.ts.map +1 -0
- package/build/react/use-captcha.js +56 -0
- package/build/react/use-captcha.js.map +1 -0
- package/build/react.d.ts +3 -0
- package/build/react.d.ts.map +1 -0
- package/build/react.js +2 -0
- package/build/react.js.map +1 -0
- package/build/types.d.ts +51 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/package.json +48 -4
- package/src/consts.ts +20 -0
- package/src/errors.ts +12 -0
- package/src/index.ts +4 -0
- package/src/loader.ts +124 -0
- package/src/react/types.ts +23 -0
- package/src/react/use-captcha.ts +63 -0
- package/src/react.ts +2 -0
- package/src/types.ts +52 -0
- package/tests/context.ts +73 -0
- package/tests/loader.spec.ts +112 -0
- package/tsconfig.json +16 -0
package/README.md
CHANGED
|
@@ -1,3 +1,78 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @owlmeans/web-captcha
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The browser half of OwlMeans captcha: a loader for Google's invisible, score-based reCAPTCHA v3 and
|
|
4
|
+
a React hook over it. It has no `@owlmeans/*` dependencies, so a size-sensitive bundle (an embedded
|
|
5
|
+
widget) can carry it alone. The server half — verifying the token — is `@owlmeans/captcha`.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add @owlmeans/web-captcha@^0.1.19
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Concepts
|
|
14
|
+
|
|
15
|
+
- **Lazy** — nothing touches the page at import. The script `api.js?render=<site key>` is injected
|
|
16
|
+
on the first `prepare` or `token` call, so the module is safe under server rendering and in tests;
|
|
17
|
+
without a page a call fails with `CaptchaLoadError('unavailable')`.
|
|
18
|
+
- **One script per site key** — concurrent and later calls share one load per loader; use the shared
|
|
19
|
+
`captchaLoader` so every form of a page shares it too. A failed load is forgotten, so the next call
|
|
20
|
+
injects the script again.
|
|
21
|
+
- **Badge** — by default the loader adds one document style hiding Google's badge. A page that hides
|
|
22
|
+
it must show Google's notice ("This site is protected by reCAPTCHA and the Google Privacy Policy
|
|
23
|
+
and Terms of Service apply.") next to the form.
|
|
24
|
+
- **Failures** — every failure is a `CaptchaLoadError` whose `reason` is `load` (the script did not
|
|
25
|
+
load), `timeout` (not ready within `loadTimeout`, 15 s), `unavailable` (no page, or the script
|
|
26
|
+
came without its API) or `execute` (no token within `executeTimeout`, 20 s).
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
import { captchaLoader } from '@owlmeans/web-captcha'
|
|
32
|
+
|
|
33
|
+
void captchaLoader.prepare(siteKey) // e.g. when the form opens
|
|
34
|
+
const token = await captchaLoader.token(siteKey, 'email_pin') // per submit; send it to the server
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
import { useCaptcha } from '@owlmeans/web-captcha/react'
|
|
39
|
+
|
|
40
|
+
const { ready, error, execute } = useCaptcha({ siteKey: options.captcha?.siteKey ?? null, action: 'email_pin' })
|
|
41
|
+
|
|
42
|
+
const submit = async () => {
|
|
43
|
+
const captcha = await execute() // null while the captcha is off
|
|
44
|
+
await api.sendPin({ email, ...(captcha != null ? { captcha } : {}) })
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## API
|
|
49
|
+
|
|
50
|
+
| Export | Description |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `createCaptchaLoader(options?)` | A `CaptchaLoader`: `prepare(siteKey)`, `token(siteKey, action)` |
|
|
53
|
+
| `captchaLoader` | The shared ready instance |
|
|
54
|
+
| `CaptchaLoadError` | `Error` with `reason: 'load' \| 'timeout' \| 'execute' \| 'unavailable'` |
|
|
55
|
+
| `RECAPTCHA_SCRIPT_URL`, `CAPTCHA_LOAD_TIMEOUT`, `CAPTCHA_EXECUTE_TIMEOUT` | Google's script, `15_000`, `20_000` |
|
|
56
|
+
| `CAPTCHA_SCRIPT_ATTRIBUTE`, `CAPTCHA_STYLE_ATTRIBUTE`, `CAPTCHA_BADGE_CSS` | What the loader marks and injects |
|
|
57
|
+
| `CaptchaLoader`, `CaptchaLoaderOptions`, `CaptchaLoadErrorReason`, `Grecaptcha`, `CaptchaWindow`, `CaptchaDocument` | Types |
|
|
58
|
+
| `useCaptcha({ siteKey, action, loader? })` (`./react`) | `{ ready, error, execute }`; `siteKey` `null` = off: `ready` true, `execute()` resolves `null` |
|
|
59
|
+
| `UseCaptchaOptions`, `UseCaptchaResult` (`./react`) | The hook's types |
|
|
60
|
+
|
|
61
|
+
`CaptchaLoaderOptions`: `hideBadge` (default `true`), `loadTimeout`, `executeTimeout`, `scriptUrl`,
|
|
62
|
+
and `document` / `window` to load into another page (or a test's stand-in) instead of the globals.
|
|
63
|
+
|
|
64
|
+
<!-- owlmeans:agent-guidance:start -->
|
|
65
|
+
## Agent guidance
|
|
66
|
+
|
|
67
|
+
This package ships embedded agent skills under `agent-meta/`. After installing your
|
|
68
|
+
`@owlmeans/*` packages, run the OwlMeans agent-skills installer to place them into
|
|
69
|
+
your project's skill store (`.agents/skills/`):
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
npx @owlmeans/agent-skills@^0.1.19
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The embedded files are version-matched to this package release. Do not edit them
|
|
76
|
+
directly — they are regenerated on each publish. To contribute guidance edits,
|
|
77
|
+
open a PR against the source monorepo.
|
|
78
|
+
<!-- owlmeans:agent-guidance:end -->
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 3,
|
|
3
|
+
"package": "@owlmeans/web-captcha",
|
|
4
|
+
"version": "0.1.19",
|
|
5
|
+
"generatedAt": "2026-11-01T22:22:22.310Z",
|
|
6
|
+
"canonicalRepo": "https://github.com/owlmeans/common",
|
|
7
|
+
"entries": [
|
|
8
|
+
{
|
|
9
|
+
"kind": "skill",
|
|
10
|
+
"name": "web-captcha",
|
|
11
|
+
"category": "package-specific",
|
|
12
|
+
"file": "skills/web-captcha/SKILL.md",
|
|
13
|
+
"canonicalPath": ".agents/skills/web-captcha/SKILL.md"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: web-captcha
|
|
3
|
+
description: How to use @owlmeans/web-captcha — the browser half of OwlMeans captcha, with no @owlmeans dependencies. The lazy reCAPTCHA v3 loader (createCaptchaLoader, the shared captchaLoader — one script per site key, the badge hidden, a failed load forgotten), CaptchaLoadError and its four reasons, and the useCaptcha React hook (./react) that answers null while the captcha is off. Auto-invoked when a form or an embedded widget needs a reCAPTCHA v3 token, when importing captchaLoader or useCaptcha, or when phrasing a captcha load failure.
|
|
4
|
+
user-invocable: false
|
|
5
|
+
---
|
|
6
|
+
<!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
|
|
7
|
+
|
|
8
|
+
# @owlmeans/web-captcha
|
|
9
|
+
|
|
10
|
+
**Layer:** Web (L0 — no `@owlmeans/*` dependencies; React an optional peer, used only by `./react`)
|
|
11
|
+
**Install:** `"@owlmeans/web-captcha": "^0.1.19"` in `dependencies`
|
|
12
|
+
**Server half:** `@owlmeans/captcha` (`makeCaptchaCheck` verifies what this produces)
|
|
13
|
+
|
|
14
|
+
Zero framework dependencies is deliberate: a size-sensitive bundle — an embedded IIFE widget that
|
|
15
|
+
must not pull ajv in through an `@owlmeans` package index — can carry it alone.
|
|
16
|
+
|
|
17
|
+
## Key Exports
|
|
18
|
+
|
|
19
|
+
| Export | Description |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `createCaptchaLoader(options?)` | A `CaptchaLoader`: `prepare(siteKey)`, `token(siteKey, action)` |
|
|
22
|
+
| `captchaLoader` | The shared ready instance — use it so every form of a page shares one script |
|
|
23
|
+
| `CaptchaLoadError` | `Error` with `reason: 'load' \| 'timeout' \| 'execute' \| 'unavailable'` |
|
|
24
|
+
| `RECAPTCHA_SCRIPT_URL`, `CAPTCHA_LOAD_TIMEOUT`, `CAPTCHA_EXECUTE_TIMEOUT` | Google's `api.js`, `15_000`, `20_000` |
|
|
25
|
+
| `CAPTCHA_SCRIPT_ATTRIBUTE`, `CAPTCHA_STYLE_ATTRIBUTE`, `CAPTCHA_BADGE_CSS` | `data-owlmeans-captcha`, `data-owlmeans-captcha-style`, `.grecaptcha-badge{visibility:hidden}` |
|
|
26
|
+
| `useCaptcha({ siteKey, action, loader? })` (`./react`) | `{ ready, error, execute }` |
|
|
27
|
+
| `CaptchaLoaderOptions` | `hideBadge` (default `true`), `loadTimeout`, `executeTimeout`, `scriptUrl`, `document` / `window` (load into another page or a test stand-in) |
|
|
28
|
+
|
|
29
|
+
## Behaviour
|
|
30
|
+
|
|
31
|
+
- **Lazy.** Nothing touches the page at import: `api.js?render=<site key>` is injected on the first
|
|
32
|
+
`prepare` or `token`. Safe under server rendering and in tests; without a page a call fails with
|
|
33
|
+
`CaptchaLoadError('unavailable')`.
|
|
34
|
+
- **One script per site key per loader**; concurrent calls share the load. A failed load is
|
|
35
|
+
forgotten, so the next call injects the script again.
|
|
36
|
+
- **Badge.** The loader adds one document rule hiding Google's badge. A page that hides it must show
|
|
37
|
+
Google's notice next to the form ("This site is protected by reCAPTCHA and the Google Privacy
|
|
38
|
+
Policy and Terms of Service apply.", both linked).
|
|
39
|
+
- **Failures** — `load` (script did not load), `timeout` (not ready within 15 s), `unavailable` (no
|
|
40
|
+
page, or the script came without its API), `execute` (no token within 20 s). A caller maps the
|
|
41
|
+
reason to its own sentence: `load`/`timeout`/`unavailable` read as "blocked" (an extension or a
|
|
42
|
+
network blocks Google), `execute` as "verification failed".
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
import { captchaLoader } from '@owlmeans/web-captcha'
|
|
48
|
+
|
|
49
|
+
void captchaLoader.prepare(siteKey) // when the form opens
|
|
50
|
+
const token = await captchaLoader.token(siteKey, 'inquiry') // per submit — never reuse a token
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
import { useCaptcha } from '@owlmeans/web-captcha/react'
|
|
55
|
+
|
|
56
|
+
const { ready, error, execute } = useCaptcha({ siteKey: options.captcha?.siteKey ?? null, action: 'email_pin' })
|
|
57
|
+
|
|
58
|
+
const submit = async () => {
|
|
59
|
+
const captcha = await execute() // null while the captcha is off; throws CaptchaLoadError
|
|
60
|
+
await api.sendPin({ email, ...(captcha != null ? { captcha } : {}) })
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Rules
|
|
65
|
+
|
|
66
|
+
- `siteKey` `null` or `''` means OFF: `ready` is true at once and `execute()` resolves `null` — send
|
|
67
|
+
no captcha field at all rather than an empty one.
|
|
68
|
+
- Fetch a fresh token for every protected request: a token is verifiable once, within two minutes,
|
|
69
|
+
and is bound to the action it was executed for — the server's strict check compares it exactly.
|
|
70
|
+
- Load the script on the user's first interaction with the form (`prepare` on open), not with the
|
|
71
|
+
page: Google's script is a third-party request the cookie notice may not yet cover.
|
|
72
|
+
|
|
73
|
+
## External docs
|
|
74
|
+
|
|
75
|
+
- https://developers.google.com/recaptcha/docs/v3 — `api.js?render=<site key>`, `grecaptcha.ready` then `execute(siteKey, { action })` per protected action
|
|
76
|
+
|
|
77
|
+
## Related
|
|
78
|
+
|
|
79
|
+
`captcha` (the server half) · `web-auth-email` (the e-mail card's send-PIN and password sign-in) ·
|
|
80
|
+
`common-inquiry` / `web-inquiry` (the inquiry widget a CRM serves loads reCAPTCHA through this package)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Google's reCAPTCHA v3 script; `?render=<site key>` selects the invisible, score-based widget. */
|
|
2
|
+
export declare const RECAPTCHA_SCRIPT_URL = "https://www.google.com/recaptcha/api.js";
|
|
3
|
+
/** Marks the script the loader injected; its value is the site key. */
|
|
4
|
+
export declare const CAPTCHA_SCRIPT_ATTRIBUTE = "data-owlmeans-captcha";
|
|
5
|
+
/** Marks the document style that hides Google's badge. */
|
|
6
|
+
export declare const CAPTCHA_STYLE_ATTRIBUTE = "data-owlmeans-captcha-style";
|
|
7
|
+
/**
|
|
8
|
+
* The one document-level rule the loader adds unless told not to: Google's badge hidden. A page
|
|
9
|
+
* that hides it must show Google's notice ("This site is protected by reCAPTCHA…") itself.
|
|
10
|
+
*/
|
|
11
|
+
export declare const CAPTCHA_BADGE_CSS = ".grecaptcha-badge{visibility:hidden}";
|
|
12
|
+
/** How long the script may take to become ready, in milliseconds. */
|
|
13
|
+
export declare const CAPTCHA_LOAD_TIMEOUT = 15000;
|
|
14
|
+
/** How long one `execute` may take, in milliseconds. */
|
|
15
|
+
export declare const CAPTCHA_EXECUTE_TIMEOUT = 20000;
|
|
16
|
+
//# sourceMappingURL=consts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,oGAAoG;AACpG,eAAO,MAAM,oBAAoB,4CAA4C,CAAA;AAE7E,uEAAuE;AACvE,eAAO,MAAM,wBAAwB,0BAA0B,CAAA;AAE/D,0DAA0D;AAC1D,eAAO,MAAM,uBAAuB,gCAAgC,CAAA;AAEpE;;;GAGG;AACH,eAAO,MAAM,iBAAiB,yCAAyC,CAAA;AAEvE,qEAAqE;AACrE,eAAO,MAAM,oBAAoB,QAAS,CAAA;AAE1C,wDAAwD;AACxD,eAAO,MAAM,uBAAuB,QAAS,CAAA"}
|
package/build/consts.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Google's reCAPTCHA v3 script; `?render=<site key>` selects the invisible, score-based widget. */
|
|
2
|
+
export const RECAPTCHA_SCRIPT_URL = 'https://www.google.com/recaptcha/api.js';
|
|
3
|
+
/** Marks the script the loader injected; its value is the site key. */
|
|
4
|
+
export const CAPTCHA_SCRIPT_ATTRIBUTE = 'data-owlmeans-captcha';
|
|
5
|
+
/** Marks the document style that hides Google's badge. */
|
|
6
|
+
export const CAPTCHA_STYLE_ATTRIBUTE = 'data-owlmeans-captcha-style';
|
|
7
|
+
/**
|
|
8
|
+
* The one document-level rule the loader adds unless told not to: Google's badge hidden. A page
|
|
9
|
+
* that hides it must show Google's notice ("This site is protected by reCAPTCHA…") itself.
|
|
10
|
+
*/
|
|
11
|
+
export const CAPTCHA_BADGE_CSS = '.grecaptcha-badge{visibility:hidden}';
|
|
12
|
+
/** How long the script may take to become ready, in milliseconds. */
|
|
13
|
+
export const CAPTCHA_LOAD_TIMEOUT = 15_000;
|
|
14
|
+
/** How long one `execute` may take, in milliseconds. */
|
|
15
|
+
export const CAPTCHA_EXECUTE_TIMEOUT = 20_000;
|
|
16
|
+
//# sourceMappingURL=consts.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,oGAAoG;AACpG,MAAM,CAAC,MAAM,oBAAoB,GAAG,yCAAyC,CAAA;AAE7E,uEAAuE;AACvE,MAAM,CAAC,MAAM,wBAAwB,GAAG,uBAAuB,CAAA;AAE/D,0DAA0D;AAC1D,MAAM,CAAC,MAAM,uBAAuB,GAAG,6BAA6B,CAAA;AAEpE;;;GAGG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,sCAAsC,CAAA;AAEvE,qEAAqE;AACrE,MAAM,CAAC,MAAM,oBAAoB,GAAG,MAAM,CAAA;AAE1C,wDAAwD;AACxD,MAAM,CAAC,MAAM,uBAAuB,GAAG,MAAM,CAAA"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CaptchaLoadErrorReason } from './types.js';
|
|
2
|
+
/** The captcha could not give a token; `reason` says which step failed. */
|
|
3
|
+
export declare class CaptchaLoadError extends Error {
|
|
4
|
+
readonly reason: CaptchaLoadErrorReason;
|
|
5
|
+
constructor(reason: CaptchaLoadErrorReason, message?: string, options?: ErrorOptions);
|
|
6
|
+
}
|
|
7
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAA;AAExD,2EAA2E;AAC3E,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,SAAgB,MAAM,EAAE,sBAAsB,CAAA;IAE9C,YAAY,MAAM,EAAE,sBAAsB,EAAE,OAAO,GAAE,MAA4B,EAAE,OAAO,CAAC,EAAE,YAAY,EAIxG;CACF"}
|
package/build/errors.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** The captcha could not give a token; `reason` says which step failed. */
|
|
2
|
+
export class CaptchaLoadError extends Error {
|
|
3
|
+
reason;
|
|
4
|
+
constructor(reason, message = `captcha:${reason}`, options) {
|
|
5
|
+
super(message, options);
|
|
6
|
+
this.name = 'CaptchaLoadError';
|
|
7
|
+
this.reason = reason;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAEA,2EAA2E;AAC3E,MAAM,OAAO,gBAAiB,SAAQ,KAAK;IACzB,MAAM,CAAwB;IAE9C,YAAY,MAA8B,EAAE,OAAO,GAAW,WAAW,MAAM,EAAE,EAAE,OAAsB;QACvG,KAAK,CAAC,OAAO,EAAE,OAAO,CAAC,CAAA;QACvB,IAAI,CAAC,IAAI,GAAG,kBAAkB,CAAA;QAC9B,IAAI,CAAC,MAAM,GAAG,MAAM,CAAA;IACtB,CAAC;CACF"}
|
package/build/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,mBAAmB,YAAY,CAAA;AAC/B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA"}
|
package/build/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CaptchaLoader, CaptchaLoaderOptions } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Google's reCAPTCHA v3 loader. Nothing touches the page until `prepare` or `token` is called, so
|
|
4
|
+
* the module is safe to import during server rendering and in tests.
|
|
5
|
+
*/
|
|
6
|
+
export declare const createCaptchaLoader: (options?: CaptchaLoaderOptions) => CaptchaLoader;
|
|
7
|
+
/** The shared loader of a page: one script per site key across every caller. */
|
|
8
|
+
export declare const captchaLoader: CaptchaLoader;
|
|
9
|
+
//# sourceMappingURL=loader.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../src/loader.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAC+B,aAAa,EAAE,oBAAoB,EAC7E,MAAM,YAAY,CAAA;AAEnB;;;GAGG;AACH,eAAO,MAAM,mBAAmB,aAAa,oBAAoB,KAAQ,aA2GxE,CAAA;AAED,gFAAgF;AAChF,eAAO,MAAM,aAAa,eAAwB,CAAA"}
|
package/build/loader.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { CAPTCHA_BADGE_CSS, CAPTCHA_EXECUTE_TIMEOUT, CAPTCHA_LOAD_TIMEOUT, CAPTCHA_SCRIPT_ATTRIBUTE, CAPTCHA_STYLE_ATTRIBUTE, RECAPTCHA_SCRIPT_URL, } from './consts.js';
|
|
2
|
+
import { CaptchaLoadError } from './errors.js';
|
|
3
|
+
/**
|
|
4
|
+
* Google's reCAPTCHA v3 loader. Nothing touches the page until `prepare` or `token` is called, so
|
|
5
|
+
* the module is safe to import during server rendering and in tests.
|
|
6
|
+
*/
|
|
7
|
+
export const createCaptchaLoader = (options = {}) => {
|
|
8
|
+
const hideBadge = options.hideBadge ?? true;
|
|
9
|
+
const loadTimeout = options.loadTimeout ?? CAPTCHA_LOAD_TIMEOUT;
|
|
10
|
+
const executeTimeout = options.executeTimeout ?? CAPTCHA_EXECUTE_TIMEOUT;
|
|
11
|
+
const scriptUrl = options.scriptUrl ?? RECAPTCHA_SCRIPT_URL;
|
|
12
|
+
const loads = new Map();
|
|
13
|
+
const documentOf = () => options.document ?? (typeof document !== 'undefined' ? document : null);
|
|
14
|
+
const windowOf = () => options.window ?? (typeof window !== 'undefined' ? window : null);
|
|
15
|
+
const addBadgeStyle = (page) => {
|
|
16
|
+
if (page.querySelector(`style[${CAPTCHA_STYLE_ATTRIBUTE}]`) != null) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const style = page.createElement('style');
|
|
20
|
+
style.setAttribute(CAPTCHA_STYLE_ATTRIBUTE, '');
|
|
21
|
+
style.textContent = CAPTCHA_BADGE_CSS;
|
|
22
|
+
page.head.appendChild(style);
|
|
23
|
+
};
|
|
24
|
+
const inject = (siteKey) => new Promise((resolve, reject) => {
|
|
25
|
+
const page = documentOf();
|
|
26
|
+
const host = windowOf();
|
|
27
|
+
if (page == null || host == null) {
|
|
28
|
+
reject(new CaptchaLoadError('unavailable', 'reCAPTCHA needs a page to load into'));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const script = page.createElement('script');
|
|
32
|
+
let settled = false;
|
|
33
|
+
let timer;
|
|
34
|
+
const fail = (reason, message) => {
|
|
35
|
+
if (!settled) {
|
|
36
|
+
settled = true;
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
script.remove();
|
|
39
|
+
reject(new CaptchaLoadError(reason, message));
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
timer = setTimeout(() => { fail('timeout', 'reCAPTCHA did not become ready in time'); }, loadTimeout);
|
|
43
|
+
script.addEventListener('error', () => { fail('load', 'reCAPTCHA failed to load'); });
|
|
44
|
+
script.addEventListener('load', () => {
|
|
45
|
+
const grecaptcha = host.grecaptcha;
|
|
46
|
+
if (grecaptcha == null || typeof grecaptcha.ready !== 'function') {
|
|
47
|
+
fail('unavailable', 'reCAPTCHA loaded without its API');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
grecaptcha.ready(() => {
|
|
51
|
+
if (!settled) {
|
|
52
|
+
settled = true;
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
resolve(host.grecaptcha ?? grecaptcha);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
script.src = `${scriptUrl}${scriptUrl.includes('?') ? '&' : '?'}render=${encodeURIComponent(siteKey)}`;
|
|
59
|
+
script.async = true;
|
|
60
|
+
script.setAttribute(CAPTCHA_SCRIPT_ATTRIBUTE, siteKey);
|
|
61
|
+
if (hideBadge) {
|
|
62
|
+
addBadgeStyle(page);
|
|
63
|
+
}
|
|
64
|
+
page.head.appendChild(script);
|
|
65
|
+
});
|
|
66
|
+
const readyOf = (siteKey) => {
|
|
67
|
+
const known = loads.get(siteKey);
|
|
68
|
+
if (known != null) {
|
|
69
|
+
return known;
|
|
70
|
+
}
|
|
71
|
+
const loading = inject(siteKey);
|
|
72
|
+
loads.set(siteKey, loading);
|
|
73
|
+
loading.catch(() => { loads.delete(siteKey); });
|
|
74
|
+
return loading;
|
|
75
|
+
};
|
|
76
|
+
const prepare = async (siteKey) => {
|
|
77
|
+
await readyOf(siteKey);
|
|
78
|
+
};
|
|
79
|
+
const token = async (siteKey, action) => {
|
|
80
|
+
const grecaptcha = await readyOf(siteKey);
|
|
81
|
+
let timer;
|
|
82
|
+
try {
|
|
83
|
+
const value = await Promise.race([
|
|
84
|
+
Promise.resolve(grecaptcha.execute(siteKey, { action })),
|
|
85
|
+
new Promise((_, reject) => {
|
|
86
|
+
timer = setTimeout(() => { reject(new Error('reCAPTCHA execute timed out')); }, executeTimeout);
|
|
87
|
+
}),
|
|
88
|
+
]);
|
|
89
|
+
if (typeof value !== 'string' || value === '') {
|
|
90
|
+
throw new Error('reCAPTCHA gave no token');
|
|
91
|
+
}
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
throw new CaptchaLoadError('execute', `reCAPTCHA refused: ${String(error)}`, { cause: error });
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
return { prepare, token };
|
|
102
|
+
};
|
|
103
|
+
/** The shared loader of a page: one script per site key across every caller. */
|
|
104
|
+
export const captchaLoader = createCaptchaLoader();
|
|
105
|
+
//# sourceMappingURL=loader.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"loader.js","sourceRoot":"","sources":["../src/loader.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { CaptchaLoader } from '../types.js';
|
|
2
|
+
export interface UseCaptchaOptions {
|
|
3
|
+
/** The site key the server advertised; `null` (or `''`) = captcha off. */
|
|
4
|
+
siteKey: string | null;
|
|
5
|
+
/** The exact action the server checks this form's token for. */
|
|
6
|
+
action: string;
|
|
7
|
+
/** Default: the shared `captchaLoader`. */
|
|
8
|
+
loader?: CaptchaLoader;
|
|
9
|
+
}
|
|
10
|
+
export interface UseCaptchaResult {
|
|
11
|
+
/** The script is ready for the site key — or the captcha is off. */
|
|
12
|
+
ready: boolean;
|
|
13
|
+
/** The last load or execute failure, cleared by the next success. */
|
|
14
|
+
error: Error | null;
|
|
15
|
+
/**
|
|
16
|
+
* A fresh token for the action, or `null` while the captcha is off.
|
|
17
|
+
*
|
|
18
|
+
* @throws {CaptchaLoadError} when the script cannot load or Google gives no token.
|
|
19
|
+
*/
|
|
20
|
+
execute: () => Promise<string | null>;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/react/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAEhD,MAAM,WAAW,iBAAiB;IAChC,0EAA0E;IAC1E,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,gEAAgE;IAChE,MAAM,EAAE,MAAM,CAAA;IACd,2CAA2C;IAC3C,MAAM,CAAC,EAAE,aAAa,CAAA;CACvB;AAED,MAAM,WAAW,gBAAgB;IAC/B,oEAAoE;IACpE,KAAK,EAAE,OAAO,CAAA;IACd,qEAAqE;IACrE,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;IACnB;;;;OAIG;IACH,OAAO,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;CACtC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/react/types.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { UseCaptchaOptions, UseCaptchaResult } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* reCAPTCHA v3 for one form: starts loading the script as soon as a site key is known and gives a
|
|
4
|
+
* fresh token per submit. With no site key the captcha is off — `ready` is true and `execute`
|
|
5
|
+
* resolves `null`, so the form sends no captcha at all.
|
|
6
|
+
*/
|
|
7
|
+
export declare const useCaptcha: ({ siteKey, action, loader }: UseCaptchaOptions) => UseCaptchaResult;
|
|
8
|
+
//# sourceMappingURL=use-captcha.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-captcha.d.ts","sourceRoot":"","sources":["../../src/react/use-captcha.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AAKrE;;;;GAIG;AACH,eAAO,MAAM,UAAU,gCAAiD,iBAAiB,KAAG,gBAgD3F,CAAA"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { CaptchaLoadError } from '../errors.js';
|
|
3
|
+
import { captchaLoader } from '../loader.js';
|
|
4
|
+
const errorOf = (failure, reason) => failure instanceof Error ? failure : new CaptchaLoadError(reason, String(failure));
|
|
5
|
+
/**
|
|
6
|
+
* reCAPTCHA v3 for one form: starts loading the script as soon as a site key is known and gives a
|
|
7
|
+
* fresh token per submit. With no site key the captcha is off — `ready` is true and `execute`
|
|
8
|
+
* resolves `null`, so the form sends no captcha at all.
|
|
9
|
+
*/
|
|
10
|
+
export const useCaptcha = ({ siteKey, action, loader = captchaLoader }) => {
|
|
11
|
+
const key = siteKey != null && siteKey !== '' ? siteKey : null;
|
|
12
|
+
const [ready, setReady] = useState(key == null);
|
|
13
|
+
const [error, setError] = useState(null);
|
|
14
|
+
const mounted = useRef(true);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
mounted.current = true;
|
|
17
|
+
return () => { mounted.current = false; };
|
|
18
|
+
}, []);
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
setError(null);
|
|
21
|
+
if (key == null) {
|
|
22
|
+
setReady(true);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
let current = true;
|
|
26
|
+
setReady(false);
|
|
27
|
+
loader.prepare(key).then(() => { if (current) {
|
|
28
|
+
setReady(true);
|
|
29
|
+
} }, (failure) => { if (current) {
|
|
30
|
+
setError(errorOf(failure, 'load'));
|
|
31
|
+
} });
|
|
32
|
+
return () => { current = false; };
|
|
33
|
+
}, [key, loader]);
|
|
34
|
+
const execute = useCallback(async () => {
|
|
35
|
+
if (key == null) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const token = await loader.token(key, action);
|
|
40
|
+
if (mounted.current) {
|
|
41
|
+
setReady(true);
|
|
42
|
+
setError(null);
|
|
43
|
+
}
|
|
44
|
+
return token;
|
|
45
|
+
}
|
|
46
|
+
catch (failure) {
|
|
47
|
+
const reported = errorOf(failure, 'execute');
|
|
48
|
+
if (mounted.current) {
|
|
49
|
+
setError(reported);
|
|
50
|
+
}
|
|
51
|
+
throw reported;
|
|
52
|
+
}
|
|
53
|
+
}, [key, action, loader]);
|
|
54
|
+
return { ready, error, execute };
|
|
55
|
+
};
|
|
56
|
+
//# sourceMappingURL=use-captcha.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-captcha.js","sourceRoot":"","sources":["../../src/react/use-captcha.ts"],"names":[],"mappings":"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"}
|
package/build/react.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AAAA,mBAAmB,kBAAkB,CAAA;AACrC,cAAc,wBAAwB,CAAA"}
|
package/build/react.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"react.js","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AACA,cAAc,wBAAwB,CAAA"}
|
package/build/types.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** The part of Google's `grecaptcha` (v3, `render=<site key>`) the loader uses. */
|
|
2
|
+
export interface Grecaptcha {
|
|
3
|
+
ready: (callback: () => void) => void;
|
|
4
|
+
execute: (siteKey: string, options: {
|
|
5
|
+
action: string;
|
|
6
|
+
}) => PromiseLike<string>;
|
|
7
|
+
}
|
|
8
|
+
/** The window as the loader reads it: only Google's global. */
|
|
9
|
+
export interface CaptchaWindow {
|
|
10
|
+
grecaptcha?: Grecaptcha;
|
|
11
|
+
}
|
|
12
|
+
/** The document as the loader uses it: one script and one style appended to its head. */
|
|
13
|
+
export interface CaptchaDocument extends Pick<Document, 'createElement' | 'querySelector' | 'head'> {
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Why a captcha could not give a token: the script failed to `load`, did not become ready in time
|
|
17
|
+
* (`timeout`), there is no page or no API to use (`unavailable`), or Google gave no token (`execute`).
|
|
18
|
+
*/
|
|
19
|
+
export type CaptchaLoadErrorReason = 'load' | 'timeout' | 'execute' | 'unavailable';
|
|
20
|
+
export interface CaptchaLoaderOptions {
|
|
21
|
+
/** Add the document style that hides Google's badge. Default `true`. */
|
|
22
|
+
hideBadge?: boolean;
|
|
23
|
+
/** Milliseconds the script may take to become ready. Default `CAPTCHA_LOAD_TIMEOUT`. */
|
|
24
|
+
loadTimeout?: number;
|
|
25
|
+
/** Milliseconds one `execute` may take. Default `CAPTCHA_EXECUTE_TIMEOUT`. */
|
|
26
|
+
executeTimeout?: number;
|
|
27
|
+
/** The script address `?render=<site key>` is added to. Default `RECAPTCHA_SCRIPT_URL`. */
|
|
28
|
+
scriptUrl?: string;
|
|
29
|
+
/** The page to load into. Default: the global `document`, read at call time. */
|
|
30
|
+
document?: CaptchaDocument;
|
|
31
|
+
/** Where Google's global appears. Default: the global `window`, read at call time. */
|
|
32
|
+
window?: CaptchaWindow;
|
|
33
|
+
}
|
|
34
|
+
/** Google's reCAPTCHA v3, loaded on first use — never at import. */
|
|
35
|
+
export interface CaptchaLoader {
|
|
36
|
+
/**
|
|
37
|
+
* Inject `api.js?render=<siteKey>` once per key (and, unless disabled, the document style that
|
|
38
|
+
* hides Google's badge); resolves once `grecaptcha` is ready. A failure is forgotten, so the next
|
|
39
|
+
* call injects again.
|
|
40
|
+
*
|
|
41
|
+
* @throws {CaptchaLoadError} `load`, `timeout` or `unavailable`.
|
|
42
|
+
*/
|
|
43
|
+
prepare: (siteKey: string) => Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* A fresh token for `action`, loading the script first when needed.
|
|
46
|
+
*
|
|
47
|
+
* @throws {CaptchaLoadError} what `prepare` throws, or `execute` when Google gives no token in time.
|
|
48
|
+
*/
|
|
49
|
+
token: (siteKey: string, action: string) => Promise<string>;
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,mFAAmF;AACnF,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,IAAI,KAAK,IAAI,CAAA;IACrC,OAAO,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,KAAK,WAAW,CAAC,MAAM,CAAC,CAAA;CAC/E;AAED,+DAA+D;AAC/D,MAAM,WAAW,aAAa;IAC5B,UAAU,CAAC,EAAE,UAAU,CAAA;CACxB;AAED,yFAAyF;AACzF,MAAM,WAAW,eAAgB,SAAQ,IAAI,CAAC,QAAQ,EAAE,eAAe,GAAG,eAAe,GAAG,MAAM,CAAC;CAAG;AAEtG;;;GAGG;AACH,MAAM,MAAM,sBAAsB,GAAG,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,aAAa,CAAA;AAEnF,MAAM,WAAW,oBAAoB;IACnC,wEAAwE;IACxE,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,wFAAwF;IACxF,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,8EAA8E;IAC9E,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,2FAA2F;IAC3F,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,gFAAgF;IAChF,QAAQ,CAAC,EAAE,eAAe,CAAA;IAC1B,sFAAsF;IACtF,MAAM,CAAC,EAAE,aAAa,CAAA;CACvB;AAED,oEAAoE;AACpE,MAAM,WAAW,aAAa;IAC5B;;;;;;OAMG;IACH,OAAO,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAC3C;;;;OAIG;IACH,KAAK,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;CAC5D"}
|
package/build/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owlmeans/web-captcha",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.19",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Browser half of OwlMeans captcha — the invisible score-based reCAPTCHA v3 loader and a React hook; no OwlMeans dependencies.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc -b",
|
|
9
|
+
"dev": "sleep 2 && nodemon -e ts,tsx,json --watch src --exec \"tsc -p ./tsconfig.json\"",
|
|
10
|
+
"watch": "tsc -b -w --preserveWatchOutput --pretty",
|
|
11
|
+
"test": "bun test ./tests"
|
|
12
|
+
},
|
|
13
|
+
"main": "build/index.js",
|
|
14
|
+
"module": "build/index.js",
|
|
15
|
+
"types": "build/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"import": "./build/index.js",
|
|
19
|
+
"require": "./build/index.js",
|
|
20
|
+
"default": "./build/index.js",
|
|
21
|
+
"module": "./build/index.js",
|
|
22
|
+
"types": "./build/index.d.ts"
|
|
23
|
+
},
|
|
24
|
+
"./react": {
|
|
25
|
+
"import": "./build/react.js",
|
|
26
|
+
"require": "./build/react.js",
|
|
27
|
+
"default": "./build/react.js",
|
|
28
|
+
"module": "./build/react.js",
|
|
29
|
+
"types": "./build/react.d.ts"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"react": "*"
|
|
34
|
+
},
|
|
35
|
+
"peerDependenciesMeta": {
|
|
36
|
+
"react": {
|
|
37
|
+
"optional": true
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@owlmeans/dep-config": "^0.1.19",
|
|
42
|
+
"@types/bun": "^1.4.2",
|
|
43
|
+
"@types/react": "^19.3.0",
|
|
44
|
+
"nodemon": "^3.1.14",
|
|
45
|
+
"typescript": "^7.0.2"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/consts.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Google's reCAPTCHA v3 script; `?render=<site key>` selects the invisible, score-based widget. */
|
|
2
|
+
export const RECAPTCHA_SCRIPT_URL = 'https://www.google.com/recaptcha/api.js'
|
|
3
|
+
|
|
4
|
+
/** Marks the script the loader injected; its value is the site key. */
|
|
5
|
+
export const CAPTCHA_SCRIPT_ATTRIBUTE = 'data-owlmeans-captcha'
|
|
6
|
+
|
|
7
|
+
/** Marks the document style that hides Google's badge. */
|
|
8
|
+
export const CAPTCHA_STYLE_ATTRIBUTE = 'data-owlmeans-captcha-style'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The one document-level rule the loader adds unless told not to: Google's badge hidden. A page
|
|
12
|
+
* that hides it must show Google's notice ("This site is protected by reCAPTCHA…") itself.
|
|
13
|
+
*/
|
|
14
|
+
export const CAPTCHA_BADGE_CSS = '.grecaptcha-badge{visibility:hidden}'
|
|
15
|
+
|
|
16
|
+
/** How long the script may take to become ready, in milliseconds. */
|
|
17
|
+
export const CAPTCHA_LOAD_TIMEOUT = 15_000
|
|
18
|
+
|
|
19
|
+
/** How long one `execute` may take, in milliseconds. */
|
|
20
|
+
export const CAPTCHA_EXECUTE_TIMEOUT = 20_000
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CaptchaLoadErrorReason } from './types.js'
|
|
2
|
+
|
|
3
|
+
/** The captcha could not give a token; `reason` says which step failed. */
|
|
4
|
+
export class CaptchaLoadError extends Error {
|
|
5
|
+
public readonly reason: CaptchaLoadErrorReason
|
|
6
|
+
|
|
7
|
+
constructor(reason: CaptchaLoadErrorReason, message: string = `captcha:${reason}`, options?: ErrorOptions) {
|
|
8
|
+
super(message, options)
|
|
9
|
+
this.name = 'CaptchaLoadError'
|
|
10
|
+
this.reason = reason
|
|
11
|
+
}
|
|
12
|
+
}
|
package/src/index.ts
ADDED
package/src/loader.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CAPTCHA_BADGE_CSS, CAPTCHA_EXECUTE_TIMEOUT, CAPTCHA_LOAD_TIMEOUT, CAPTCHA_SCRIPT_ATTRIBUTE, CAPTCHA_STYLE_ATTRIBUTE,
|
|
3
|
+
RECAPTCHA_SCRIPT_URL,
|
|
4
|
+
} from './consts.js'
|
|
5
|
+
import { CaptchaLoadError } from './errors.js'
|
|
6
|
+
import type {
|
|
7
|
+
CaptchaDocument, CaptchaLoadErrorReason, CaptchaLoader, CaptchaLoaderOptions, CaptchaWindow, Grecaptcha,
|
|
8
|
+
} from './types.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Google's reCAPTCHA v3 loader. Nothing touches the page until `prepare` or `token` is called, so
|
|
12
|
+
* the module is safe to import during server rendering and in tests.
|
|
13
|
+
*/
|
|
14
|
+
export const createCaptchaLoader = (options: CaptchaLoaderOptions = {}): CaptchaLoader => {
|
|
15
|
+
const hideBadge = options.hideBadge ?? true
|
|
16
|
+
const loadTimeout = options.loadTimeout ?? CAPTCHA_LOAD_TIMEOUT
|
|
17
|
+
const executeTimeout = options.executeTimeout ?? CAPTCHA_EXECUTE_TIMEOUT
|
|
18
|
+
const scriptUrl = options.scriptUrl ?? RECAPTCHA_SCRIPT_URL
|
|
19
|
+
const loads = new Map<string, Promise<Grecaptcha>>()
|
|
20
|
+
|
|
21
|
+
const documentOf = (): CaptchaDocument | null =>
|
|
22
|
+
options.document ?? (typeof document !== 'undefined' ? document : null)
|
|
23
|
+
|
|
24
|
+
const windowOf = (): CaptchaWindow | null =>
|
|
25
|
+
options.window ?? (typeof window !== 'undefined' ? window as unknown as CaptchaWindow : null)
|
|
26
|
+
|
|
27
|
+
const addBadgeStyle = (page: CaptchaDocument): void => {
|
|
28
|
+
if (page.querySelector(`style[${CAPTCHA_STYLE_ATTRIBUTE}]`) != null) {
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
const style = page.createElement('style')
|
|
32
|
+
style.setAttribute(CAPTCHA_STYLE_ATTRIBUTE, '')
|
|
33
|
+
style.textContent = CAPTCHA_BADGE_CSS
|
|
34
|
+
page.head.appendChild(style)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const inject = (siteKey: string): Promise<Grecaptcha> => new Promise<Grecaptcha>((resolve, reject) => {
|
|
38
|
+
const page = documentOf()
|
|
39
|
+
const host = windowOf()
|
|
40
|
+
if (page == null || host == null) {
|
|
41
|
+
reject(new CaptchaLoadError('unavailable', 'reCAPTCHA needs a page to load into'))
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const script = page.createElement('script')
|
|
46
|
+
let settled = false
|
|
47
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
48
|
+
const fail = (reason: CaptchaLoadErrorReason, message: string): void => {
|
|
49
|
+
if (!settled) {
|
|
50
|
+
settled = true
|
|
51
|
+
clearTimeout(timer)
|
|
52
|
+
script.remove()
|
|
53
|
+
reject(new CaptchaLoadError(reason, message))
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
timer = setTimeout(() => { fail('timeout', 'reCAPTCHA did not become ready in time') }, loadTimeout)
|
|
57
|
+
|
|
58
|
+
script.addEventListener('error', () => { fail('load', 'reCAPTCHA failed to load') })
|
|
59
|
+
script.addEventListener('load', () => {
|
|
60
|
+
const grecaptcha = host.grecaptcha
|
|
61
|
+
if (grecaptcha == null || typeof grecaptcha.ready !== 'function') {
|
|
62
|
+
fail('unavailable', 'reCAPTCHA loaded without its API')
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
grecaptcha.ready(() => {
|
|
66
|
+
if (!settled) {
|
|
67
|
+
settled = true
|
|
68
|
+
clearTimeout(timer)
|
|
69
|
+
resolve(host.grecaptcha ?? grecaptcha)
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
script.src = `${scriptUrl}${scriptUrl.includes('?') ? '&' : '?'}render=${encodeURIComponent(siteKey)}`
|
|
75
|
+
script.async = true
|
|
76
|
+
script.setAttribute(CAPTCHA_SCRIPT_ATTRIBUTE, siteKey)
|
|
77
|
+
if (hideBadge) {
|
|
78
|
+
addBadgeStyle(page)
|
|
79
|
+
}
|
|
80
|
+
page.head.appendChild(script)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const readyOf = (siteKey: string): Promise<Grecaptcha> => {
|
|
84
|
+
const known = loads.get(siteKey)
|
|
85
|
+
if (known != null) {
|
|
86
|
+
return known
|
|
87
|
+
}
|
|
88
|
+
const loading = inject(siteKey)
|
|
89
|
+
loads.set(siteKey, loading)
|
|
90
|
+
loading.catch(() => { loads.delete(siteKey) })
|
|
91
|
+
|
|
92
|
+
return loading
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const prepare: CaptchaLoader['prepare'] = async siteKey => {
|
|
96
|
+
await readyOf(siteKey)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const token: CaptchaLoader['token'] = async (siteKey, action) => {
|
|
100
|
+
const grecaptcha = await readyOf(siteKey)
|
|
101
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
102
|
+
try {
|
|
103
|
+
const value = await Promise.race([
|
|
104
|
+
Promise.resolve(grecaptcha.execute(siteKey, { action })),
|
|
105
|
+
new Promise<never>((_, reject) => {
|
|
106
|
+
timer = setTimeout(() => { reject(new Error('reCAPTCHA execute timed out')) }, executeTimeout)
|
|
107
|
+
}),
|
|
108
|
+
])
|
|
109
|
+
if (typeof value !== 'string' || value === '') {
|
|
110
|
+
throw new Error('reCAPTCHA gave no token')
|
|
111
|
+
}
|
|
112
|
+
return value
|
|
113
|
+
} catch (error) {
|
|
114
|
+
throw new CaptchaLoadError('execute', `reCAPTCHA refused: ${String(error)}`, { cause: error })
|
|
115
|
+
} finally {
|
|
116
|
+
clearTimeout(timer)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { prepare, token }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The shared loader of a page: one script per site key across every caller. */
|
|
124
|
+
export const captchaLoader = createCaptchaLoader()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CaptchaLoader } from '../types.js'
|
|
2
|
+
|
|
3
|
+
export interface UseCaptchaOptions {
|
|
4
|
+
/** The site key the server advertised; `null` (or `''`) = captcha off. */
|
|
5
|
+
siteKey: string | null
|
|
6
|
+
/** The exact action the server checks this form's token for. */
|
|
7
|
+
action: string
|
|
8
|
+
/** Default: the shared `captchaLoader`. */
|
|
9
|
+
loader?: CaptchaLoader
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface UseCaptchaResult {
|
|
13
|
+
/** The script is ready for the site key — or the captcha is off. */
|
|
14
|
+
ready: boolean
|
|
15
|
+
/** The last load or execute failure, cleared by the next success. */
|
|
16
|
+
error: Error | null
|
|
17
|
+
/**
|
|
18
|
+
* A fresh token for the action, or `null` while the captcha is off.
|
|
19
|
+
*
|
|
20
|
+
* @throws {CaptchaLoadError} when the script cannot load or Google gives no token.
|
|
21
|
+
*/
|
|
22
|
+
execute: () => Promise<string | null>
|
|
23
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { CaptchaLoadError } from '../errors.js'
|
|
3
|
+
import { captchaLoader } from '../loader.js'
|
|
4
|
+
import type { CaptchaLoadErrorReason } from '../types.js'
|
|
5
|
+
import type { UseCaptchaOptions, UseCaptchaResult } from './types.js'
|
|
6
|
+
|
|
7
|
+
const errorOf = (failure: unknown, reason: CaptchaLoadErrorReason): Error =>
|
|
8
|
+
failure instanceof Error ? failure : new CaptchaLoadError(reason, String(failure))
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* reCAPTCHA v3 for one form: starts loading the script as soon as a site key is known and gives a
|
|
12
|
+
* fresh token per submit. With no site key the captcha is off — `ready` is true and `execute`
|
|
13
|
+
* resolves `null`, so the form sends no captcha at all.
|
|
14
|
+
*/
|
|
15
|
+
export const useCaptcha = ({ siteKey, action, loader = captchaLoader }: UseCaptchaOptions): UseCaptchaResult => {
|
|
16
|
+
const key = siteKey != null && siteKey !== '' ? siteKey : null
|
|
17
|
+
const [ready, setReady] = useState<boolean>(key == null)
|
|
18
|
+
const [error, setError] = useState<Error | null>(null)
|
|
19
|
+
const mounted = useRef(true)
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
mounted.current = true
|
|
23
|
+
return () => { mounted.current = false }
|
|
24
|
+
}, [])
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
setError(null)
|
|
28
|
+
if (key == null) {
|
|
29
|
+
setReady(true)
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
let current = true
|
|
33
|
+
setReady(false)
|
|
34
|
+
loader.prepare(key).then(
|
|
35
|
+
() => { if (current) { setReady(true) } },
|
|
36
|
+
(failure: unknown) => { if (current) { setError(errorOf(failure, 'load')) } }
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return () => { current = false }
|
|
40
|
+
}, [key, loader])
|
|
41
|
+
|
|
42
|
+
const execute = useCallback(async (): Promise<string | null> => {
|
|
43
|
+
if (key == null) {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const token = await loader.token(key, action)
|
|
48
|
+
if (mounted.current) {
|
|
49
|
+
setReady(true)
|
|
50
|
+
setError(null)
|
|
51
|
+
}
|
|
52
|
+
return token
|
|
53
|
+
} catch (failure) {
|
|
54
|
+
const reported = errorOf(failure, 'execute')
|
|
55
|
+
if (mounted.current) {
|
|
56
|
+
setError(reported)
|
|
57
|
+
}
|
|
58
|
+
throw reported
|
|
59
|
+
}
|
|
60
|
+
}, [key, action, loader])
|
|
61
|
+
|
|
62
|
+
return { ready, error, execute }
|
|
63
|
+
}
|
package/src/react.ts
ADDED
package/src/types.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** The part of Google's `grecaptcha` (v3, `render=<site key>`) the loader uses. */
|
|
2
|
+
export interface Grecaptcha {
|
|
3
|
+
ready: (callback: () => void) => void
|
|
4
|
+
execute: (siteKey: string, options: { action: string }) => PromiseLike<string>
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** The window as the loader reads it: only Google's global. */
|
|
8
|
+
export interface CaptchaWindow {
|
|
9
|
+
grecaptcha?: Grecaptcha
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The document as the loader uses it: one script and one style appended to its head. */
|
|
13
|
+
export interface CaptchaDocument extends Pick<Document, 'createElement' | 'querySelector' | 'head'> {}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Why a captcha could not give a token: the script failed to `load`, did not become ready in time
|
|
17
|
+
* (`timeout`), there is no page or no API to use (`unavailable`), or Google gave no token (`execute`).
|
|
18
|
+
*/
|
|
19
|
+
export type CaptchaLoadErrorReason = 'load' | 'timeout' | 'execute' | 'unavailable'
|
|
20
|
+
|
|
21
|
+
export interface CaptchaLoaderOptions {
|
|
22
|
+
/** Add the document style that hides Google's badge. Default `true`. */
|
|
23
|
+
hideBadge?: boolean
|
|
24
|
+
/** Milliseconds the script may take to become ready. Default `CAPTCHA_LOAD_TIMEOUT`. */
|
|
25
|
+
loadTimeout?: number
|
|
26
|
+
/** Milliseconds one `execute` may take. Default `CAPTCHA_EXECUTE_TIMEOUT`. */
|
|
27
|
+
executeTimeout?: number
|
|
28
|
+
/** The script address `?render=<site key>` is added to. Default `RECAPTCHA_SCRIPT_URL`. */
|
|
29
|
+
scriptUrl?: string
|
|
30
|
+
/** The page to load into. Default: the global `document`, read at call time. */
|
|
31
|
+
document?: CaptchaDocument
|
|
32
|
+
/** Where Google's global appears. Default: the global `window`, read at call time. */
|
|
33
|
+
window?: CaptchaWindow
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Google's reCAPTCHA v3, loaded on first use — never at import. */
|
|
37
|
+
export interface CaptchaLoader {
|
|
38
|
+
/**
|
|
39
|
+
* Inject `api.js?render=<siteKey>` once per key (and, unless disabled, the document style that
|
|
40
|
+
* hides Google's badge); resolves once `grecaptcha` is ready. A failure is forgotten, so the next
|
|
41
|
+
* call injects again.
|
|
42
|
+
*
|
|
43
|
+
* @throws {CaptchaLoadError} `load`, `timeout` or `unavailable`.
|
|
44
|
+
*/
|
|
45
|
+
prepare: (siteKey: string) => Promise<void>
|
|
46
|
+
/**
|
|
47
|
+
* A fresh token for `action`, loading the script first when needed.
|
|
48
|
+
*
|
|
49
|
+
* @throws {CaptchaLoadError} what `prepare` throws, or `execute` when Google gives no token in time.
|
|
50
|
+
*/
|
|
51
|
+
token: (siteKey: string, action: string) => Promise<string>
|
|
52
|
+
}
|
package/tests/context.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { CaptchaDocument, CaptchaWindow, Grecaptcha } from '../src/types.js'
|
|
2
|
+
|
|
3
|
+
/** An element as the loader uses it: attributes, listeners, `src`/`async`/`textContent`, `remove`. */
|
|
4
|
+
export interface PageElement {
|
|
5
|
+
tag: string
|
|
6
|
+
attributes: Record<string, string>
|
|
7
|
+
src?: string
|
|
8
|
+
async?: boolean
|
|
9
|
+
textContent?: string | null
|
|
10
|
+
removed: boolean
|
|
11
|
+
setAttribute: (name: string, value: string) => void
|
|
12
|
+
addEventListener: (type: string, listener: () => void) => void
|
|
13
|
+
remove: () => void
|
|
14
|
+
/** Run the listeners of `type`, as the browser would. */
|
|
15
|
+
fire: (type: string) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TestPage {
|
|
19
|
+
document: CaptchaDocument
|
|
20
|
+
window: CaptchaWindow
|
|
21
|
+
/** What the loader appended to the head, in order (removed ones included, flagged). */
|
|
22
|
+
head: PageElement[]
|
|
23
|
+
/** The scripts still in the head. */
|
|
24
|
+
scripts: () => PageElement[]
|
|
25
|
+
/** Google's script arrives: `grecaptcha` (ready at once) is installed and `load` fires. */
|
|
26
|
+
arrive: (script: PageElement, grecaptcha?: Grecaptcha | null) => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const makeElement = (tag: string): PageElement => {
|
|
30
|
+
const listeners: Record<string, (() => void)[]> = {}
|
|
31
|
+
const element: PageElement = {
|
|
32
|
+
tag,
|
|
33
|
+
attributes: {},
|
|
34
|
+
removed: false,
|
|
35
|
+
setAttribute: (name, value) => { element.attributes[name] = value },
|
|
36
|
+
addEventListener: (type, listener) => { (listeners[type] ??= []).push(listener) },
|
|
37
|
+
remove: () => { element.removed = true },
|
|
38
|
+
fire: type => { (listeners[type] ?? []).forEach(listener => listener()) },
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return element
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A page stand-in (document + window) for the loader's pure logic — no browser. */
|
|
45
|
+
export const makeTestPage = (): TestPage => {
|
|
46
|
+
const head: PageElement[] = []
|
|
47
|
+
const window: CaptchaWindow = {}
|
|
48
|
+
|
|
49
|
+
const querySelector = (selector: string): PageElement | null => {
|
|
50
|
+
const [, tag, attribute] = /^(\w+)\[([^\]=]+)\]$/.exec(selector) ?? []
|
|
51
|
+
|
|
52
|
+
return head.find(element => !element.removed && element.tag === tag && attribute in element.attributes) ?? null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const document = {
|
|
56
|
+
head: { appendChild: (element: PageElement) => { head.push(element); return element } },
|
|
57
|
+
createElement: makeElement,
|
|
58
|
+
querySelector,
|
|
59
|
+
} as unknown as CaptchaDocument
|
|
60
|
+
|
|
61
|
+
const arrive: TestPage['arrive'] = (script, grecaptcha) => {
|
|
62
|
+
window.grecaptcha = grecaptcha === null ? undefined : grecaptcha ?? {
|
|
63
|
+
ready: callback => { callback() },
|
|
64
|
+
execute: async (siteKey, { action }) => `token:${siteKey}:${action}`,
|
|
65
|
+
}
|
|
66
|
+
script.fire('load')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
document, window, head, arrive,
|
|
71
|
+
scripts: () => head.filter(element => element.tag === 'script' && !element.removed),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
CAPTCHA_BADGE_CSS, CAPTCHA_SCRIPT_ATTRIBUTE, CAPTCHA_STYLE_ATTRIBUTE, RECAPTCHA_SCRIPT_URL,
|
|
4
|
+
} from '../src/consts.js'
|
|
5
|
+
import { CaptchaLoadError } from '../src/errors.js'
|
|
6
|
+
import { captchaLoader, createCaptchaLoader } from '../src/loader.js'
|
|
7
|
+
import type { CaptchaLoaderOptions } from '../src/types.js'
|
|
8
|
+
import { makeTestPage } from './context.js'
|
|
9
|
+
|
|
10
|
+
const loaderOn = (page = makeTestPage(), options: CaptchaLoaderOptions = {}) =>
|
|
11
|
+
({ page, loader: createCaptchaLoader({ document: page.document, window: page.window, ...options }) })
|
|
12
|
+
|
|
13
|
+
const failure = async (promise: Promise<unknown>): Promise<CaptchaLoadError> => {
|
|
14
|
+
try {
|
|
15
|
+
await promise
|
|
16
|
+
} catch (error) {
|
|
17
|
+
expect(error).toBeInstanceOf(CaptchaLoadError)
|
|
18
|
+
return error as CaptchaLoadError
|
|
19
|
+
}
|
|
20
|
+
throw new Error('expected a failure')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('@owlmeans/web-captcha — loader', () => {
|
|
24
|
+
test('injects one script per site key, with the badge style once, and resolves when grecaptcha is ready', async () => {
|
|
25
|
+
const { page, loader } = loaderOn()
|
|
26
|
+
|
|
27
|
+
const first = loader.prepare('key one')
|
|
28
|
+
const again = loader.prepare('key one')
|
|
29
|
+
expect(page.scripts()).toHaveLength(1)
|
|
30
|
+
const [script] = page.scripts()
|
|
31
|
+
expect(script.src).toBe(`${RECAPTCHA_SCRIPT_URL}?render=key%20one`)
|
|
32
|
+
expect(script.async).toBe(true)
|
|
33
|
+
expect(script.attributes[CAPTCHA_SCRIPT_ATTRIBUTE]).toBe('key one')
|
|
34
|
+
|
|
35
|
+
page.arrive(script)
|
|
36
|
+
await Promise.all([first, again])
|
|
37
|
+
await loader.prepare('key one')
|
|
38
|
+
expect(page.scripts()).toHaveLength(1)
|
|
39
|
+
|
|
40
|
+
const second = loader.prepare('key-two')
|
|
41
|
+
page.arrive(page.scripts()[1])
|
|
42
|
+
await second
|
|
43
|
+
expect(page.scripts()).toHaveLength(2)
|
|
44
|
+
const styles = page.head.filter(element => element.tag === 'style')
|
|
45
|
+
expect(styles).toHaveLength(1)
|
|
46
|
+
expect(styles[0].attributes).toHaveProperty(CAPTCHA_STYLE_ATTRIBUTE)
|
|
47
|
+
expect(styles[0].textContent).toBe(CAPTCHA_BADGE_CSS)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('a load that fails or never becomes ready is refused with its reason, and forgotten', async () => {
|
|
51
|
+
const { page, loader } = loaderOn(makeTestPage(), { loadTimeout: 5 })
|
|
52
|
+
|
|
53
|
+
const timedOut = await failure(loader.prepare('k'))
|
|
54
|
+
expect(timedOut.reason).toBe('timeout')
|
|
55
|
+
expect(page.scripts()).toHaveLength(0)
|
|
56
|
+
|
|
57
|
+
const erroring = loader.prepare('k')
|
|
58
|
+
page.scripts()[0].fire('error')
|
|
59
|
+
expect((await failure(erroring)).reason).toBe('load')
|
|
60
|
+
|
|
61
|
+
const bare = loader.prepare('k')
|
|
62
|
+
page.arrive(page.scripts()[0], null)
|
|
63
|
+
expect((await failure(bare)).reason).toBe('unavailable')
|
|
64
|
+
|
|
65
|
+
const retried = loader.prepare('k')
|
|
66
|
+
page.arrive(page.scripts()[0])
|
|
67
|
+
await retried
|
|
68
|
+
expect(page.head.filter(element => element.tag === 'script')).toHaveLength(4)
|
|
69
|
+
expect(page.scripts()).toHaveLength(1)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('without a page nothing is touched at import, and a call is unavailable', async () => {
|
|
73
|
+
expect(typeof (globalThis as { document?: unknown }).document).toBe('undefined')
|
|
74
|
+
expect((await failure(captchaLoader.prepare('k'))).reason).toBe('unavailable')
|
|
75
|
+
expect((await failure(createCaptchaLoader().token('k', 'a'))).reason).toBe('unavailable')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('hideBadge: false adds no document style; a script url with a query keeps it', async () => {
|
|
79
|
+
const { page, loader } = loaderOn(makeTestPage(), { hideBadge: false, scriptUrl: 'https://www.recaptcha.net/recaptcha/api.js?hl=pl' })
|
|
80
|
+
|
|
81
|
+
const ready = loader.prepare('k')
|
|
82
|
+
page.arrive(page.scripts()[0])
|
|
83
|
+
await ready
|
|
84
|
+
expect(page.head.map(element => element.tag)).toEqual(['script'])
|
|
85
|
+
expect(page.scripts()[0].src).toBe('https://www.recaptcha.net/recaptcha/api.js?hl=pl&render=k')
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('@owlmeans/web-captcha — token', () => {
|
|
90
|
+
test('loads on demand and executes the action', async () => {
|
|
91
|
+
const { page, loader } = loaderOn()
|
|
92
|
+
|
|
93
|
+
const token = loader.token('k', 'email_pin')
|
|
94
|
+
page.arrive(page.scripts()[0])
|
|
95
|
+
expect(await token).toBe('token:k:email_pin')
|
|
96
|
+
expect(await loader.token('k', 'email_password')).toBe('token:k:email_password')
|
|
97
|
+
expect(page.scripts()).toHaveLength(1)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('no token, a throwing or a hanging execute is an execute failure', async () => {
|
|
101
|
+
for (const execute of [
|
|
102
|
+
async () => '',
|
|
103
|
+
() => { throw new Error('denied') },
|
|
104
|
+
() => new Promise<string>(() => {}),
|
|
105
|
+
]) {
|
|
106
|
+
const { page, loader } = loaderOn(makeTestPage(), { executeTimeout: 5 })
|
|
107
|
+
const token = loader.token('k', 'email_pin')
|
|
108
|
+
page.arrive(page.scripts()[0], { ready: callback => { callback() }, execute })
|
|
109
|
+
expect((await failure(token)).reason).toBe('execute')
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": [
|
|
3
|
+
"@owlmeans/dep-config/tsconfig.base.json",
|
|
4
|
+
"@owlmeans/dep-config/tsconfig.react.json"
|
|
5
|
+
],
|
|
6
|
+
"compilerOptions": {
|
|
7
|
+
"rootDir": "./src/",
|
|
8
|
+
"outDir": "./build/"
|
|
9
|
+
},
|
|
10
|
+
"exclude": [
|
|
11
|
+
"./dist/**/*",
|
|
12
|
+
"./build/**/*",
|
|
13
|
+
"./tests/**/*",
|
|
14
|
+
"./*.ts"
|
|
15
|
+
]
|
|
16
|
+
}
|