Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
50b6cfd
Answer prerender path discovery only inside the build-time runner
Brooooooklyn Oct 7, 2026
73b0dc1
Resolve named page actions from own function properties only
Brooooooklyn Oct 7, 2026
d2278f4
Keep catch-all redirect destinations on the same site
Brooooooklyn Oct 7, 2026
5970251
Send a generic message for unexpected deferred prop errors in production
Brooooooklyn Oct 7, 2026
99db212
Ignore the session cookie on cross-origin unsafe requests and WebSock…
Brooooooklyn Oct 7, 2026
9229a39
Rate-limit auth endpoints on deployed Cloudflare apps
Brooooooklyn Oct 7, 2026
4ddf5d0
Document that a WebSocket's user is fixed for the connection
Brooooooklyn Oct 7, 2026
f471caf
Keep router.visit() page loads on the current origin
Brooooooklyn Oct 7, 2026
ac5a9df
Reject route params that would change the request path
Brooooooklyn Oct 7, 2026
6a78178
Send a fixed message for internal void/live errors
Brooooooklyn Oct 7, 2026
8c7ae4c
Explain that void/live topics are open until onSubscribe checks them
Brooooooklyn Oct 7, 2026
43ac447
Fail basicAuth closed when the password resolves to an empty value
Brooooooklyn Oct 7, 2026
8e300c4
Keep database URLs out of the project directory for db studio and db …
Brooooooklyn Oct 7, 2026
705c2d7
Drop terminal control sequences from app logs printed by void logs
Brooooooklyn Oct 7, 2026
728ef20
Send Analog dev trigger proxy requests to the bound dev server, not t…
Brooooooklyn Oct 7, 2026
8a63850
Refuse prerender paths that resolve outside the output directory
Brooooooklyn Oct 7, 2026
26ba561
Run the Svelte island style loader only during builds
Brooooooklyn Oct 7, 2026
1070496
Show Hono cache() on a public route instead of as global middleware
Brooooooklyn Oct 7, 2026
8bc5fe0
Pick upload keys on the server and serve stored files as downloads in…
Brooooooklyn Oct 7, 2026
85caa88
Key hosted ISR entries on allowlisted query params for direct requests
Brooooooklyn Oct 7, 2026
34c005e
Never store ISR renders made with query params outside the allowlist
Brooooooklyn Oct 7, 2026
31faea6
Scope the hashed-asset edge cache key to the project
Brooooooklyn Oct 7, 2026
08b1c1b
Keep AI gateway metadata and logging under platform control
Brooooooklyn Oct 7, 2026
b65253f
Count only platform-signed AI Gateway rows toward project AI usage
Brooooooklyn Oct 7, 2026
b5ba63a
Reserve certificate-authority validation mailbox names for new projects
Brooooooklyn Oct 7, 2026
72d15d2
Keep project logs from before the project was created out of its log …
Brooooooklyn Oct 7, 2026
9d3e3ab
Require a Confirm button POST to grant email recipient consent
Brooooooklyn Oct 7, 2026
748d684
Refuse framing of platform API and dashboard HTML pages
Brooooooklyn Oct 7, 2026
81153ff
Check useForm() route params when the form is submitted
Brooooooklyn Oct 7, 2026
f52214e
Reset terminal styles after app log text that keeps colors
Brooooooklyn Oct 7, 2026
848db1a
Remove database URLs that older CLI versions left in .void
Brooooooklyn Oct 7, 2026
efd0d75
Deploy the email gateway before the API
Brooooooklyn Oct 7, 2026
f44139f
Verify proxy credentials before reading the request body
Brooooooklyn Oct 7, 2026
07ab362
Refuse email-only sign-in and linking for administrator accounts
Brooooooklyn Oct 7, 2026
16f8a88
Pause cron jobs and queue relays while an administrator suspends a pr…
Brooooooklyn Oct 7, 2026
a0442ea
Limit platform deploys to 100 distinct cron expressions per project
Brooooooklyn Oct 7, 2026
c4d127e
Explain cookie sharing on platform application domains
Brooooooklyn Oct 7, 2026
ad856ad
Stop the dev server from serving the dev-trigger token
Brooooooklyn Oct 7, 2026
1499350
Neutralize C1 terminal controls in human operator CLI output
Brooooooklyn Oct 7, 2026
1c7e720
Stop the dev server from serving Astro's background dev log
Brooooooklyn Oct 7, 2026
dfecdb3
Tag provider-native AI calls with the signed usage tag and drop calle…
Brooooooklyn Oct 7, 2026
4dc1b60
Keep invocations that started before the project was created out of i…
Brooooooklyn Oct 7, 2026
1a70644
Roll the email gateway back after the API in hosted rollback-all
Brooooooklyn Oct 7, 2026
4c6cf8c
Limit platform deploys to 1000 cron schedules per project
Brooooooklyn Oct 7, 2026
fa78dae
Keep provider-native AI calls out of platform AI usage
Brooooooklyn Oct 7, 2026
cf9e4b9
Stop the dev server from serving database URLs left in .void
Brooooooklyn Oct 8, 2026
551db5b
Say that a CNAME alone proves custom-domain ownership
Brooooooklyn Oct 8, 2026
bc0a840
Keep cache and retry gateway headers on provider-native AI calls
Brooooooklyn Oct 8, 2026
81b7e84
Remove whole escape sequences from app logs printed by void logs
Brooooooklyn Oct 8, 2026
ef332ad
Show the real void/live error message in dev
Brooooooklyn Oct 8, 2026
6294a67
Explain auth rate limits behind a proxy and in end-to-end tests
Brooooooklyn Oct 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/guide/ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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

