@onderwijsin/nuxt-turnstile 0.2.0
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/LICENSE +21 -0
- package/README.md +139 -0
- package/dist/module.d.mts +19 -0
- package/dist/module.json +12 -0
- package/dist/module.mjs +149 -0
- package/dist/runtime/app/composables/turnstile.d.ts +19 -0
- package/dist/runtime/app/composables/turnstile.js +59 -0
- package/dist/runtime/constants.d.ts +2 -0
- package/dist/runtime/constants.js +1 -0
- package/dist/runtime/index.d.ts +3 -0
- package/dist/runtime/index.js +7 -0
- package/dist/runtime/server/utils/turnstile.d.ts +32 -0
- package/dist/runtime/server/utils/turnstile.js +71 -0
- package/dist/runtime/types/config.d.ts +15 -0
- package/dist/runtime/types/errors.d.ts +20 -0
- package/dist/runtime/types/errors.js +12 -0
- package/dist/types.d.mts +7 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stichting Onderwijs in
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# @onderwijsin/nuxt-turnstile
|
|
2
|
+
|
|
3
|
+
Nuxt 4 integration for action-aware Cloudflare Turnstile protection. The module registers
|
|
4
|
+
`@nuxtjs/turnstile` and `@nuxt/ui`, exposes the auto-imported `useTurnstile()` composable, and
|
|
5
|
+
provides server helpers for validating single-use Turnstile tokens before a protected operation.
|
|
6
|
+
|
|
7
|
+
## Installation and configuration
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm add @onderwijsin/nuxt-turnstile
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
export default defineNuxtConfig({
|
|
15
|
+
modules: ["@onderwijsin/nuxt-turnstile"],
|
|
16
|
+
turnstile: {
|
|
17
|
+
siteKey: process.env.TURNSTILE_SITE_KEY ?? "",
|
|
18
|
+
secretKey: process.env.TURNSTILE_SECRET_KEY ?? ""
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The module's options are:
|
|
24
|
+
|
|
25
|
+
| Option | Default | Purpose |
|
|
26
|
+
| ----------------- | ----------------- | ------------------------------------------------- |
|
|
27
|
+
| `enabled` | `true` | Enables or disables module setup |
|
|
28
|
+
| `siteKey` | `""` | Public key used by the Turnstile widget |
|
|
29
|
+
| `secretKey` | `""` | Server-only key used for verification |
|
|
30
|
+
| `adminToken` | `""` | Optional trusted token that bypasses verification |
|
|
31
|
+
| `adminHeaderName` | `"x-admin-token"` | Header accepted for `adminToken` |
|
|
32
|
+
|
|
33
|
+
`siteKey` is public and is also passed to `@nuxtjs/turnstile`. Keep `secretKey` and `adminToken`
|
|
34
|
+
private. In production, prefer `NUXT_TURNSTILE_SECRET_KEY` for the private runtime value. The
|
|
35
|
+
module's dependency registration also requires the consuming app's normal Nuxt UI stylesheet setup.
|
|
36
|
+
|
|
37
|
+
## Protecting a form
|
|
38
|
+
|
|
39
|
+
Render `NuxtTurnstile`, choose a stable action for the operation, wait for a token, and forward it
|
|
40
|
+
in `x-turnstile-token`. `useTurnstile()` is auto-imported and provides token lifecycle helpers plus
|
|
41
|
+
Nuxt UI toast feedback.
|
|
42
|
+
|
|
43
|
+
```vue
|
|
44
|
+
<script setup lang="ts">
|
|
45
|
+
import { TURNSTILE_TOKEN_HEADER } from "@onderwijsin/nuxt-turnstile/runtime";
|
|
46
|
+
|
|
47
|
+
const token = ref<string>();
|
|
48
|
+
const widget = useTemplateRef<{ reset: () => void }>("turnstile");
|
|
49
|
+
const { getTokenWithRetry, isEnabled, showMissingTokenErrorHint, captureTurnstileError } =
|
|
50
|
+
useTurnstile();
|
|
51
|
+
|
|
52
|
+
async function onSubmit() {
|
|
53
|
+
const currentToken = await getTokenWithRetry();
|
|
54
|
+
if (isEnabled.value && !currentToken) {
|
|
55
|
+
showMissingTokenErrorHint();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
await $fetch("/api/user/session", {
|
|
61
|
+
method: "POST",
|
|
62
|
+
body: { email: "user@example.com" },
|
|
63
|
+
headers: currentToken ? { [TURNSTILE_TOKEN_HEADER]: currentToken } : undefined
|
|
64
|
+
});
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (!captureTurnstileError(error)) throw error;
|
|
67
|
+
} finally {
|
|
68
|
+
widget.value?.reset();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<template>
|
|
74
|
+
<form @submit.prevent="onSubmit">
|
|
75
|
+
<NuxtTurnstile
|
|
76
|
+
ref="turnstile"
|
|
77
|
+
v-model="token"
|
|
78
|
+
:options="{ action: 'magic-link', appearance: 'interaction-only' }"
|
|
79
|
+
/>
|
|
80
|
+
<UButton type="submit" label="Send magic link" />
|
|
81
|
+
</form>
|
|
82
|
+
</template>
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Use `getToken()` when the widget is already ready, or `getTokenWithRetry()` when submission may race
|
|
86
|
+
with widget initialization. Reset the widget after every processed submission because Turnstile
|
|
87
|
+
tokens are single-use.
|
|
88
|
+
|
|
89
|
+
## Protecting a server route
|
|
90
|
+
|
|
91
|
+
Call `assertTurnstileToken` before application validation, persistence, delivery, or other protected
|
|
92
|
+
work. The expected action must match the widget action.
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { z } from "zod";
|
|
96
|
+
import { assertTurnstileToken } from "@onderwijsin/nuxt-turnstile/runtime";
|
|
97
|
+
|
|
98
|
+
export default defineEventHandler(async (event) => {
|
|
99
|
+
await assertTurnstileToken(event, "magic-link");
|
|
100
|
+
const body = await readValidatedBody(event, z.object({ email: z.email() }).parse);
|
|
101
|
+
|
|
102
|
+
return await sendMagicLink(body.email);
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The helper reads `x-turnstile-token` and verifies it through `@nuxtjs/turnstile`. It rejects
|
|
107
|
+
missing, failed, unavailable, or mismatched-action tokens. In development, an absent secret leaves
|
|
108
|
+
local forms usable; in production, it produces `TURNSTILE_SERVER_MISCONFIGURED`.
|
|
109
|
+
|
|
110
|
+
## Trusted administrator bypass
|
|
111
|
+
|
|
112
|
+
Configure `adminToken` for trusted server-to-server or administrative requests. The configured
|
|
113
|
+
`adminHeaderName` and `Authorization: Bearer <token>` are both accepted. This bypass is checked
|
|
114
|
+
before Turnstile verification; do not expose the token in public runtime config, client code, logs,
|
|
115
|
+
or application aliases.
|
|
116
|
+
|
|
117
|
+
## Runtime exports
|
|
118
|
+
|
|
119
|
+
The `@onderwijsin/nuxt-turnstile/runtime` subpath is the explicit runtime API and does not require
|
|
120
|
+
loading the Nuxt module entrypoint. It exports:
|
|
121
|
+
|
|
122
|
+
- `TURNSTILE_TOKEN_HEADER` (`"x-turnstile-token"`)
|
|
123
|
+
- `assertTurnstileToken`
|
|
124
|
+
- `createTurnstileError`
|
|
125
|
+
- `createTurnstileErrorData`
|
|
126
|
+
- `isErrorWithStatusCode`
|
|
127
|
+
- `TurnstileErrorCode` and `TurnstileErrorData` types
|
|
128
|
+
|
|
129
|
+
Stable error codes are `TURNSTILE_TOKEN_MISSING`, `TURNSTILE_VALIDATION_FAILED`,
|
|
130
|
+
`TURNSTILE_ACTION_MISMATCH`, `TURNSTILE_VALIDATION_UNAVAILABLE`, and
|
|
131
|
+
`TURNSTILE_SERVER_MISCONFIGURED`. Apply authorization and business validation in the consuming route
|
|
132
|
+
after Turnstile succeeds; the module does not replace the route's existing submit or business logic.
|
|
133
|
+
|
|
134
|
+
## Compatibility
|
|
135
|
+
|
|
136
|
+
- Nuxt 4
|
|
137
|
+
- Node.js 22+
|
|
138
|
+
- Node and Cloudflare Workers-compatible server runtime
|
|
139
|
+
- No Sentry dependency or telemetry is included
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as _nuxt_schema from '@nuxt/schema';
|
|
2
|
+
|
|
3
|
+
interface ModuleOptions {
|
|
4
|
+
/** Whether the module is enabled. @default true */
|
|
5
|
+
enabled?: boolean;
|
|
6
|
+
/** Public site key consumed by the Turnstile widget. @default "" */
|
|
7
|
+
siteKey?: string;
|
|
8
|
+
/** Secret key used by server-side Turnstile verification. @default "" */
|
|
9
|
+
secretKey?: string;
|
|
10
|
+
/** Optional administrator token that bypasses Turnstile verification. @default "" */
|
|
11
|
+
adminToken?: string;
|
|
12
|
+
/** Header accepted for the administrator token. @default "x-admin-token" */
|
|
13
|
+
adminHeaderName?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Registers shared Turnstile configuration, client helpers, and server validation. */
|
|
17
|
+
declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
|
|
18
|
+
|
|
19
|
+
export { _default as default };
|
package/dist/module.json
ADDED
package/dist/module.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { defineNuxtModule, useLogger, createResolver, addTypeTemplate, addImportsDir, addServerScanDir } from '@nuxt/kit';
|
|
2
|
+
import { defu } from 'defu';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
var NUMBER_CHAR_RE = /\d/;
|
|
6
|
+
var STR_SPLITTERS = ["-", "_", "/", "."];
|
|
7
|
+
function isUppercase(char = "") {
|
|
8
|
+
if (NUMBER_CHAR_RE.test(char)) {
|
|
9
|
+
return void 0;
|
|
10
|
+
}
|
|
11
|
+
return char !== char.toLowerCase();
|
|
12
|
+
}
|
|
13
|
+
function splitByCase(str, separators) {
|
|
14
|
+
const splitters = STR_SPLITTERS;
|
|
15
|
+
const parts = [];
|
|
16
|
+
let buff = "";
|
|
17
|
+
let previousUpper;
|
|
18
|
+
let previousSplitter;
|
|
19
|
+
for (const char of str) {
|
|
20
|
+
const isSplitter = splitters.includes(char);
|
|
21
|
+
if (isSplitter === true) {
|
|
22
|
+
parts.push(buff);
|
|
23
|
+
buff = "";
|
|
24
|
+
previousUpper = void 0;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const isUpper = isUppercase(char);
|
|
28
|
+
if (previousSplitter === false) {
|
|
29
|
+
if (previousUpper === false && isUpper === true) {
|
|
30
|
+
parts.push(buff);
|
|
31
|
+
buff = char;
|
|
32
|
+
previousUpper = isUpper;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (previousUpper === true && isUpper === false && buff.length > 1) {
|
|
36
|
+
const lastChar = buff.at(-1);
|
|
37
|
+
parts.push(buff.slice(0, Math.max(0, buff.length - 1)));
|
|
38
|
+
buff = lastChar + char;
|
|
39
|
+
previousUpper = isUpper;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
buff += char;
|
|
44
|
+
previousUpper = isUpper;
|
|
45
|
+
previousSplitter = isSplitter;
|
|
46
|
+
}
|
|
47
|
+
parts.push(buff);
|
|
48
|
+
return parts;
|
|
49
|
+
}
|
|
50
|
+
function kebabCase(str, joiner) {
|
|
51
|
+
return str ? (Array.isArray(str) ? str : splitByCase(str)).map((p) => p.toLowerCase()).join("-") : "";
|
|
52
|
+
}
|
|
53
|
+
var enabled = z.boolean().default(true);
|
|
54
|
+
function resolveModuleName(moduleKey) {
|
|
55
|
+
return `@onderwijsin/nuxt-${kebabCase(moduleKey)}`;
|
|
56
|
+
}
|
|
57
|
+
function resolveLoggerScope(moduleKey) {
|
|
58
|
+
return kebabCase(moduleKey);
|
|
59
|
+
}
|
|
60
|
+
function transpileRuntime(nuxt, runtimeDir) {
|
|
61
|
+
nuxt.options.build.transpile.push(runtimeDir);
|
|
62
|
+
}
|
|
63
|
+
function moduleSetup(MODULE_NAME, options, log) {
|
|
64
|
+
const start = () => log.start(`Loading module ${MODULE_NAME}`);
|
|
65
|
+
const end = () => log.success(`Module ${MODULE_NAME} Loaded`);
|
|
66
|
+
const isEnabled = () => {
|
|
67
|
+
if ("enabled" in options && options.enabled === false) {
|
|
68
|
+
log.info(`Module ${MODULE_NAME} is disabled. Skipping setup...`);
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
};
|
|
73
|
+
return { start, end, isEnabled };
|
|
74
|
+
}
|
|
75
|
+
function validateModuleOptions(options, schema, log) {
|
|
76
|
+
const result = schema.safeParse(options);
|
|
77
|
+
if (result.success) {
|
|
78
|
+
return result.data;
|
|
79
|
+
}
|
|
80
|
+
log.info(z.prettifyError(result.error));
|
|
81
|
+
throw new Error("Invalid module options \u261D. Exiting.");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const version = "0.2.0";
|
|
85
|
+
|
|
86
|
+
const turnstileOptionsSchema = z.object({
|
|
87
|
+
enabled,
|
|
88
|
+
siteKey: z.string(),
|
|
89
|
+
secretKey: z.string(),
|
|
90
|
+
adminToken: z.string(),
|
|
91
|
+
adminHeaderName: z.string().min(1)
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const MODULE_KEY = "turnstile";
|
|
95
|
+
const MODULE_NAME = resolveModuleName(MODULE_KEY);
|
|
96
|
+
const DEFAULTS = {
|
|
97
|
+
enabled: true,
|
|
98
|
+
siteKey: "",
|
|
99
|
+
secretKey: "",
|
|
100
|
+
adminToken: "",
|
|
101
|
+
adminHeaderName: "x-admin-token"
|
|
102
|
+
};
|
|
103
|
+
const module$1 = defineNuxtModule({
|
|
104
|
+
meta: {
|
|
105
|
+
name: MODULE_NAME,
|
|
106
|
+
configKey: MODULE_KEY,
|
|
107
|
+
version,
|
|
108
|
+
compatibility: { nuxt: "^4.0.0" }
|
|
109
|
+
},
|
|
110
|
+
defaults: DEFAULTS,
|
|
111
|
+
moduleDependencies: {
|
|
112
|
+
"@nuxt/ui": { version: ">=4.0.0" },
|
|
113
|
+
"@nuxtjs/turnstile": { version: ">=1.1.3" }
|
|
114
|
+
},
|
|
115
|
+
setup(rawOptions, nuxt) {
|
|
116
|
+
const log = useLogger(resolveLoggerScope(MODULE_KEY));
|
|
117
|
+
const { start, end, isEnabled } = moduleSetup(MODULE_NAME, rawOptions, log);
|
|
118
|
+
start();
|
|
119
|
+
const options = validateModuleOptions(rawOptions, turnstileOptionsSchema, log);
|
|
120
|
+
const resolver = createResolver(import.meta.url);
|
|
121
|
+
const runtimeDir = resolver.resolve("./runtime");
|
|
122
|
+
addTypeTemplate({
|
|
123
|
+
filename: "types/turnstile-config.d.ts",
|
|
124
|
+
src: resolver.resolve(runtimeDir, "types/config.d.ts")
|
|
125
|
+
});
|
|
126
|
+
if (!isEnabled()) return;
|
|
127
|
+
nuxt.options.runtimeConfig.turnstile = defu(nuxt.options.runtimeConfig.turnstile, {
|
|
128
|
+
secretKey: options.secretKey,
|
|
129
|
+
adminToken: options.adminToken,
|
|
130
|
+
adminHeaderName: options.adminHeaderName
|
|
131
|
+
});
|
|
132
|
+
nuxt.options.runtimeConfig.public.turnstile = defu(
|
|
133
|
+
nuxt.options.runtimeConfig.public.turnstile,
|
|
134
|
+
{
|
|
135
|
+
siteKey: options.siteKey
|
|
136
|
+
}
|
|
137
|
+
);
|
|
138
|
+
if (!nuxt.options.turnstile) {
|
|
139
|
+
nuxt.options.turnstile = {};
|
|
140
|
+
}
|
|
141
|
+
nuxt.options.turnstile.siteKey ??= options.siteKey;
|
|
142
|
+
transpileRuntime(nuxt, runtimeDir);
|
|
143
|
+
addImportsDir(resolver.resolve(runtimeDir, "app", "composables"));
|
|
144
|
+
addServerScanDir(resolver.resolve(runtimeDir, "server"));
|
|
145
|
+
end();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
export { module$1 as default };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Minimal template-ref contract exposed by `NuxtTurnstile`. */
|
|
2
|
+
export interface TurnstileResetInstance {
|
|
3
|
+
reset: () => void;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Provides token lifecycle, retry, reset, and Nuxt UI feedback helpers.
|
|
7
|
+
* @returns Turnstile state and lifecycle helpers.
|
|
8
|
+
*/
|
|
9
|
+
export declare function useTurnstile(): {
|
|
10
|
+
token: import("vue").Ref<string | undefined, string | undefined>;
|
|
11
|
+
isEnabled: import("vue").ComputedRef<boolean>;
|
|
12
|
+
getToken: () => string | undefined;
|
|
13
|
+
getTokenWithRetry: (retries?: number, delayMs?: number) => Promise<string | undefined>;
|
|
14
|
+
isReady: () => boolean;
|
|
15
|
+
reset: (instance?: TurnstileResetInstance) => void;
|
|
16
|
+
showPendingHint: () => void;
|
|
17
|
+
showMissingTokenErrorHint: () => void;
|
|
18
|
+
captureTurnstileError: (error: unknown) => boolean;
|
|
19
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { computed, ref } from "vue";
|
|
2
|
+
import { useRuntimeConfig, useToast } from "#imports";
|
|
3
|
+
import { turnstileErrorDataSchema } from "../../types/errors.js";
|
|
4
|
+
export function useTurnstile() {
|
|
5
|
+
const runtimeConfig = useRuntimeConfig();
|
|
6
|
+
const token = ref();
|
|
7
|
+
const toast = useToast();
|
|
8
|
+
const isEnabled = computed(() => Boolean(runtimeConfig.public.turnstile?.siteKey?.trim()));
|
|
9
|
+
const getToken = () => token.value?.trim() || void 0;
|
|
10
|
+
async function getTokenWithRetry(retries = 12, delayMs = 250) {
|
|
11
|
+
if (!isEnabled.value) return void 0;
|
|
12
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
13
|
+
const current = getToken();
|
|
14
|
+
if (current) return current;
|
|
15
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
16
|
+
}
|
|
17
|
+
return void 0;
|
|
18
|
+
}
|
|
19
|
+
function reset(instance) {
|
|
20
|
+
token.value = void 0;
|
|
21
|
+
if (isEnabled.value) instance?.reset();
|
|
22
|
+
}
|
|
23
|
+
function showPendingHint() {
|
|
24
|
+
toast.add({
|
|
25
|
+
title: "Please wait",
|
|
26
|
+
description: "Security check in progress",
|
|
27
|
+
color: "warning"
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function showMissingTokenErrorHint() {
|
|
31
|
+
toast.add({
|
|
32
|
+
title: "Security check failed",
|
|
33
|
+
description: "Refresh the page and try again",
|
|
34
|
+
color: "error"
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function captureTurnstileError(error) {
|
|
38
|
+
const data = extractErrorData(error);
|
|
39
|
+
if (!turnstileErrorDataSchema.safeParse(data).success) return false;
|
|
40
|
+
showMissingTokenErrorHint();
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
token,
|
|
45
|
+
isEnabled,
|
|
46
|
+
getToken,
|
|
47
|
+
getTokenWithRetry,
|
|
48
|
+
isReady: () => !isEnabled.value || Boolean(getToken()),
|
|
49
|
+
reset,
|
|
50
|
+
showPendingHint,
|
|
51
|
+
showMissingTokenErrorHint,
|
|
52
|
+
captureTurnstileError
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function extractErrorData(error) {
|
|
56
|
+
if (!error || typeof error !== "object" || !("data" in error)) return void 0;
|
|
57
|
+
const data = error.data;
|
|
58
|
+
return data && typeof data === "object" && "data" in data ? data.data : data;
|
|
59
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const TURNSTILE_TOKEN_HEADER = "x-turnstile-token";
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { TURNSTILE_TOKEN_HEADER } from "./constants.js";
|
|
2
|
+
export { assertTurnstileToken, createTurnstileError, createTurnstileErrorData, isErrorWithStatusCode } from "./server/utils/turnstile.js";
|
|
3
|
+
export type { TurnstileErrorCode, TurnstileErrorData } from "./types/errors.js";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { H3Event } from "h3";
|
|
2
|
+
import type { TurnstileErrorCode, TurnstileErrorData } from "../../types/errors.js";
|
|
3
|
+
/**
|
|
4
|
+
* Validates the Turnstile token from a protected request.
|
|
5
|
+
* @param event - H3 request event.
|
|
6
|
+
* @param expectedAction - Action expected from the Turnstile response.
|
|
7
|
+
* @returns Resolves when the token is valid.
|
|
8
|
+
*/
|
|
9
|
+
export declare function assertTurnstileToken(event: H3Event, expectedAction: string): Promise<void>;
|
|
10
|
+
/** Creates a stable H3 error for Turnstile failures.
|
|
11
|
+
* @param statusCode - HTTP status code.
|
|
12
|
+
* @param statusMessage - Human-readable status message.
|
|
13
|
+
* @param code - Stable Turnstile error code.
|
|
14
|
+
* @param expectedAction - Optional expected action.
|
|
15
|
+
* @returns An H3 error with normalized Turnstile data.
|
|
16
|
+
*/
|
|
17
|
+
export declare function createTurnstileError(statusCode: number, statusMessage: string, code: TurnstileErrorCode, expectedAction?: string): Error;
|
|
18
|
+
/**
|
|
19
|
+
* Creates the serializable Turnstile error payload.
|
|
20
|
+
* @param code - Stable Turnstile error code.
|
|
21
|
+
* @param expectedAction - Optional expected action.
|
|
22
|
+
* @returns Serializable error data.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createTurnstileErrorData(code: TurnstileErrorCode, expectedAction?: string): TurnstileErrorData;
|
|
25
|
+
/**
|
|
26
|
+
* Narrows unknown exceptions to errors carrying an HTTP status code.
|
|
27
|
+
* @param error - Unknown exception.
|
|
28
|
+
* @returns Whether the exception has a numeric status code.
|
|
29
|
+
*/
|
|
30
|
+
export declare function isErrorWithStatusCode(error: unknown): error is {
|
|
31
|
+
statusCode: number;
|
|
32
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createError, getRequestHeader } from "h3";
|
|
2
|
+
import { useRuntimeConfig } from "#imports";
|
|
3
|
+
import { verifyTurnstileToken } from "@nuxtjs/turnstile/runtime/server/utils/verify.js";
|
|
4
|
+
import { isAdmin } from "module-utils/server";
|
|
5
|
+
import { attempt } from "module-utils/shared";
|
|
6
|
+
import { TURNSTILE_TOKEN_HEADER } from "../../constants.js";
|
|
7
|
+
export async function assertTurnstileToken(event, expectedAction) {
|
|
8
|
+
const config = useRuntimeConfig(event);
|
|
9
|
+
const turnstileConfig = config.turnstile;
|
|
10
|
+
if (isAdmin(event, turnstileConfig?.adminToken, turnstileConfig?.adminHeaderName ?? "x-admin-token"))
|
|
11
|
+
return;
|
|
12
|
+
const secretKey = turnstileConfig?.secretKey?.trim();
|
|
13
|
+
if (!secretKey) {
|
|
14
|
+
if (!import.meta.dev)
|
|
15
|
+
throw createTurnstileError(
|
|
16
|
+
500,
|
|
17
|
+
"Turnstile secret key is missing",
|
|
18
|
+
"TURNSTILE_SERVER_MISCONFIGURED",
|
|
19
|
+
expectedAction
|
|
20
|
+
);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const token = getRequestHeader(event, TURNSTILE_TOKEN_HEADER)?.trim();
|
|
24
|
+
if (!token)
|
|
25
|
+
throw createTurnstileError(
|
|
26
|
+
400,
|
|
27
|
+
"Turnstile token is missing",
|
|
28
|
+
"TURNSTILE_TOKEN_MISSING",
|
|
29
|
+
expectedAction
|
|
30
|
+
);
|
|
31
|
+
const result = await attempt(() => verifyTurnstileToken(token));
|
|
32
|
+
if (result.error !== null) {
|
|
33
|
+
if (isErrorWithStatusCode(result.error)) throw result.error;
|
|
34
|
+
throw createError({
|
|
35
|
+
statusCode: 502,
|
|
36
|
+
statusMessage: "Turnstile validation could not be performed",
|
|
37
|
+
data: createTurnstileErrorData("TURNSTILE_VALIDATION_UNAVAILABLE", expectedAction),
|
|
38
|
+
cause: result.error
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
const verification = result.data;
|
|
42
|
+
if (!verification.success)
|
|
43
|
+
throw createTurnstileError(
|
|
44
|
+
403,
|
|
45
|
+
"Turnstile validation failed",
|
|
46
|
+
"TURNSTILE_VALIDATION_FAILED",
|
|
47
|
+
expectedAction
|
|
48
|
+
);
|
|
49
|
+
if (verification.action && verification.action !== expectedAction)
|
|
50
|
+
throw createTurnstileError(
|
|
51
|
+
403,
|
|
52
|
+
"Turnstile action does not match",
|
|
53
|
+
"TURNSTILE_ACTION_MISMATCH",
|
|
54
|
+
expectedAction
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
export function createTurnstileError(statusCode, statusMessage, code, expectedAction) {
|
|
58
|
+
return createError({
|
|
59
|
+
statusCode,
|
|
60
|
+
statusMessage,
|
|
61
|
+
data: createTurnstileErrorData(code, expectedAction)
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
export function createTurnstileErrorData(code, expectedAction) {
|
|
65
|
+
return expectedAction ? { code, expectedAction } : { code };
|
|
66
|
+
}
|
|
67
|
+
export function isErrorWithStatusCode(error) {
|
|
68
|
+
return Boolean(
|
|
69
|
+
error && typeof error === "object" && "statusCode" in error && typeof error.statusCode === "number"
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
declare module "nuxt/schema" {
|
|
2
|
+
interface RuntimeConfig {
|
|
3
|
+
turnstile: {
|
|
4
|
+
secretKey: string;
|
|
5
|
+
adminToken: string;
|
|
6
|
+
adminHeaderName: string;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface PublicRuntimeConfig {
|
|
11
|
+
turnstile: { siteKey: string };
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const turnstileErrorCodeSchema: z.ZodEnum<{
|
|
3
|
+
TURNSTILE_TOKEN_MISSING: "TURNSTILE_TOKEN_MISSING";
|
|
4
|
+
TURNSTILE_VALIDATION_FAILED: "TURNSTILE_VALIDATION_FAILED";
|
|
5
|
+
TURNSTILE_ACTION_MISMATCH: "TURNSTILE_ACTION_MISMATCH";
|
|
6
|
+
TURNSTILE_VALIDATION_UNAVAILABLE: "TURNSTILE_VALIDATION_UNAVAILABLE";
|
|
7
|
+
TURNSTILE_SERVER_MISCONFIGURED: "TURNSTILE_SERVER_MISCONFIGURED";
|
|
8
|
+
}>;
|
|
9
|
+
export declare const turnstileErrorDataSchema: z.ZodObject<{
|
|
10
|
+
code: z.ZodEnum<{
|
|
11
|
+
TURNSTILE_TOKEN_MISSING: "TURNSTILE_TOKEN_MISSING";
|
|
12
|
+
TURNSTILE_VALIDATION_FAILED: "TURNSTILE_VALIDATION_FAILED";
|
|
13
|
+
TURNSTILE_ACTION_MISMATCH: "TURNSTILE_ACTION_MISMATCH";
|
|
14
|
+
TURNSTILE_VALIDATION_UNAVAILABLE: "TURNSTILE_VALIDATION_UNAVAILABLE";
|
|
15
|
+
TURNSTILE_SERVER_MISCONFIGURED: "TURNSTILE_SERVER_MISCONFIGURED";
|
|
16
|
+
}>;
|
|
17
|
+
expectedAction: z.ZodOptional<z.ZodString>;
|
|
18
|
+
}, z.core.$strip>;
|
|
19
|
+
export type TurnstileErrorCode = z.infer<typeof turnstileErrorCodeSchema>;
|
|
20
|
+
export type TurnstileErrorData = z.infer<typeof turnstileErrorDataSchema>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const turnstileErrorCodeSchema = z.enum([
|
|
3
|
+
"TURNSTILE_TOKEN_MISSING",
|
|
4
|
+
"TURNSTILE_VALIDATION_FAILED",
|
|
5
|
+
"TURNSTILE_ACTION_MISMATCH",
|
|
6
|
+
"TURNSTILE_VALIDATION_UNAVAILABLE",
|
|
7
|
+
"TURNSTILE_SERVER_MISCONFIGURED"
|
|
8
|
+
]);
|
|
9
|
+
export const turnstileErrorDataSchema = z.object({
|
|
10
|
+
code: turnstileErrorCodeSchema,
|
|
11
|
+
expectedAction: z.string().optional()
|
|
12
|
+
});
|
package/dist/types.d.mts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@onderwijsin/nuxt-turnstile",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Nuxt Turnstile integration with client helpers and server-side validation.",
|
|
5
|
+
"homepage": "https://github.com/onderwijsin/nuxt-modules#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/onderwijsin/nuxt-modules/issues"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/onderwijsin/nuxt-modules.git",
|
|
13
|
+
"directory": "modules/turnstile"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "./dist/module.mjs",
|
|
20
|
+
"types": "./dist/types.d.mts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/types.d.mts",
|
|
24
|
+
"import": "./dist/module.mjs"
|
|
25
|
+
},
|
|
26
|
+
"./runtime": {
|
|
27
|
+
"types": "./dist/runtime/index.d.ts",
|
|
28
|
+
"import": "./dist/runtime/index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@nuxt/kit": "4.5.2",
|
|
36
|
+
"@nuxt/schema": "4.5.2",
|
|
37
|
+
"@nuxt/ui": "4.10.0",
|
|
38
|
+
"@nuxtjs/turnstile": "1.1.3",
|
|
39
|
+
"defu": "6.1.7",
|
|
40
|
+
"h3": "1.15.11",
|
|
41
|
+
"zod": "4.4.3"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@nuxt/module-builder": "1.0.3",
|
|
45
|
+
"@nuxt/test-utils": "4.1.0",
|
|
46
|
+
"nuxt": "4.5.2",
|
|
47
|
+
"typescript": "5.9.3",
|
|
48
|
+
"unbuild": "3.6.1",
|
|
49
|
+
"module-utils": "0.0.0"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=22"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "nuxt-module-build build",
|
|
56
|
+
"typecheck": "tsc --noEmit --project tsconfig.json",
|
|
57
|
+
"dev": "npm run dev:prepare && nuxt dev playground",
|
|
58
|
+
"dev:build": "nuxt build playground",
|
|
59
|
+
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt-module-build build && nuxt prepare playground"
|
|
60
|
+
}
|
|
61
|
+
}
|