diff --git a/docs/guide/ai.md b/docs/guide/ai.md index aba223c15..0617b4940 100644 --- a/docs/guide/ai.md +++ b/docs/guide/ai.md @@ -133,7 +133,7 @@ const result = await ai.run('google/gemini-2.5-flash', { }); ``` -Pass AI Gateway options as the third argument: `ai.run(model, inputs, { gateway: { skipCache: true } })`. +Pass AI Gateway options as the third argument: `ai.run(model, inputs, { gateway: { skipCache: true } })`. On a team platform, the platform chooses the gateway and sets its metadata and logging; options such as `skipCache` and `cacheTtl` still apply. For provider-native requests on a direct deployment, configure [`ai.gateway`](../integrations/cloudflare.md#ai-self-host). @@ -142,7 +142,9 @@ For provider-native requests on a direct deployment, configure [`ai.gateway`](.. Use `ai.provider(provider).fetch(path, init)` for a provider-native API with your own provider key. The request goes through the configured Cloudflare AI Gateway and retains the provider's native HTTP shape. On a team platform, it also passes -through that installation's proxy. +through that installation's proxy. AI Gateway cache, timeout and retry headers, +such as `cf-aig-cache-ttl` and `cf-aig-max-attempts`, still apply there; the +platform ignores logging, metadata and cost headers. ### OpenAI diff --git a/docs/guide/auth.md b/docs/guide/auth.md index 77ada7a1c..40617b404 100644 --- a/docs/guide/auth.md +++ b/docs/guide/auth.md @@ -127,6 +127,42 @@ Auth sessions use the same database as your app. Void creates `BETTER_AUTH_SECRE Local development needs no secret setup. To preview a production build locally, provide `BETTER_AUTH_SECRET`. +`POST`, `PUT`, `PATCH` and `DELETE` requests and WebSocket connections from another origin don't use the signed-in session unless that origin is in `trustedOrigins`. If a separate frontend calls your app, add its origin there (see [Customization](#customization)). If your routes use their own cookies, check the request `Origin` yourself, for example with Hono's [`csrf()`](https://hono.dev/docs/middleware/builtin/csrf) middleware. + +Deployed apps on Cloudflare limit auth requests per client IP with Better Auth's defaults, for example 3 sign-in attempts per 10 seconds. Each Worker instance keeps its own count. Change or turn off the limits with `rateLimit` in `auth.ts`. To share one count across instances, set `rateLimit: { storage: 'database' }`, then run `void db generate` for a direct Cloudflare deploy. + +### Proxies and end-to-end tests + +All requests from one IP share one count. Two common setups reach the limits: + +- A server calls your auth endpoints for your users, such as a backend-for-frontend, a rewrite from Vercel or nginx, or another Worker's `fetch`. All of those users share that server's IP. +- End-to-end tests sign up or sign in many times against a deployed app. + +Don't read the client IP from `x-forwarded-for` instead: any client can send that header. Give the trusted caller a secret header, and skip the limits only for requests that carry it: + +```ts +import { defineAuth } from 'void/auth'; + +export default defineAuth(({ defaults, env }) => ({ + ...defaults, + rateLimit: { + customRules: { + '/**': (request, rule) => + env.AUTH_TRUSTED_KEY && request.headers.get('x-auth-trusted-key') === env.AUTH_TRUSTED_KEY + ? false + : rule, + }, + }, +})); +``` + +`'/**'` matches every auth endpoint. Returning `false` skips the limit, and returning `rule` keeps the default limit for everyone else. Declare `AUTH_TRUSTED_KEY: string().optional()` in `env.ts` and set it with `void secret put AUTH_TRUSTED_KEY` (see [Environment variables](./env-vars.md#production-server-values)). + +Your app no longer limits requests that carry the key, so: + +- A proxy that sends it must limit every auth request it forwards for each of its users, including sign-up, password reset and verification emails. +- A test runner should send it only to your app. Never put the key in code that runs in your users' browsers. + ## Customization For advanced configuration, create `auth.ts` at the project root and export `defineAuth(...)`: diff --git a/docs/guide/edge/revalidation.md b/docs/guide/edge/revalidation.md index daf6812aa..e5f152987 100644 --- a/docs/guide/edge/revalidation.md +++ b/docs/guide/edge/revalidation.md @@ -108,7 +108,7 @@ Requests with `Cookie`, `Authorization`, or Cloudflare Access's `Cf-Access-Jwt-A For a dispatch rewrite, the cache key includes both the destination and original pathname. A direct request to `/en/docs/foo` therefore uses a different entry from `/docs/foo` rewritten to that destination. -Query parameters are excluded by default. Add `routing.revalidateQueryAllowlist` when specific parameters should produce separate cached responses. Middleware `c.rewrite()` runs after the ISR lookup, so it doesn't create an additional cache variant. +Query parameters are excluded by default. Add `routing.revalidateQueryAllowlist` when specific parameters should produce separate cached responses. Requests with other query parameters get the cached page. If nothing is cached yet, they render live and are not stored, so add every parameter a page reads to `routing.revalidateQueryAllowlist`. Middleware `c.rewrite()` runs after the ISR lookup, so it doesn't create an additional cache variant. Purge the destination pathname with `revalidate({ paths })`. Purging `/en/docs/foo` clears both direct and rewritten variants. Purging only `/docs/foo` won't clear them, because the entries are stored under the destination. diff --git a/docs/guide/jobs.md b/docs/guide/jobs.md index 67ae22dfe..205954673 100644 --- a/docs/guide/jobs.md +++ b/docs/guide/jobs.md @@ -75,3 +75,5 @@ The endpoint works in native Void apps and supported frameworks. Framework jobs ## Deployment behavior Native scheduled events do not require a manual HTTP token. For framework deployments, HTTP requests to `/__void/scheduled` require a matching `x-void-internal` token: the managed platform supplies its proxy token, or you can explicitly configure `CRON_SECRET` for manual calls. An absent or mismatched token returns `401` without running a job. + +On a Void platform, each project can use up to 100 different cron expressions and 1,000 schedules (each expression a job exports counts as one schedule). Jobs that share an expression count once toward the expression limit. When you deploy to your own Cloudflare account, Cloudflare's [Cron Trigger limits](https://developers.cloudflare.com/workers/platform/limits/) apply instead. diff --git a/docs/guide/live.md b/docs/guide/live.md index f89301623..f862face6 100644 --- a/docs/guide/live.md +++ b/docs/guide/live.md @@ -22,6 +22,8 @@ export const live = defineLiveStream({ }); ``` +This example accepts visitors who are not signed in. Without `onSubscribe`, any connection can subscribe to any topic, so read [Authorization](#authorization) before you publish private data. + Void enables live streams when a [scanned file](../reference/resource-inference.md#scanned-directories) imports `void/live`, so keep this module in `src/` or another scanned directory. To keep it in an unscanned directory such as `lib/`, add that directory to [`inference.scanDirs`](../reference/resource-inference.md#custom-scan-directories) and list the default directories you still use, because `scanDirs` replaces them. Expose it from a normal route: @@ -101,6 +103,8 @@ await live.withEnv(env).publish('post:12', { title: 'Updated title' }); Only a connection's owner can change its subscriptions. The example above allows anonymous connections; authenticated streams use the current Void user by default. +The owner decides who can change a connection's subscriptions, not which topics the connection can read. + For authenticated streams, pass the same owner key to `connect()` and `control()`: @@ -127,16 +131,17 @@ current authenticated user and uses `user:${user.id}` when the user has a string `id`. If neither path produces an owner and `allowAnonymousControl` is not set, the request is rejected with `403`. -For stream-local subscription rules, use `onSubscribe`: +Without `onSubscribe`, any connection that passes the owner check (any signed-in user, or any visitor when `allowAnonymousControl` is set) can subscribe to any topic and receives everything published to it. Topic names are easy to guess. Use `onSubscribe` to decide who can read each topic: return a `Response` to reject the subscription, or return nothing to allow it. ```ts export const live = defineLiveStream({ id: 'app', async onSubscribe(ctx) { - const match = /^post:(.+)$/.exec(ctx.topic); - if (match && !(await canReadPost(ctx.env, ctx.user, match[1]))) { - return new Response('Forbidden', { status: 403 }); - } + const post = /^post:(.+)$/.exec(ctx.topic); + if (post && (await canReadPost(ctx.env, ctx.user, post[1]))) return; + const inbox = /^user:(.+):inbox$/.exec(ctx.topic); + if (inbox && ctx.owner === `user:${inbox[1]}`) return; + return new Response('Forbidden', { status: 403 }); }, }); ``` diff --git a/docs/guide/pages-routing/actions-and-forms.md b/docs/guide/pages-routing/actions-and-forms.md index 0fef233dc..b7e0d6942 100644 --- a/docs/guide/pages-routing/actions-and-forms.md +++ b/docs/guide/pages-routing/actions-and-forms.md @@ -355,20 +355,32 @@ On the server, use `c.req.parseBody()` to access the uploaded file: ```ts // pages/photos.server.ts -import { defineHandler } from 'void'; +import { defineHandler, ValidationError } from 'void/handler'; import { storage } from 'void/storage'; +// file.name and file.type come from the browser. Don't use them as the key or trust the type. +const imageTypes = new Map([ + ['image/jpeg', 'jpg'], + ['image/png', 'png'], + ['image/webp', 'webp'], +]); + export const action = defineHandler(async (c) => { const body = await c.req.parseBody(); - const file = body['photo'] as File; - if (file && file.size > 0) { - await storage.put(file.name, file.stream(), { - httpMetadata: { contentType: file.type }, - }); + const file = body['photo']; + if (!(file instanceof File) || file.size === 0) return; + const ext = imageTypes.get(file.type); + if (!ext) { + throw new ValidationError({ photo: 'Upload a JPEG, PNG, or WebP image.' }); } + await storage.put(`uploads/${crypto.randomUUID()}.${ext}`, file.stream(), { + httpMetadata: { contentType: file.type }, + }); }); ``` +Pick the storage key on the server and allow only the file types you expect. See [Serving Files](../storage.md#serving-files) to send them back safely. + ## Choosing a Primitive | Primitive | Page update | Form state | Framework-specific | diff --git a/docs/guide/pages-routing/loaders.md b/docs/guide/pages-routing/loaders.md index b9699af81..0509c2524 100644 --- a/docs/guide/pages-routing/loaders.md +++ b/docs/guide/pages-routing/loaders.md @@ -157,6 +157,10 @@ around the Suspense boundary when the page should render a custom failure state. For explicit React prop annotations, import `Deferred` from `@void/react`; the adapter export is typed as `Promise`. +In production, an unexpected error in a deferred prop reaches the page as +`Internal Server Error`; throw an +[`HTTPException`](../../integrations/hono.md#utilities) to show your own message. + In Vue, Svelte, and Solid, `Deferred` behaves as a [discriminated union](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions) with three states: ```ts diff --git a/docs/guide/pages-routing/overview.md b/docs/guide/pages-routing/overview.md index 19815346d..6155bd9ec 100644 --- a/docs/guide/pages-routing/overview.md +++ b/docs/guide/pages-routing/overview.md @@ -212,6 +212,8 @@ router.refresh(); // re-fetch current page props router.visit('/logout', { method: 'POST' }); // non-GET navigation ``` +`router.visit()` updates the page in place for same-origin URLs. `http(s)` URLs on another origin get a full page load, and other schemes such as `javascript:` are rejected. + For dynamic route params, use `useParams()` from the same adapter package: ```tsx diff --git a/docs/guide/platform/administration/access.md b/docs/guide/platform/administration/access.md index a6988d10b..e9a7f7445 100644 --- a/docs/guide/platform/administration/access.md +++ b/docs/guide/platform/administration/access.md @@ -157,4 +157,4 @@ void platform invitation list Invitation history remains available after an involved account is removed. The stored actor ID remains visible when that account's login no longer exists. -`void platform signup open` allows anyone to sign up. Use `void platform signup restrict` to require an allowlist match again. Disallowing an entry affects future signup; it does not suspend an existing account. +`void platform signup open` allows anyone to sign up. Everyone who signs up can deploy apps on your application domain, so check how those apps [share cookies](/guide/platform/installation/domains#adding-a-domain) before opening signup. Use `void platform signup restrict` to require an allowlist match again. Disallowing an entry affects future signup; it does not suspend an existing account. diff --git a/docs/guide/platform/installation/domains.md b/docs/guide/platform/installation/domains.md index 50efaa7b3..8aed5719c 100644 --- a/docs/guide/platform/installation/domains.md +++ b/docs/guide/platform/installation/domains.md @@ -23,6 +23,8 @@ Choose an application domain whose wildcard leaves existing Worker Custom Domain Browser login sessions are specific to each origin. Apps using their own OAuth providers may need to register their new callback URLs. Void's built-in auth uses the request origin automatically unless the app overrides that configuration. +Apps on one application domain can set cookies that the other apps there receive. With a nested domain such as `apps.example.com`, apps can also set cookies for `example.com` and its other subdomains. Apps on `workers.dev` testing URLs can set cookies that every Worker on your account's `workers.dev` subdomain receives. If people outside your team can deploy, use a domain that serves only Void apps. A platform that serves thousands of independent developers can also apply to list its application domain on the [Public Suffix List](https://github.com/publicsuffix/list/wiki/Guidelines). + ### What Changes in Testing Mode? In testing mode, each app gets a `workers.dev` URL. These URLs continue working after you add a domain; new apps then use the domain. diff --git a/docs/guide/storage.md b/docs/guide/storage.md index 5dc659142..d0add4949 100644 --- a/docs/guide/storage.md +++ b/docs/guide/storage.md @@ -42,12 +42,12 @@ const head = await storage.head('uploads/photo.jpg'); A common pattern is serving uploaded files from an API route: ```ts +// routes/files/[key].ts import { defineHandler } from 'void'; import { storage } from 'void/storage'; export const GET = defineHandler(async (c) => { - const key = c.req.param('key'); - const object = await storage.get(key); + const object = await storage.get(`uploads/${c.req.param('key')}`); if (!object) { return c.notFound(); @@ -56,11 +56,16 @@ export const GET = defineHandler(async (c) => { const headers = new Headers(); object.writeHttpMetadata(headers); headers.set('etag', object.httpEtag); + // Don't let an uploaded HTML or SVG file run as a page on your site. + headers.set('X-Content-Type-Options', 'nosniff'); + headers.set('Content-Disposition', 'attachment'); return new Response(object.body, { headers }); }); ``` +Files your users upload can contain HTML or scripts. Store them under keys your server picks, and serve them with `nosniff` and `Content-Disposition: attachment`. `` and `