Expand Down
36 changes: 36 additions & 0 deletions docs/guide/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(...)`:
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/edge/revalidation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions docs/guide/jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
15 changes: 10 additions & 5 deletions docs/guide/live.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()`:

Expand All @@ -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 });
},
});
```
Expand Down
24 changes: 18 additions & 6 deletions docs/guide/pages-routing/actions-and-forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 4 additions & 0 deletions docs/guide/pages-routing/loaders.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`.

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<T>` behaves as a [discriminated union](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions) with three states:

```ts
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/pages-routing/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/platform/administration/access.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions docs/guide/platform/installation/domains.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 7 additions & 2 deletions docs/guide/storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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`. `<img>` and `<video>` tags still display them; opening the URL directly downloads the file.

## Custom bindings

Use `createStorage(bucket)` with your own R2 binding, or in tests:
Expand Down
4 changes: 4 additions & 0 deletions docs/guide/websockets.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ Messages are JSON events. Raw string and binary messages are not supported.

When Void auth is enabled, every WebSocket hook receives the current user as `ctx.user`, or `null` for an anonymous connection. Use `onBeforeConnect` to reject unauthenticated clients.

Connections from another origin are anonymous unless that origin is in your auth `trustedOrigins`. If you read your own cookies from `ctx.request`, check its `Origin` header yourself.

`ctx.user` is resolved when the socket connects and stays the same for that connection, even if the user later signs out or the session is revoked. For actions that must respect a ban or role change, re-check your own records in `onMessage` and close the connection with `ctx.connection.close()` in rooms or `ctx.socket.close()` in `defineWebSocket()`.

## Hooks

Both `defineRoom()` and `defineWebSocket()` support:
Expand Down
14 changes: 11 additions & 3 deletions docs/integrations/hono.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,21 @@ export default defineMiddleware(cors({ origin: 'https://example.com' }));
```

```ts
// middleware/02.cache.ts
import { defineMiddleware } from 'void';
// routes/api/products.ts (per-route cache)
import { defineHandler } from 'void';
import { cache } from 'hono/cache';
import { listProducts } from '../../lib/products';

export default defineMiddleware(cache({ cacheName: 'my-app', cacheControl: 'max-age=3600' }));
export const GET = defineHandler(
cache({ cacheName: 'products', cacheControl: 'max-age=3600' }),
async () => ({ products: await listProducts() }),
);
```

::: warning
`cache()` stores one response per URL and returns it to every visitor. It skips requests with an `Authorization` header, but not cookies. Use it only on routes whose response is the same for everyone, never on a route that reads the signed-in user. To cache pages, use [revalidation](../guide/edge/revalidation.md), which skips requests that carry a cookie.
:::

