{"_id":"@arthur.eudeline/payload-plugin-turnstile","_rev":"2-63ce29bfcfc452e3fe206944c2c7198b","name":"@arthur.eudeline/payload-plugin-turnstile","dist-tags":{"latest":"0.1.2"},"versions":{"0.1.1":{"name":"@arthur.eudeline/payload-plugin-turnstile","version":"0.1.1","keywords":["payload","payload-plugin","payloadcms","turnstile","cloudflare","captcha","login"],"author":"Arthur Eudeline","license":"MIT","_id":"@arthur.eudeline/payload-plugin-turnstile@0.1.1","maintainers":[{"name":"arthur.eudeline","email":"contact@arthur-eudeline.com"}],"homepage":"https://github.com/arthur-eudeline/payload-plugins/tree/main/packages/payload-plugin-turnstile#readme","bugs":{"url":"https://github.com/arthur-eudeline/payload-plugins/issues"},"dist":{"shasum":"6c1fa36923c2b929a1b03c4fda9f7485e882a471","tarball":"https://registry.npmjs.org/@arthur.eudeline/payload-plugin-turnstile/-/payload-plugin-turnstile-0.1.1.tgz","fileCount":52,"integrity":"sha512-dRpPmzFzsT2XHD2XAE0nKwsUI8R3uEN2l1Xax3IM50cvXuQoLFM/PBOzZp0x+0AvUf8PmUlHq8qo57Sou91yvQ==","signatures":[{"sig":"MEQCIHB11g++CAGDsg9X5UZ9AwOcRuvUfIOnSg+jnGSDBgeOAiBkXoVsHr27rTzBoshRE7nWAwr1WPvhcGtsvTsE0Vm16g==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":78197},"type":"module","engines":{"node":">=20.9.0"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","default":"./dist/index.js"},"./client":{"types":"./dist/exports/client.d.ts","import":"./dist/exports/client.js","default":"./dist/exports/client.js"}},"scripts":{"build":"rm -rf dist && tsc && node scripts/copy-assets.mjs","typecheck":"tsc --noEmit"},"_npmUser":{"name":"arthur.eudeline","email":"contact@arthur-eudeline.com"},"repository":{"url":"git+https://github.com/arthur-eudeline/payload-plugins.git","type":"git","directory":"packages/payload-plugin-turnstile"},"description":"Cloudflare Turnstile captcha on the Payload CMS admin login, configured from the admin panel.","directories":{},"sideEffects":["*.css"],"_nodeVersion":"24.11.1","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"react":"19.2.8","payload":"^3.88.0","typescript":"6.0.3","@types/node":"^26.2.0","@types/react":"19.2.18","@payloadcms/ui":"3.88.0"},"peerDependencies":{"react":"^19.0.0","payload":"^3.88.0","@payloadcms/ui":"^3.88.0"},"_npmOperationalInternal":{"tmp":"tmp/payload-plugin-turnstile_0.1.1_1788702684458_0.8389426329647385","host":"s3://npm-registry-packages-npm-production"}},"0.1.2":{"_id":"@arthur.eudeline/payload-plugin-turnstile@0.1.2","bugs":{"url":"https://github.com/arthur-eudeline/payload-plugins/issues"},"dist":{"shasum":"4cc2bdb788cc1b1d49a3ed709cf88f79ae24f915","tarball":"https://registry.npmjs.org/@arthur.eudeline/payload-plugin-turnstile/-/payload-plugin-turnstile-0.1.2.tgz","fileCount":52,"integrity":"sha512-/Emuv4nXwkO8kZzN0R16ok4BqGjMTgSMFHIrZsrYO/9pl2o+Nbqb1ujaGzFRj5PT0/USFY3qAhs28fki8ZpTJA==","signatures":[{"sig":"MEUCIEtipROoSQmh1S8nymYm6IEADbtKQa5IOPUVL99IkqaeAiEAq8yNq9NEsosdtyQM6ikE1GJ/qOwNmdUbEdOEvzFp1dQ=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQDi2nIXAs7dCvc2Vfy5VXI+0lFzHjBjkaEToZfhg/MVjwIgJZoGfKUPaPtudpPMYBHnNc72p9Wl9LsnSPDtwMVmHks="}],"unpackedSize":78231},"name":"@arthur.eudeline/payload-plugin-turnstile","type":"module","author":"Arthur Eudeline","engines":{"node":">=20.9.0"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","default":"./dist/index.js"},"./client":{"types":"./dist/exports/client.d.ts","import":"./dist/exports/client.js","default":"./dist/exports/client.js"}},"license":"MIT","scripts":{"build":"rm -rf dist && tsc && node scripts/copy-assets.mjs","typecheck":"tsc --noEmit"},"version":"0.1.2","_npmUser":{"name":"arthur.eudeline","email":"contact@arthur-eudeline.com"},"homepage":"https://github.com/arthur-eudeline/payload-plugins/tree/main/packages/payload-plugin-turnstile#readme","keywords":["payload","payload-plugin","payloadcms","turnstile","cloudflare","captcha","login"],"repository":{"url":"git+https://github.com/arthur-eudeline/payload-plugins.git","type":"git","directory":"packages/payload-plugin-turnstile"},"description":"Cloudflare Turnstile captcha on the Payload CMS admin login, configured from the admin panel.","directories":{},"maintainers":[{"name":"arthur.eudeline","email":"contact@arthur-eudeline.com"}],"sideEffects":["*.css"],"_nodeVersion":"24.11.1","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"react":"19.2.8","payload":"^3.88.0","typescript":"6.0.3","@types/node":"^26.2.0","@types/react":"19.2.18","@payloadcms/ui":"3.88.0"},"peerDependencies":{"react":"^19.0.0","payload":"^3.88.0","@payloadcms/ui":"^3.88.0"},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/payload-plugin-turnstile_0.1.2_1788847489636_0.7388273239207079"}}},"time":{"created":"2026-09-06T13:51:24.260Z","modified":"2026-09-08T06:04:49.896Z","0.1.1":"2026-09-06T13:51:24.599Z","0.1.2":"2026-09-08T06:04:49.726Z"},"bugs":{"url":"https://github.com/arthur-eudeline/payload-plugins/issues"},"author":"Arthur Eudeline","license":"MIT","homepage":"https://github.com/arthur-eudeline/payload-plugins/tree/main/packages/payload-plugin-turnstile#readme","keywords":["payload","payload-plugin","payloadcms","turnstile","cloudflare","captcha","login"],"repository":{"url":"git+https://github.com/arthur-eudeline/payload-plugins.git","type":"git","directory":"packages/payload-plugin-turnstile"},"description":"Cloudflare Turnstile captcha on the Payload CMS admin login, configured from the admin panel.","maintainers":[{"name":"arthur.eudeline","email":"contact@arthur-eudeline.com"}],"readme":"# @arthur-eudeline/payload-plugin-turnstile\n\nCloudflare Turnstile captcha on the Payload CMS admin login — enabled and configured from the admin\npanel itself, not from environment variables.\n\n- A **Turnstile captcha** global with three fields: an on/off checkbox, the site key and the secret\n  key (both created at <https://dash.cloudflare.com/?to=/:account/turnstile>).\n- The widget above the sign-in form, injected through `admin.components.beforeLogin`.\n- Two endpoints, `GET /api/turnstile/config` and `POST /api/turnstile/verify`.\n- A `beforeLogin` hook that rejects any sign-in whose challenge was not solved.\n\nLabels ship in English and French.\n\n## Install\n\n```bash\npnpm add @arthur-eudeline/payload-plugin-turnstile\n```\n\n```ts\nimport { turnstilePlugin } from '@arthur-eudeline/payload-plugin-turnstile';\n\nexport default buildConfig({\n  admin: { user: 'users' },\n  plugins: [turnstilePlugin()],\n});\n```\n\nThen `payload generate:types`, `payload generate:importmap`, and a migration — the plugin adds a\ntable.\n\nNothing happens until someone ticks the checkbox in the admin panel and fills in both keys. That is\ndeliberate: the deployment ships the capability, the operator decides when to switch it on, and can\nswitch it off again without a redeploy.\n\n## Options\n\n| Option              | Default                     | What it does                                                                                                                                                                                                                                     |\n| ------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `collections`       | `[config.admin.user]`       | Auth collections to protect.                                                                                                                                                                                                                     |\n| `enabled`           | `true`                      | `false` disables the plugin _without removing the global_ — the Payload convention, so an already-migrated database keeps its table. Not to be confused with the admin checkbox: this one is the developer's switch, that one is the operator's. |\n| `globalSlug`        | `'turnstile'`               | Slug of the settings global.                                                                                                                                                                                                                     |\n| `adminGroup`        | `'Settings'` / `'Réglages'` | Sidebar group for the global; `false` to leave it ungrouped.                                                                                                                                                                                     |\n| `passDuration`      | `10 * 60 * 1000`            | How long a solved challenge stays valid, in milliseconds.                                                                                                                                                                                        |\n| `labels`            | —                           | Per-language label overrides: `{ fr: { widgetError: '…' } }`.                                                                                                                                                                                    |\n| `language`          | admin language              | Pins the label language.                                                                                                                                                                                                                         |\n| `injectLoginWidget` | `true`                      | `false` if the project mounts `TurnstileWidget` itself.                                                                                                                                                                                          |\n\n## How it works, and why it looks like that\n\nTurnstile's contract is simple: the widget hands the browser a token, and the server must exchange\nthat token for a verdict by calling Cloudflare with the secret key. The natural design would send\nthe token along with the credentials and check it in `beforeLogin`.\n\nThat is not possible here. Payload's sign-in form posts a fixed body to\n`/api/<collection>/login` — it has no way to carry an extra field, and a project that has replaced\nthe login view (a two-step 2FA form, say) has its own body to build. A plugin that required the\nform to cooperate would only work with the form it shipped with.\n\nSo the exchange happens on its own endpoint. As soon as the challenge is solved, the widget POSTs\nthe token to `/api/turnstile/verify`, which calls Cloudflare and — on success — sets a **pass\ncookie**. The sign-in form then submits unchanged, and the `beforeLogin` hook only has to check the\ncookie. The plugin never touches the form, and works with any of them.\n\nThe pass cookie is:\n\n- **signed** (HMAC-SHA256 with the Payload secret) and **dated**, so it can be neither forged nor\n  replayed past `passDuration`;\n- **`HttpOnly`**, **`SameSite=Lax`**, and **`Secure`** whenever the request came in over HTTPS;\n- **anonymous** — it identifies nobody and grants nothing. It attests that _a_ challenge was solved\n  from this browser, recently. That is the entire claim, and it is exactly the claim Turnstile\n  makes.\n\nCloudflare invalidates a token the first time it is verified, so a token cannot be redeemed twice.\nWithin `passDuration`, the resulting cookie does cover several sign-in attempts from that browser —\nwhich is what makes a wrong password, or a two-step 2FA flow, survivable without re-solving the\nchallenge.\n\n### Failure modes\n\nThe plugin errs toward _not_ locking the operator out, except where doing so would defeat the\ncaptcha:\n\n| Situation                                          | Behaviour                                                                               |\n| -------------------------------------------------- | --------------------------------------------------------------------------------------- |\n| Checkbox ticked, a key missing                     | Sign-in allowed, warning logged. Refusing would leave nobody able to go untick the box. |\n| Settings unreadable (table missing, database down) | Same.                                                                                   |\n| Cloudflare unreachable, or times out               | Verification **fails**. The opposite would turn any network blip into a bypass.         |\n| Widget script blocked                              | The widget reports the error; sign-in is refused until it loads.                        |\n\n## HTTP surface\n\n| Method | Path                    | Auth   | Purpose                                                                                                                                                           |\n| ------ | ----------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `GET`  | `/api/turnstile/config` | public | `{ enabled, siteKey }` for the widget. Never returns the secret key; the site key is public by design — it appears in the HTML of every Turnstile-protected page. |\n| `POST` | `/api/turnstile/verify` | public | `{ token }` → validates with Cloudflare and sets the pass cookie.                                                                                                 |\n\nBoth must be public: they run on the sign-in page, before any session exists.\n\nThe `beforeLogin` hook throws `403` with `errors[0].data.code === 'TURNSTILE_REQUIRED'` when no\nvalid pass is present.\n\n> **Note for plugin authors.** That code is delivered through `formatErrors`'s\n> `Array.isArray(message)` branch rather than an `APIError`'s `data` field. Payload only forwards\n> `data` for an error it recognises with `instanceof APIError`, and that check fails in a Next.js\n> production build — `withPayload` externalises `payload` only in development, so the bundled copy\n> the plugin throws from is not the one `formatErrors` compares against. Going through the array\n> branch is identity-independent and behaves the same in dev and in production.\n\n## Exports\n\n- `@arthur-eudeline/payload-plugin-turnstile` — `turnstilePlugin`, `TURNSTILE_ERROR_CODES`,\n  `TURNSTILE_DASHBOARD_URL`, `builtInLabels`, and the option types.\n- `@arthur-eudeline/payload-plugin-turnstile/client` — `TurnstileWidget`, plus the error codes and label helpers.\n\n## Licence\n\nMIT.\n","readmeFilename":""}