# Authorization & Sessions VitNode implements two isolated sessions: a **public session** for the community site and a separate **admin session** for the AdminCP. Both use opaque random tokens stored as SHA-256 hashes in PostgreSQL. ## Quick start [#quick-start] Customize session parameters in `apps/api/src/vitnode.api.config.ts`: ```ts title="apps/api/src/vitnode.api.config.ts" import { buildApiConfig } from "@vitnode/core/vitnode.config" export const vitNodeApiConfig = buildApiConfig({ // [!code ++:6] authorization: { cookieExpires: 1000 * 60 * 60 * 24 * 30, // 30 days for public users adminCookieExpires: 1000 * 60 * 60 * 8, // 8 hours for AdminCP cookieDomain: ".yourdomain.com", // Optional cross-subdomain sharing }, }) ``` *** ## The Two-Session Model [#the-two-session-model] | Session Type | Cookie Name | Default Lifetime | Storage Table | Purpose | | :----------------- | :------------------- | :--------------- | :---------------------------- | :----------------------------- | | **Public Session** | `vitnode_auth` | 90 days | `core_sessions` | Frontend member authentication | | **Admin Session** | `vitnode_auth_admin` | 1 day | `core_admin_sessions` | High-privilege AdminCP access | | **Known Device** | `vitnode_device` | 1 year | `core_sessions_known_devices` | Device authorization tracking | Signing out of the AdminCP does not terminate the user's public session, and vice versa. An administrator compromised in a public context cannot access the AdminCP without re-authenticating with staff credentials. *** ## Security Guarantees [#security-guarantees] * **SHA-256 Token Storage**: The raw token is stored only in the user's `HttpOnly` cookie. The database stores only its cryptographic hash. * **Device Pinning**: Tokens are bound to a verified device ID. Tokens copied to another device without the matching device cookie are rejected. * **Fast 60-Second Caching**: Active sessions are cached in Redis for up to 60 seconds, eliminating database query overhead on repeated requests. *** ## `authorization` Options Reference [#authorization-options-reference] ## Learn More [#learn-more] # Queue Tasks Queue tasks handle asynchronous, one-off background work (e.g. sending bulk emails, processing media, or scheduling posts). Tasks are persisted in the `core_queue` table and drained periodically by VitNode's background worker. ## Quick start [#quick-start] ### 1. Define a Queue Task [#1-define-a-queue-task] ```ts title="plugins/blog/src/api/tasks/send-newsletter.task.ts" import { buildQueueTask } from "@vitnode/core/api/lib/queue" export interface NewsletterPayload { postId: number } export const sendNewsletterTask = buildQueueTask({ name: "send-newsletter", handler: async (c, payload) => { await c.get("log").info(`Processing newsletter for post #${payload.postId}`) }, }) ``` *** ### 2. Register in an API Module [#2-register-in-an-api-module] Attach the task to your module's `queueTasks` list: ```ts title="plugins/blog/src/api/modules/posts/posts.module.ts" import { buildModule } from "@vitnode/core/api/lib/module" import { sendNewsletterTask } from "../../tasks/send-newsletter.task" export const postsModule = buildModule({ name: "posts", routes: [createPostRoute], queueTasks: [sendNewsletterTask], // [!code ++] }) ``` *** ### 3. Dispatch Tasks [#3-dispatch-tasks] Dispatch jobs from any Hono route or service: ```ts // Dispatch immediately await c.get("queue").dispatch({ name: "send-newsletter", payload: { postId: 42 }, }) // Or schedule for future execution await c.get("queue").dispatch({ name: "send-newsletter", payload: { postId: 42 }, executeAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour from now priority: 10, // Higher numbers run first }) ``` `dispatch` returns `{ id }` as soon as the task row is written to PostgreSQL. *** ## Retries and Error Handling [#retries-and-error-handling] VitNode tasks automatically retry on failure using exponential backoff: ```ts export const riskyTask = buildQueueTask({ name: "sync-external-api", maxAttempts: 5, // Default is 3 handler: async (c, payload) => { // If an error is thrown, the task retries automatically }, }) ``` | Attempt | Delay Before Next Retry | | :------------ | :--------------------------------- | | 1st failure | \~1 minute | | 2nd failure | \~4 minutes | | 3rd failure | \~9 minutes | | Final failure | Marked as `failed` in `core_queue` | *** ## AdminCP Queue Monitor [#admincp-queue-monitor] {/* Image prompt: VitNode AdminCP Queue Management screen at /admin/core/advanced/queue. Table displaying queued jobs with status badges (pending, processing, completed, failed), attempts count, payload JSON preview, and a retry action button. Dark theme, 1440x900. */} Inspect and debug tasks at **Core → Advanced → Queue** (`/admin/core/advanced/queue`): * Monitor pending, active, and failed jobs in real time. * View failure error traces and retry failed tasks with one click. *** ## `buildQueueTask` Options [#buildqueuetask-options] ## Learn More [#learn-more] # Rate Limiter VitNode includes automated IP-based rate limiting middleware. Exceeding the request budget immediately returns `429 Too Many Requests` with a `Retry-After` header, protecting authentication and public endpoints from brute-force attacks. ## Quick start [#quick-start] Customize the rate limiter budget in `apps/api/src/vitnode.api.config.ts`: ```ts title="apps/api/src/vitnode.api.config.ts" import { buildApiConfig } from "@vitnode/core/vitnode.config" export const vitNodeApiConfig = buildApiConfig({ // [!code ++:4] rateLimiter: { points: 60, // Max requests allowed duration: 60, // Window in seconds }, }) ``` The default configuration allows **80 requests per 60 seconds** per IP address. Rate limiting is automatically bypassed when `NODE_ENV=development` to prevent interruptions during local coding. *** ## 429 Error Response Format [#429-error-response-format] When a client exhausts their request allowance, the API returns a structured JSON error: ```http HTTP/1.1 429 Too Many Requests Retry-After: 24 Content-Type: application/json { "error": "Too Many Requests", "retryAfter": 24 } ``` *** ## Distributed Rate Limiting (Redis) [#distributed-rate-limiting-redis] * **Without Redis**: Counters are tracked in local memory per server instance. * **With Redis**: Counters are synchronized across all API containers using a distributed sliding window. Configure Redis in `vitnode.api.config.ts`: ```ts export const vitNodeApiConfig = buildApiConfig({ redis: process.env.REDIS_URL ? { url: process.env.REDIS_URL, password: process.env.REDIS_PASSWORD } : undefined, }) ``` *** ## `rateLimiter` Options [#ratelimiter-options] ## Learn More [#learn-more] # Redis [Redis](https://redis.io) serves as VitNode's shared cache and pub/sub broker across horizontally scaled instances. Redis is optional: without it, VitNode degrades gracefully to in-memory counters and local delivery. ## Quick start [#quick-start] ### 1. Configure Environment Variables [#1-configure-environment-variables] ```bash title=".env" REDIS_URL=redis://localhost:6379 // [!code ++] REDIS_PASSWORD=root // [!code ++] ``` ### 2. Connect in API Configuration [#2-connect-in-api-configuration] ```ts title="apps/api/src/vitnode.api.config.ts" import { buildApiConfig } from "@vitnode/core/vitnode.config" export const vitNodeApiConfig = buildApiConfig({ // [!code ++:5] redis: process.env.REDIS_URL ? { url: process.env.REDIS_URL, password: process.env.REDIS_PASSWORD } : undefined, }) ``` *** ## What Redis Powers [#what-redis-powers] | Feature | Without Redis | With Redis | | :--------------------- | :-------------------------------- | :------------------------------------------ | | **API Domain Cache** | No-op (always executes DB query) | Shared key-value store via `c.get("cache")` | | **Rate Limiter** | In-memory counters per instance | Distributed sliding-window across cluster | | **WebSocket Realtime** | Single instance only | Cross-instance broadcasts via Redis Pub/Sub | | **Sessions** | Database queries on every request | Instant cache hits with DB fallback | *** ## Local Development (Docker) [#local-development-docker] Start a Redis container with Docker Compose: ```yaml title="docker-compose.yml" services: redis: image: redis:7-alpine restart: always ports: - "6379:6379" command: redis-server --requirepass root ``` *** ## Using the Cache in API Routes [#using-the-cache-in-api-routes] Use `remember` to cache expensive database lookups: ```ts const posts = await c.get("cache").remember({ key: "featured_posts", ttlSeconds: 60, // 1 minute loader: async () => await fetchFeaturedPostsFromDB(), }) ``` *** ## Verifying Redis in AdminCP [#verifying-redis-in-admincp] {/* Image prompt: VitNode AdminCP System -> Diagnostics screen at /admin/core/advanced/debug. System health card shows Redis status "Connected" with latency graph, memory usage, and ping metrics. Dark theme, 1440x900. */} Check Redis connection health anytime under **Core → Advanced → Debug** (`/admin/core/advanced/debug`). ## Learn More [#learn-more] # AI Setup VitNode integrates the [Vercel AI SDK](https://ai-sdk.dev). Register models in `vitnode.api.config.ts` once, then resolve them inside any API handler via `c.get("ai")`. ## Quick start [#quick-start] ### 1. Configure Models in API Config [#1-configure-models-in-api-config] You can configure models via string identifiers (using AI Gateway) or direct provider instances: ```ts title="apps/api/src/vitnode.api.config.ts" import { openai } from "@ai-sdk/openai" import { buildApiConfig } from "@vitnode/core/vitnode.config" export const vitNodeApiConfig = buildApiConfig({ // [!code ++:10] ai: { models: [ { id: "default", name: "GPT-4o Mini", model: openai("gpt-4o-mini"), }, ], }, }) ``` Set `OPENAI_API_KEY` in your `.env` file. *** ### 2. Use in Route Handlers [#2-use-in-route-handlers] ```ts import { generateText } from "ai" handler: async (c) => { // [!code ++:4] const { text } = await generateText({ model: c.get("ai").model(), // Resolves the configured "default" model prompt: "Write a summary of this discussion.", }) return c.json({ text }) } ``` *** ## Multiple Models & Embeddings [#multiple-models--embeddings] Configure dedicated models for fast completions, reasoning, and vector embeddings: ```ts title="apps/api/src/vitnode.api.config.ts" ai: { models: [ { id: "default", name: "GPT-4o Mini", model: openai("gpt-4o-mini") }, { id: "reasoning", name: "Claude 3.5 Sonnet", model: anthropic("claude-3-5-sonnet-20241022") }, ], embeddingModels: [ { id: "default", name: "Text Embedding 3", model: openai.embedding("text-embedding-3-small") }, ], } ``` Resolve specific models by ID: ```ts const smartModel = c.get("ai").model("reasoning") const embedModel = c.get("ai").embeddingModel() ``` ## Learn More [#learn-more] # AI Usage VitNode integrates the [Vercel AI SDK](https://ai-sdk.dev). Resolve models from `c.get("ai")` and call native SDK functions directly. ## Model Resolvers [#model-resolvers] | Resolver | Returned Type | Used By | | :-------------------------------- | :--------------- | :--------------------------------------------- | | `c.get("ai").model(id?)` | `LanguageModel` | `generateText`, `streamText`, `generateObject` | | `c.get("ai").embeddingModel(id?)` | `EmbeddingModel` | `embed`, `embedMany` | | `c.get("ai").imageModel(id?)` | `ImageModel` | `generateImage` | *** ## 1. Text Generation [#1-text-generation] Generate text responses in a Hono API route: ```ts title="plugins/blog/src/api/modules/posts/routes/summarize.route.ts" import { buildRoute } from "@vitnode/core/api/lib/route" import { generateText } from "ai" import { z } from "zod" export const summarizeRoute = buildRoute({ pluginId: "blog", route: { method: "post", path: "/summarize", request: { body: { content: { "application/json": { schema: z.object({ text: z.string().min(1) }) }, }, }, }, responses: { 200: { description: "Text summary" }, }, }, handler: async (c) => { const { text } = c.req.valid("json") // [!code ++:6] const { text: summary } = await generateText({ model: c.get("ai").model(), system: "You are a concise summary assistant.", prompt: `Summarize the following content: ${text}`, }) return c.json({ summary }) }, }) ``` *** ## 2. Streaming Responses [#2-streaming-responses] Stream LLM responses directly to the client: ```ts import { streamText } from "ai" handler: async (c) => { const result = streamText({ model: c.get("ai").model(), prompt: "Write an introduction to WebSockets.", }) // [!code ++:1] return result.toDataStreamResponse() } ``` *** ## 3. Structured Output (`generateObject`) [#3-structured-output-generateobject] Extract strongly typed JSON from model completions using Zod: ```ts import { generateObject } from "ai" import { z } from "zod" const postMetadataSchema = z.object({ title: z.string(), tags: z.array(z.string()), estimatedReadingMinutes: z.number(), }) // [!code ++:6] const { object } = await generateObject({ model: c.get("ai").model(), schema: postMetadataSchema, prompt: "Generate SEO metadata for an article on Postgres indexing.", }) ``` *** ## 4. Generating Embeddings [#4-generating-embeddings] Calculate vector embeddings for semantic search: ```ts import { embed } from "ai" // [!code ++:4] const { embedding } = await embed({ model: c.get("ai").embeddingModel(), value: "How to configure Redis caching in VitNode", }) ``` ## Learn More [#learn-more] # Architecture VitNode separates concerns between two core layers: 1. **TanStack Start**: Frontend UI, SSR, isomorphic routing, and client caching. 2. **Hono API**: Backend routing, session management, staff permissions, and database operations. {/* Image prompt: VitNode architectural flow diagram displaying TanStack Start (SSR, route loaders, TanStack Query) communicating across an HTTP/RPC boundary with Hono API (middleware, sessions, Drizzle ORM) and PostgreSQL/Redis storage. Dark theme, 1600x900. */} ## System Boundaries [#system-boundaries] | Responsibility | TanStack Start (Web App) | Hono (API) | | :-------------------- | :-------------------------------------------- | :-------------------------------------------------------------- | | **Routing** | Page URLs, dynamic parameters, nested layouts | REST/RPC endpoints under `/api/*` | | **Data Fetching** | Route loaders and the universal `fetcher` | Query execution via Drizzle ORM | | **State & Cache** | TanStack Query client cache | Redis domain cache & database storage | | **Security Boundary** | UI guards (redirecting unauthenticated users) | **Enforces authentication, permissions, CSRF, and rate limits** | Route guards (`beforeLoad`) enhance UX by redirecting visitors early, but the Hono API is the true security boundary. All private endpoints strictly verify cookies and permissions on every request. *** ## End-to-End Request Flow [#end-to-end-request-flow] When a user visits a page (e.g. `/blog`): | Phase | Runtime | Action | | :------------------------ | :--------------------- | :------------------------------------------------------------------ | | **1. Request** | Browser | Visitor navigates to `/blog` | | **2. Routing** | Server (SSR) / Browser | TanStack Router matches route and executes `loader` | | **3. Query Warming** | Server / Browser | `context.queryClient.ensureQueryData` executes isomorphic fetcher | | **4. RPC Call** | Server / Browser | `fetcher` (server) or `fetcherClient` (browser) calls Hono endpoint | | **5. API Middleware** | Server (Hono) | Verifies session cookie, applies rate limits, injects `c.get(db)` | | **6. Handler & Database** | Server (Hono) | Handler validates input and queries PostgreSQL via Drizzle | | **7. Response** | Server / Browser | JSON data hydrates TanStack Query cache and paints component | *** ## The Request Pipeline [#the-request-pipeline] Before route matching, every request passes through the middleware `createVitNodeStart` installs - in this order, and an app cannot get in front of any of it: | Order | Middleware | Applies to | | :---- | :----------------- | :------------------------------------------------------------- | | 1 | **CSRF** | Server function calls (`handlerType === 'serverFn'`) | | 2 | **Locale** | Page requests: canonical `308` redirects and the locale cookie | | 3 | **Document cache** | HTML responses: forced `Cache-Control: private, no-store` | | 4 | Your own | Whatever `requestMiddleware` lists | `/api/*` reaches the same middleware and passes through untouched - no redirect, no rewrite, no cache directive - so the Hono bridge sees the request exactly as the client sent it and keeps its own caching policy. See [Configuration](/docs/dev/configuration). *** ## Plugin System Architecture [#plugin-system-architecture] VitNode is built around modular plugins located in `plugins/*`: * **Independent Packages**: Plugins compile to their own `dist/` with isolated dependencies. * **Unified Manifest**: Routes, AdminCP navigation, and database models are registered declaratively. * **Zero Overhead**: Inactive plugins contribute no code or overhead to production bundles. ## Learn More [#learn-more] # Cache VitNode uses a simple two-layer caching model: 1. **App cache**: what the frontend keeps, so a page does not re-ask for something it already has. 2. **Redis Cache (API)**: Caches database queries and heavy computations inside Hono route handlers. Caching is **opt-in**. Dynamic data stays fresh by default. [`fetcher()`](/docs/dev/fetcher) forwards the visitor's cookies, so a stored response is one visitor's data handed to another. It never caches. The two layers below are where caching belongs. *** ## App Caching (TanStack Query) [#app-caching-tanstack-query] Every read a route makes goes through TanStack Query, and that *is* the app cache: a route's `loader` warms an entry, the component reads the same one back, and a mutation invalidates exactly what it changed. One request per navigation instead of one per component. ### Load it from the plugin route [#load-it-from-the-plugin-route] ```ts title="plugins/announcements/src/pages/announcements-page.tsx" import { definePluginRoute } from '@vitnode/core/routing' export const route = definePluginRoute({ load: async () => await fetchAnnouncements(), // [!code ++] }) ``` ```tsx title="announcements-screen.tsx" const { data } = useSuspenseQuery(announcementsQueryOptions()) ``` Keep the fetcher and any `queryOptions` helper inside the plugin too. The plugin route is the SSR boundary; its screen can reuse the same query key for client updates. See [Data loading](/docs/dev/data-loading) for the isomorphic fetcher. ### Pick a lifetime that matches the data [#pick-a-lifetime-that-matches-the-data] `staleTime` is the whole configuration surface. Public data that changes rarely can sit for minutes; anything per-visitor should be short or zero: ```ts export const announcementsQueryOptions = () => queryOptions({ queryKey: ['@acme/announcements', 'announcements'], // [!code ++] queryFn: fetchAnnouncements, staleTime: 5 * 60 * 1000, }) ``` `invalidateQueries` matches prefixes. Put the plugin ID first so one plugin cannot read from or invalidate another plugin's similarly named key. A session, a permission set or a personal file list must not share a cache entry with anybody else. Key it by the identity it belongs to, and keep the database work behind it in the API's Redis layer instead - that is where a session read is already cached, with explicit invalidation on every mutation that changes the answer. *** ## Invalidation [#invalidation] A write invalidates what it changed. `invalidateQueries` matches by key prefix, so invalidate the narrowest root that covers the rows a mutation could have moved: ```ts title="publish-announcement.ts" const queryClient = useQueryClient() await publishAnnouncement(id) await queryClient.invalidateQueries({ queryKey: ['@acme/announcements', 'announcements'], // [!code ++] }) ``` A row that could be on any page under any sort means invalidating the list's root, not one page of it - the changed row may have moved. Content Engine entries are tagged and expired for you. A background mutation cannot expire a frontend's cache by calling a function, so `dispatchContentRevalidation` posts to the origins an install opts into via `content.revalidateOrigins`. See [Content Engine Caching](/docs/dev/content-engine/public-api-and-caching). *** ## API Caching (Redis) [#api-caching-redis] Inside your Hono route handlers, `c.get("cache")` stores expensive query results in Redis. Keys are namespaced per plugin, so the `stats:42` your plugin writes actually lives at `vitnode:cache:@vitnode/example:stats:42` and cannot collide with another plugin's. ### Wrap the read in `remember` [#wrap-the-read-in-remember] `remember` takes a key, a TTL in seconds, and the loader to run on a miss. It returns the value either way, so the call site never branches: ```ts title="plugins/announcements/src/api/modules/stats/routes/overview.route.ts" handler: async c => { // [!code ++:5] const stats = await c.get('cache').remember( `stats:${containerId}`, 60 * 5, async () => await calculateHeavyStats(c, containerId), ) return c.json(stats) }, ``` Only a non-`null` value counts as a hit, so a loader that legitimately answers `null` re-runs every time. Wrap it - `{ stats }` rather than a bare nullable - if that is a miss you cannot afford. ### Delete the key when the record changes [#delete-the-key-when-the-record-changes] The write that changes the answer is the write that expires it. There is no TTL short enough to substitute for this: ```ts title="plugins/announcements/src/api/modules/stats/routes/update.route.ts" await c.get('cache').delete(`stats:${containerId}`) // [!code ++] ``` `delete` also takes an array of keys. `flush()` drops every key belonging to the current plugin and leaves other plugins - and any unrelated data in the same Redis instance - untouched. ### Verify it is actually caching [#verify-it-is-actually-caching] Wire up `redis` in `buildApiConfig` (see [Redis setup](/docs/dev/advanced/redis)), restart, then open **System → Integrations** in the AdminCP: Redis reads *active* when it is connected and *configured but unreachable* when the URL is wrong. Call the route twice and only the first call should reach the database. Redis is optional. If not configured, `c.get("cache")` gracefully acts as a no-op and runs the callback directly - which is one way to spell "runs your loader". Nothing you write against it needs a fallback branch. ## Next [#next] # Cloudflare Turnstile Turnstile is the provider to reach for first: a visible widget, no score to tune, and a set of dummy keys that work on `localhost`. There is nothing to install - the provider lives in core - so the whole job is two keys and one config block. ## Quick start [#quick-start] If you already have a site key and a secret key, this is the entire integration. ```bash title=".env" CLOUDFLARE_TURNSTILE_SITE_KEY=0x4AAAAAAA... CLOUDFLARE_TURNSTILE_SECRET_KEY=0x4AAAAAAA... ``` ```ts title="src/vitnode.api.config.ts" import { buildApiConfig } from '@vitnode/core/vitnode.config' export const vitNodeApiConfig = buildApiConfig({ plugins: [], // [!code ++:5] captcha: { type: 'cloudflare_turnstile', siteKey: process.env.CLOUDFLARE_TURNSTILE_SITE_KEY, secretKey: process.env.CLOUDFLARE_TURNSTILE_SECRET_KEY, }, }) ``` Restart the API and the widget appears on `/register`, and on `/login/reset-password` on a deployment that has an [email adapter](/docs/dev/email) to send the link with. The rest of this page is how to get those two values. An empty `secretKey` does not disable captcha - it breaks it. `captchaMiddleware` steps aside only when the whole `captcha` block is missing, so a deployment with the block and no keys **rejects** registration and password reset with `400`. Gate the block on the environment variable: ```ts title="src/vitnode.api.config.ts" export const vitNodeApiConfig = buildApiConfig({ // [!code ++:7] captcha: process.env.CLOUDFLARE_TURNSTILE_SECRET_KEY ? { type: 'cloudflare_turnstile', siteKey: process.env.CLOUDFLARE_TURNSTILE_SITE_KEY, secretKey: process.env.CLOUDFLARE_TURNSTILE_SECRET_KEY, } : undefined, }) ``` ## Create the widget [#create-the-widget] ### Sign in to Cloudflare [#sign-in-to-cloudflare] Open the [Cloudflare dashboard](https://dash.cloudflare.com/) and sign in. Turnstile is an independent product - Cloudflare's own words are that you can use it "on any website, regardless of whether it is proxied through the Cloudflare network" - so there is no DNS to move before you start. ### Open Turnstile [#open-turnstile] Pick **Turnstile** in the account sidebar, below **WAF**. ### Add a widget [#add-a-widget] Press **Add widget** and fill in three things, then **Create**: | Field | What to put in it | | ----------------------- | ------------------------------------------------------------------------ | | **Widget name** | Anything - it is internal. Your site's name is a fine answer. | | **Hostname management** | Every hostname you serve the form from, e.g. `your-domain.com`. | | **Widget mode** | **Managed** unless you have a reason. All three modes work with VitNode. | {/* Image prompt: The Cloudflare Turnstile "Add widget" form filled in with a widget name, one entry under Hostname management, and the "Managed" widget mode option selected out of Managed / Non-Interactive / Invisible, light theme, 1100x700. */} ### Copy the site key and the secret key [#copy-the-site-key-and-the-secret-key] Cloudflare shows both immediately after the widget is created, and you can come back to them from the widget's **Settings** tab at any time. The **site key** is public - the API publishes it so the browser can load the widget. The **secret key** is not: it is only ever sent from your API to Cloudflare's `siteverify` endpoint. {/* Image prompt: The Cloudflare Turnstile widget detail page showing the "Site Key" and "Secret Key" fields with copy buttons, values partially redacted, light theme, 1100x500. */} ### Set the environment variables [#set-the-environment-variables] These names are not magic - they are whatever you read in your config on the next step. What matters is that both are server-side variables, because the secret key must never reach a browser bundle. ```bash title=".env" CLOUDFLARE_TURNSTILE_SITE_KEY=0x4AAAAAAA... # [!code ++] CLOUDFLARE_TURNSTILE_SECRET_KEY=0x4AAAAAAA... # [!code ++] ``` ### Register the provider [#register-the-provider] Add the `captcha` block to your API config with `type: 'cloudflare_turnstile'`. ```ts title="src/vitnode.api.config.ts" import { buildApiConfig } from '@vitnode/core/vitnode.config' export const vitNodeApiConfig = buildApiConfig({ plugins: [], // [!code ++:5] captcha: { type: 'cloudflare_turnstile', siteKey: process.env.CLOUDFLARE_TURNSTILE_SITE_KEY, secretKey: process.env.CLOUDFLARE_TURNSTILE_SECRET_KEY, }, }) ``` The config is evaluated when the process boots, so restart the API rather than waiting for a hot reload to notice. ### Verify it works [#verify-it-works] Ask the API what it thinks it has configured. The site key comes back on the public middleware route: ```bash curl http://localhost:3000/api/@vitnode/core/middleware # {"isEmail":false,"sso":[],"captcha":{"siteKey":"0x4AAAAAAA...","type":"cloudflare_turnstile"}} ``` Then open `/register`. A Turnstile widget renders just above the **Register** button, and the button stays disabled until the widget reports success. The AdminCP confirms it from the other side: **AdminCP → System → Integrations** (`/admin/core/system/integrations`) shows the **Captcha** card as *Active*, with **Cloudflare Turnstile** underneath. ## Test keys for local development [#test-keys-for-local-development] Cloudflare publishes dummy keys that work on any hostname, `localhost` included. Use them while you are wiring the form up, and to reproduce failures on purpose. The two halves are independent: a site key decides what the widget does in the browser, a secret key decides what `siteverify` answers. Pick one of each. ### Test site keys [#test-site-keys] | Site key | Widget | What it does | | -------------------------- | --------- | ------------------------------- | | `1x00000000000000000000AA` | Visible | Always passes | | `2x00000000000000000000AB` | Visible | Always fails | | `1x00000000000000000000BB` | Invisible | Always passes | | `2x00000000000000000000BB` | Invisible | Always fails | | `3x00000000000000000000FF` | Visible | Forces an interactive challenge | ### Test secret keys [#test-secret-keys] | Secret key | What `siteverify` answers | | ------------------------------------- | ------------------------- | | `1x0000000000000000000000000000000AA` | Always passes | | `2x0000000000000000000000000000000AA` | Always fails | | `3x0000000000000000000000000000000AA` | `token already spent` | A test site key mints the token `XXXX.DUMMY.TOKEN.XXXX`, which a real secret key rejects - so mixing a dummy site key with a production secret is its own kind of `400`. All of them come from [Cloudflare's testing reference](https://developers.cloudflare.com/turnstile/troubleshooting/testing/), which is also where the failure modes each one reproduces are spelled out. ## Widget modes [#widget-modes] All three of Cloudflare's modes work, because VitNode renders the widget through `turnstile.render` and waits for its callback either way. | Mode | What the visitor sees | Submit button | | ------------------- | ------------------------------------------ | ---------------------------------- | | **Managed** | A checkbox, only when Cloudflare wants one | Unlocks when the widget calls back | | **Non-Interactive** | A spinner, and never a checkbox | Unlocks when the widget calls back | | **Invisible** | Nothing at all | Unlocks when the widget calls back | Invisible mode is the one worth thinking twice about: the button is disabled for a moment with nothing on screen explaining why. The widget also inherits two things from the page it renders on - the visitor's locale, and your site's resolved theme - so it follows the language switcher and dark mode with no configuration of its own. ## Gotchas [#gotchas] Turnstile refuses to render for a hostname that is not on the widget's list, and VitNode's submit button simply never unlocks. There is no toast and no server error, because nothing was ever submitted. Check the browser console for Turnstile's own message, then add the hostname - or switch to the test keys above while developing. `siteverify` answers `success: true` or `success: false`, and VitNode scores that as `1` or `0`. How much the visitor is asked to do is the widget mode you picked in Cloudflare's dashboard; how suspicious they looked is Cloudflare's call, and it is not a number you get to see or set. If you want one to tune, that is [reCAPTCHA v3](/docs/dev/captcha/recaptcha). The API config reads `process.env` when the process starts, so a new key in `.env` only takes effect after you restart the dev server. ## Next [#next] # Custom forms `AutoForm` handles captcha for you, and most of the time that is the end of it. When you are rendering the form yourself - a hand-built `
`, a wizard, a component that is not an `AutoForm` at all - `useCaptcha` is the same three values `AutoForm` uses internally. There is no captcha *adapter* to write: the two providers live in core, and `type` is a closed union. What this page is about is the client half. ## Quick start [#quick-start] Three values, and a `
` for the widget to land in. ```tsx title="plugins/contact/src/views/contact/contact-form.tsx" import type React from 'react' import { useCaptcha } from '@vitnode/core/hooks/use-captcha' // [!code ++] import { type MiddlewareConfig } from '@vitnode/core/tanstack/auth' import { sendMessage } from './send-message' export const ContactForm = ({ captcha, }: { captcha: MiddlewareConfig['captcha'] }) => { const { isReady, getToken, onReset } = useCaptcha(captcha) // [!code ++] const onSubmit = async (event: React.FormEvent) => { event.preventDefault() await sendMessage({ message: new FormData(event.currentTarget).get('message') as string, captchaToken: await getToken(), // [!code ++] }) onReset() // [!code ++] } return (