```ts
// routes/api/webhooks/stripe.ts (per-route usage)
import { defineHandler } from 'void';
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/api/handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ Void’s reserved `/__void` endpoints use their own authentication and are exclu

When credentials come from `void/env`, wrap the reads in functions so they are resolved per request after Void has bound the runtime env.

If `password` resolves to an empty value, requests fail with a server error instead of passing the gate.

For app-specific bypasses such as health checks or public webhooks, compose that logic in your own middleware before calling `basicAuth()`.

```ts
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/cli/platform-users.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ void platform user restore <id>
void platform user delete <id>
```

Suspending a user blocks their applications. Deleting a user also deletes their project resources. A plan change updates resource limits while preserving any administrator suspension.
Suspending a user blocks their applications, including scheduled jobs and queue consumers. While the account is suspended, each queue message is retried about once an hour, and each retry uses one of its queue's retries. Messages that run out of retries are dropped: after about 3 hours with the default of 3 retries, and at once for a queue with `maxRetries: 0`. Restore an account suspended by mistake promptly; queue delivery resumes within about an hour. Deleting a user also deletes their project resources. A plan change updates resource limits while preserving any administrator suspension.

The last active administrator cannot be deleted or suspended. Both previews and
actual mutations enforce this rule, including in the browser admin UI. Allowed
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ ISR revalidation TTL in seconds, either globally or per path. See [Revalidation]

#### `routing.revalidateQueryAllowlist`

Query parameters that participate in ISR variant keys for dispatch rewrites. Keys are source URL patterns, values are query parameter names to keep. When omitted, rewritten ISR cache keys drop every incoming query parameter to avoid unbounded cache fanout.
Query parameters to keep in ISR cache keys. Keys are source URL patterns, values are query parameter names to keep. The listed parameters are part of the cache key for every request to a matching path; all other query parameters are left out of the key. When omitted, ISR cache keys drop every incoming query parameter to avoid unbounded cache fanout.

```json
{
Expand Down
15 changes: 15 additions & 0 deletions packages/deploy-core/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ export const MANIFEST_REDIRECT_RULE_STATUSES: ReadonlySet<number> = new Set([
export const MAX_ROUTING_RULES = 2000;
export const MAX_REVALIDATE_QUERY_ALLOWLIST_ENTRIES: number = MAX_ROUTING_RULES;
const MAX_PAGES = 5000;
// Each distinct cron expression becomes one message on the platform's shared
// cron queue, and one user-worker call, every minute it matches. The scheduler
// dedupes only identical strings. 100 matches Vercel's per-project cap and is
// far above what real apps declare.
const MAX_CRON_EXPRESSIONS = 100;
// The scheduler parses every entry, shared expression or not, on each alarm.
const MAX_SCHEDULES = 1000;

/**
* Strict literal-hostname regex used to validate `redirectRules[].host` /
Expand Down Expand Up @@ -115,6 +122,10 @@ export function validateManifestShape(parsed: unknown): string | null {
if (!Array.isArray(manifest.schedules)) {
return 'invalid manifest shape: schedules must be an array';
}
if (manifest.schedules.length > MAX_SCHEDULES) {
return `invalid manifest shape: schedules has ${manifest.schedules.length} entries, exceeds the ${MAX_SCHEDULES} per-project limit`;
}
const cronExpressions = new Set<string>();
for (const schedule of manifest.schedules) {
if (!isRecord(schedule)) {
return 'invalid manifest shape: schedules entries must be objects';
Expand All @@ -123,6 +134,10 @@ export function validateManifestShape(parsed: unknown): string | null {
if (typeof scheduleObject.name !== 'string' || typeof scheduleObject.cron !== 'string') {
return 'invalid manifest shape: schedules entries must have string name and cron';
}
cronExpressions.add(scheduleObject.cron);
}
if (cronExpressions.size > MAX_CRON_EXPRESSIONS) {
return `invalid manifest shape: schedules has ${cronExpressions.size} distinct cron expressions, exceeds the ${MAX_CRON_EXPRESSIONS} per-project limit`;
}
}

Expand Down
Loading
Loading