From 1cf65c7c9c35118fbed50b91ca3f0028dfe70923 Mon Sep 17 00:00:00 2001 From: batuhan Date: Fri, 18 Sep 2026 23:38:26 +0000 Subject: [PATCH 01/15] Document Zero outbound fetch and People invitations --- content/(dynamic)/functions.mdx | 8 ++-- content/(dynamic)/zero-runtime.mdx | 69 ++++++++++++++++++++++++++---- 2 files changed, 64 insertions(+), 13 deletions(-) diff --git a/content/(dynamic)/functions.mdx b/content/(dynamic)/functions.mdx index 2315f1fa..32ad6058 100644 --- a/content/(dynamic)/functions.mdx +++ b/content/(dynamic)/functions.mdx @@ -7,7 +7,7 @@ sidebar: After this page you can add a worker to a Space, know which file layout the publish detects, write a handler with the right signature, and find its logs. -Functions runs your code as a worker. Use it when you need npm packages, framework output like OpenNext Next.js, or outbound HTTP. If you want a database next to your handlers and live queries in the browser, use [Zero](/zero-runtime) instead. One version declares one runtime. +Functions runs your code as a worker. Use it when you need npm packages or framework output like OpenNext Next.js. If you want a database next to your handlers and live queries in the browser, use [Zero](/zero-runtime) instead. Both runtimes support outbound HTTP. One version declares one runtime. Functions is on for every account. @@ -107,12 +107,12 @@ Detection means most projects need no `runtime` block at all. Declare one when d | --- | --- | --- | --- | | `entry` | string | detected | Path to the worker entry. Naming a file that does not exist fails the publish rather than falling back to a static publish | | `database` | boolean | `false` | Adds `env.DB` | -| `fetch` | boolean | see below | Allows outbound HTTP from the worker | +| `fetch` | boolean | `true` | Allows outbound HTTP from the worker | | `compatibilityDate` | `YYYY-MM-DD` | `2026-07-01` | The runtime semantics this worker was written against | -Capabilities are declared, never detected. Authority an author did not ask for is authority they cannot see. +Capabilities are explicit in the resolved runtime config. `database` defaults off. Outbound `fetch()` defaults on for both hand-written workers and OpenNext builds; set `fetch: false` to disable it. -`fetch` has no schema default. An OpenNext build gets it unless you set `fetch: false`, because SSR fetches while it renders. A hand-written handler and a `functions/` router reach nothing until you declare it. Without the capability, `fetch()` inside the worker is refused. +An unclaimed space reaches only the trusted host list. A claimed space can reach any public HTTP or HTTPS host. The target still enforces its own credentials. ## What is on `env` diff --git a/content/(dynamic)/zero-runtime.mdx b/content/(dynamic)/zero-runtime.mdx index af90492f..ecc70ca5 100644 --- a/content/(dynamic)/zero-runtime.mdx +++ b/content/(dynamic)/zero-runtime.mdx @@ -9,7 +9,7 @@ After this page you can tell whether your project needs Zero, declare it in `sf. ## What Zero is -Zero is the runtime a Space gets when its project declares it. Your server code runs on the machine that already serves the site, next to that space's own MySQL database, inside a QuickJS runner. You write one TypeScript app. It declares tables, queries, mutations, and HTTP endpoints, and `sf publish` compiles it into a **capsule** the platform installs on the space. +Zero is the runtime a Space gets when its project declares it. Your server code runs on the machine that already serves the site, next to that space's own MySQL database, inside a QuickJS runner. You write one TypeScript app. It declares tables, queries, mutations, actions, and HTTP endpoints, and `sf publish` compiles it into a **capsule** the platform installs on the space. Zero is on for every account. There is nothing to enable. @@ -48,7 +48,7 @@ sf publish | --- | --- | --- | | Database | The space's own MySQL. Your capsule declares the tables; the platform migrates them when a version finalizes. | [Database](/database) | | Endpoints | Plain HTTP routes in your capsule, for webhooks and callers outside the app. | This page | -| Queries and mutations | The read and write path your client calls. Queries are live. | This page | +| Queries, mutations, and actions | The read, transactional write, and outbound-work paths your client calls. Queries are live. | This page | | Environment variables | `ctx.env`, from `.env.server` or `sf env`. | [Environment variables](/environment-variables) | | Crons | Scheduled GETs against your own paths, declared in `sf.jsonc`. | [Crons](/crons) | | Storage | Runtime object storage for visitor uploads. | [Storage](/storage) | @@ -119,7 +119,7 @@ export function App() { } ``` -`capsule()` takes `name`, `favicon`, `schema`, `queries`, `mutations`, and `endpoints`, and throws on a key it does not know. +`capsule()` takes `name`, `favicon`, `schema`, `queries`, `mutations`, `actions`, and `endpoints`, and throws on a key it does not know. ### Tables @@ -127,7 +127,7 @@ export function App() { ### Handler context -Every query, mutation, and endpoint handler takes `ctx` first. +Every query, mutation, action, and endpoint handler takes `ctx` first. | On `ctx` | Queries | Mutations and write endpoints | What it is | | --- | --- | --- | --- | @@ -139,6 +139,8 @@ Every query, mutation, and endpoint handler takes `ctx` first. A query can be replayed to satisfy a subscription, so anything that leaves a mark outside the database is withheld from it. That is why `invalidate` is write-side only. +An action runs without a transaction. It gets a read-only `db`, can call `fetch()`, and has no `invalidate`. Use an action when an external API call can take time or cause an external effect. Send the result to a mutation when the capsule must save it. + `invalidate()` takes **query names, not table names**. `ctx.invalidate("notes")` refreshes every `notes` subscription whatever arguments it carries. Calling it with nothing is the safe default and refreshes every live query on the page. ### Endpoints @@ -154,7 +156,7 @@ Return one of the response helpers: `json(value)`, `text(value)`, `empty()` for 3. Your endpoint table matches. A hit runs the handler in the QuickJS runner on the space's own machine, in one transaction with the database. 4. Everything else falls to the app shell, which serves your client bundle. -Queries and mutations do not get their own URLs. The client sends them over `/__zero/run`. When a mutation commits, the runtime publishes an invalidation event and every browser holding a matching subscription re-runs its query. That is the whole realtime story. Do not poll. +Queries, mutations, and actions do not get their own URLs. The client sends them over `/__zero/run`. When a mutation commits, the runtime publishes an invalidation event and every browser holding a matching subscription re-runs its query. That is the whole realtime story. Do not poll. An endpoint cannot claim `/`, `/index.html`, `/client.js`, `/auth/callback`, anything under `/auth/`, or anything under `/_spacefast/`. The shell, the client bundle, and the sign-in flow already answer those. @@ -172,13 +174,62 @@ Only three source roots are read: `client/`, `server/`, and `shared/`. Beyond th | Client imports | Anything except the server SDK and `node:*` built-ins | | Endpoint request body | 2 MiB | | Rejected everywhere | `eval`, `Function`, `require()`, dynamic `import()`, `process`, `__dirname`, `__filename`, `Bun`, `Deno` | -| Rejected in `server/` and `shared/` | `window`, `document`, `localStorage`, `fetch` has no polyfill, `WebSocket`, `Worker`, `XMLHttpRequest`, `globalThis` | +| Rejected in `server/` and `shared/` | `window`, `document`, `localStorage`, `WebSocket`, `Worker`, `XMLHttpRequest`, `globalThis` | Platform modules do not count against your client cap. The SDK, kit, charts, preact, recharts, and lucide serve from immutable same-origin URLs the browser keeps across publishes. -:::warning[There is no outbound fetch in a Zero handler] -The runner defines `Headers`, `Response`, `URL`, and `URLSearchParams`, but no `fetch`. A handler that needs to call an external API belongs in [Functions](/functions). -::: +### Outbound HTTP + +Every Zero handler can call `fetch()`. Use an action for a slow call or an external side effect, because an action does not hold a database transaction. + +A claimed space can reach any public HTTPS host. An unclaimed space can reach only the trusted host list. The same rule applies to Zero and Functions. Private visitor access is separate from outbound access: a call to an API does not make the space public or change its Grants. + +There is no `ctx.people` service. To create or resend a private-space Person invitation, call the public Spacefast API from an action. Keep a narrowly scoped API key in a server-only variable. The key needs `spaces:write` and an active `access.manage` Grant that covers the requested path and target. + +```ts server/invitations.ts +import { action, capsule } from "@spacefast/zero/server"; + +const apiOrigin = "https://api.spacefast.com"; + +export const invitePartner = action(async (ctx, email: string) => { + const credential = ctx.env.PARTNER_TRACKER_API_KEY; + const spaceId = ctx.env.PARTNER_SPACE_ID; + if (!credential || !spaceId) throw new Error("Partner invitation is not configured."); + + const response = await fetch(`${apiOrigin}/v1/spaces/${encodeURIComponent(spaceId)}/people`, { + method: "POST", + headers: { + authorization: `Bearer ${credential}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + email, + grants: [{ scope: "/**", role: "viewer", target: { kind: "live" } }], + }), + }); + if (!response.ok) throw new Error(`Person invitation failed with ${response.status}.`); + return response.json(); +}); + +export const resendPartnerInvitation = action(async (ctx, personId: string) => { + const credential = ctx.env.PARTNER_TRACKER_API_KEY; + const spaceId = ctx.env.PARTNER_SPACE_ID; + if (!credential || !spaceId) throw new Error("Partner invitation is not configured."); + + const response = await fetch( + `${apiOrigin}/v1/spaces/${encodeURIComponent(spaceId)}/people/${encodeURIComponent(personId)}/resend`, + { method: "POST", headers: { authorization: `Bearer ${credential}` } }, + ); + if (!response.ok) throw new Error(`Person invitation resend failed with ${response.status}.`); + return response.json(); +}); + +export default capsule({ + actions: { invitePartner, resendPartnerInvitation }, +}); +``` + +The invitation routes return `{ data }`. They never return the invitation credential. Email delivery owns that credential. ## Styling From 9a7daefc8366fc95bf95eeebdab7e3a4cbe3427f Mon Sep 17 00:00:00 2001 From: batuhan Date: Fri, 18 Sep 2026 23:49:03 +0000 Subject: [PATCH 02/15] Authorize Zero invitation actions --- content/(dynamic)/zero-runtime.mdx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/content/(dynamic)/zero-runtime.mdx b/content/(dynamic)/zero-runtime.mdx index ecc70ca5..16eda680 100644 --- a/content/(dynamic)/zero-runtime.mdx +++ b/content/(dynamic)/zero-runtime.mdx @@ -186,12 +186,19 @@ A claimed space can reach any public HTTPS host. An unclaimed space can reach on There is no `ctx.people` service. To create or resend a private-space Person invitation, call the public Spacefast API from an action. Keep a narrowly scoped API key in a server-only variable. The key needs `spaces:write` and an active `access.manage` Grant that covers the requested path and target. +Actions are client-visible entry points. Check `ctx.auth` and your app's own authorization rules before using the management key. This example keeps an explicit server-side allowlist of operators in `PARTNER_INVITER_USER_IDS`; private-space viewer access alone is not enough. + ```ts server/invitations.ts import { action, capsule } from "@spacefast/zero/server"; const apiOrigin = "https://api.spacefast.com"; export const invitePartner = action(async (ctx, email: string) => { + const authorizedUserIds = ctx.env.PARTNER_INVITER_USER_IDS?.split(",").map((id) => id.trim()); + if (!ctx.auth.isAuthenticated || !authorizedUserIds?.includes(ctx.auth.userId)) { + throw new Error("You cannot manage partner invitations."); + } + const credential = ctx.env.PARTNER_TRACKER_API_KEY; const spaceId = ctx.env.PARTNER_SPACE_ID; if (!credential || !spaceId) throw new Error("Partner invitation is not configured."); @@ -212,6 +219,11 @@ export const invitePartner = action(async (ctx, email: string) => { }); export const resendPartnerInvitation = action(async (ctx, personId: string) => { + const authorizedUserIds = ctx.env.PARTNER_INVITER_USER_IDS?.split(",").map((id) => id.trim()); + if (!ctx.auth.isAuthenticated || !authorizedUserIds?.includes(ctx.auth.userId)) { + throw new Error("You cannot manage partner invitations."); + } + const credential = ctx.env.PARTNER_TRACKER_API_KEY; const spaceId = ctx.env.PARTNER_SPACE_ID; if (!credential || !spaceId) throw new Error("Partner invitation is not configured."); From 25d3547b0cfbd6457f5cee2f70a3ca84266f0096 Mon Sep 17 00:00:00 2001 From: batuhan Date: Fri, 18 Sep 2026 23:50:22 +0000 Subject: [PATCH 03/15] Use the configured Zero server entry --- content/(dynamic)/zero-runtime.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/(dynamic)/zero-runtime.mdx b/content/(dynamic)/zero-runtime.mdx index 16eda680..97fdd8f9 100644 --- a/content/(dynamic)/zero-runtime.mdx +++ b/content/(dynamic)/zero-runtime.mdx @@ -186,9 +186,9 @@ A claimed space can reach any public HTTPS host. An unclaimed space can reach on There is no `ctx.people` service. To create or resend a private-space Person invitation, call the public Spacefast API from an action. Keep a narrowly scoped API key in a server-only variable. The key needs `spaces:write` and an active `access.manage` Grant that covers the requested path and target. -Actions are client-visible entry points. Check `ctx.auth` and your app's own authorization rules before using the management key. This example keeps an explicit server-side allowlist of operators in `PARTNER_INVITER_USER_IDS`; private-space viewer access alone is not enough. +Actions are client-visible entry points. Check `ctx.auth` and your app's own authorization rules before using the management key. Put the actions in the configured server entry. This example keeps an explicit server-side allowlist of operators in `PARTNER_INVITER_USER_IDS`; private-space viewer access alone is not enough. -```ts server/invitations.ts +```ts server/index.ts import { action, capsule } from "@spacefast/zero/server"; const apiOrigin = "https://api.spacefast.com"; From 2c5348506817111d02e79da911668084e651d400 Mon Sep 17 00:00:00 2001 From: batuhan Date: Fri, 18 Sep 2026 23:51:28 +0000 Subject: [PATCH 04/15] Align the Functions fetch default --- content/(reference)/config-file.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/(reference)/config-file.mdx b/content/(reference)/config-file.mdx index a8cecebf..f07c2c8e 100644 --- a/content/(reference)/config-file.mdx +++ b/content/(reference)/config-file.mdx @@ -195,7 +195,7 @@ Both are required. Zero is never inferred. See [Dynamic sites with Zero](/zero-r | --- | --- | --- | --- | | `runtime.entry` | string | detected | Worker entry. Naming a file that does not exist fails the publish | | `runtime.database` | boolean | `false` | Adds `env.DB` | -| `runtime.fetch` | boolean | no schema default | Allows outbound HTTP. An OpenNext build gets it unless set to `false`; a hand-written handler does not until you ask | +| `runtime.fetch` | boolean | `true` | Allows outbound HTTP. Set it to `false` to disable fetch for either an OpenNext build or a hand-written handler | | `runtime.compatibilityDate` | `YYYY-MM-DD` | `2026-07-01` | Runtime semantics this worker was written against | See [Functions](/functions). From 71e81e6e392949b9d2bbd5b87b0590e97e7d500b Mon Sep 17 00:00:00 2001 From: batuhan Date: Fri, 18 Sep 2026 23:52:54 +0000 Subject: [PATCH 05/15] Clarify Zero HTTPS-only fetch --- content/(dynamic)/zero-runtime.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/(dynamic)/zero-runtime.mdx b/content/(dynamic)/zero-runtime.mdx index 97fdd8f9..ae750688 100644 --- a/content/(dynamic)/zero-runtime.mdx +++ b/content/(dynamic)/zero-runtime.mdx @@ -182,7 +182,7 @@ Platform modules do not count against your client cap. The SDK, kit, charts, pre Every Zero handler can call `fetch()`. Use an action for a slow call or an external side effect, because an action does not hold a database transaction. -A claimed space can reach any public HTTPS host. An unclaimed space can reach only the trusted host list. The same rule applies to Zero and Functions. Private visitor access is separate from outbound access: a call to an API does not make the space public or change its Grants. +A claimed space can reach any public HTTPS host; Zero does not allow plain HTTP. An unclaimed space can reach only the trusted host list. Functions uses the same claimed/unclaimed host policy but supports public HTTP and HTTPS. Private visitor access is separate from outbound access: a call to an API does not make the space public or change its Grants. There is no `ctx.people` service. To create or resend a private-space Person invitation, call the public Spacefast API from an action. Keep a narrowly scoped API key in a server-only variable. The key needs `spaces:write` and an active `access.manage` Grant that covers the requested path and target. From 2d4ba8e92082a80d2e10d45bd0c29007d7cd5fca Mon Sep 17 00:00:00 2001 From: batuhan Date: Fri, 18 Sep 2026 23:54:17 +0000 Subject: [PATCH 06/15] Document the Zero action context --- content/(dynamic)/zero-runtime.mdx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/content/(dynamic)/zero-runtime.mdx b/content/(dynamic)/zero-runtime.mdx index ae750688..5ac90570 100644 --- a/content/(dynamic)/zero-runtime.mdx +++ b/content/(dynamic)/zero-runtime.mdx @@ -129,13 +129,13 @@ export function App() { Every query, mutation, action, and endpoint handler takes `ctx` first. -| On `ctx` | Queries | Mutations and write endpoints | What it is | -| --- | --- | --- | --- | -| `db` | read only | read and write | `get`, `withIndex`, and on the write side `insert`, `update`, `delete` | -| `auth` | yes | yes | `userId`, `displayName`, `isGuest`, `isAuthenticated`, `provider` | -| `env` | yes | yes | Server-only variables. See [Environment variables](/environment-variables) | -| `log` | yes | yes | `info`, `warn`, `error`, each with an optional data bag | -| `invalidate` | no | yes | Names the live queries this mutation changed | +| On `ctx` | Queries and read endpoints | Mutations and write endpoints | Actions | What it is | +| --- | --- | --- | --- | --- | +| `db` | read only | read and write | read only | `get`, `withIndex`, and on the write side `insert`, `update`, `delete` | +| `auth` | yes | yes | yes | `userId`, `displayName`, `isGuest`, `isAuthenticated`, `provider` | +| `env` | yes | yes | yes | Server-only variables. See [Environment variables](/environment-variables) | +| `log` | yes | yes | yes | `info`, `warn`, `error`, each with an optional data bag | +| `invalidate` | no | yes | no | Names the live queries this mutation changed | A query can be replayed to satisfy a subscription, so anything that leaves a mark outside the database is withheld from it. That is why `invalidate` is write-side only. From c4b0feb267567c1c89b4817d5f02f2013a8f4bb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Sat, 26 Sep 2026 16:57:24 +0000 Subject: [PATCH 07/15] Match Zero and Functions outbound docs to the shipped egress model Capabilities are no longer config: every Functions worker gets env.DB and fetch, and sf.jsonc drops runtime.database and runtime.fetch. Outbound reach depends only on whether the space is claimed, so list the trusted hosts and the refusal text. The People invite example now uses a manager-role machine credential, the canonical whole-space scope "/", and one shared authorization gate. --- content/(dynamic)/database.mdx | 4 +- content/(dynamic)/functions.mdx | 26 +++---- content/(dynamic)/zero-runtime.mdx | 101 +++++++++++++++------------- content/(reference)/config-file.mdx | 2 - 4 files changed, 70 insertions(+), 63 deletions(-) diff --git a/content/(dynamic)/database.mdx b/content/(dynamic)/database.mdx index 82574827..5ed87066 100644 --- a/content/(dynamic)/database.mdx +++ b/content/(dynamic)/database.mdx @@ -9,7 +9,7 @@ After this page you can read your space's schema and rows from the CLI, apply a ## What it is -Every Space that runs [Zero](/zero-runtime) gets one database, the MySQL that lives on the machine serving the site. A [Functions](/functions) worker gets one too, when it declares `database: true`. +Every Space that runs [Zero](/zero-runtime) gets one database, the MySQL that lives on the machine serving the site. A [Functions](/functions) worker gets one too, as `env.DB`. You do not create it, size it, or connect to it. Your capsule declares the tables and the platform applies the migration when a version finalizes. @@ -167,7 +167,7 @@ sf db console --show-secret The console is also how you reach a Functions worker's tables, since the schema routes do not apply to one. -If the space has never run a Zero app or declared `database: true`, the Database page shows **Database isn't enabled**. +Until the space runs a Zero app or a worker, the Database page shows **Database isn't enabled**. ## Limits diff --git a/content/(dynamic)/functions.mdx b/content/(dynamic)/functions.mdx index 32ad6058..bf9dfa09 100644 --- a/content/(dynamic)/functions.mdx +++ b/content/(dynamic)/functions.mdx @@ -7,7 +7,7 @@ sidebar: After this page you can add a worker to a Space, know which file layout the publish detects, write a handler with the right signature, and find its logs. -Functions runs your code as a worker. Use it when you need npm packages or framework output like OpenNext Next.js. If you want a database next to your handlers and live queries in the browser, use [Zero](/zero-runtime) instead. Both runtimes support outbound HTTP. One version declares one runtime. +Functions runs your code as a worker. Use it when you need npm packages or framework output like OpenNext Next.js. If you want live queries in the browser and a schema the platform migrates for you, use [Zero](/zero-runtime) instead. Both can call `fetch()`. One version declares one runtime. Functions is on for every account. @@ -88,7 +88,7 @@ Requests and responses are the standard `Request` and `Response`. There is no Sp ## Declare it -Detection means most projects need no `runtime` block at all. Declare one when detection cannot guess the layout, or when you want a capability. +Detection means most projects need no `runtime` block at all. Declare one when detection cannot guess the layout. ```jsonc sf.jsonc { @@ -96,8 +96,6 @@ Detection means most projects need no `runtime` block at all. Declare one when d "runtime": { "kind": "functions", "entry": "handler.ts", - "database": true, - "fetch": true, "compatibilityDate": "2026-07-01" } } @@ -106,22 +104,26 @@ Detection means most projects need no `runtime` block at all. Declare one when d | Key | Type | Default | What it does | | --- | --- | --- | --- | | `entry` | string | detected | Path to the worker entry. Naming a file that does not exist fails the publish rather than falling back to a static publish | -| `database` | boolean | `false` | Adds `env.DB` | -| `fetch` | boolean | `true` | Allows outbound HTTP from the worker | | `compatibilityDate` | `YYYY-MM-DD` | `2026-07-01` | The runtime semantics this worker was written against | -Capabilities are explicit in the resolved runtime config. `database` defaults off. Outbound `fetch()` defaults on for both hand-written workers and OpenNext builds; set `fetch: false` to disable it. +There is nothing to switch on. Every worker gets its database and outbound `fetch()`. A leftover `database` or `fetch` key is ignored with a warning. -An unclaimed space reaches only the trusted host list. A claimed space can reach any public HTTP or HTTPS host. The target still enforces its own credentials. +## Outbound HTTP + +`fetch()` works in every worker. How far it reaches depends on who owns the space: + +| Space | Reaches | +| --- | --- | +| Claimed | Any public HTTP or HTTPS host | +| Unclaimed | Only the trusted list: `api.anthropic.com`, `api.openai.com`, `api.github.com`, `api.stripe.com`, `api.groq.com`, `api.mistral.ai`, `api.together.xyz`, `generativelanguage.googleapis.com`, `openrouter.ai` | + +Anything else from an unclaimed space answers 403 with "This host isn't reachable from an unclaimed space. Claim the space to reach any public host." Claiming opens it up without a republish. ## What is on `env` `env` carries the space's environment variables. See [Environment variables](/environment-variables) for how to set them. -Two names are added when you declare them. - -- `env.DB`, with `database: true`. It is D1-shaped, so `env.DB.prepare(sql).bind(...).all()` is the call you already know. It reaches the space's own database through a broker, so no connection string ever lands in the bundle. -- Nothing else is injected. A capability you did not declare is absent, not empty. +`env.DB` is always there. It is D1-shaped, so `env.DB.prepare(sql).bind(...).all()` is the call you already know. It reaches the space's own database through a broker, so no connection string ever lands in the bundle. Two groups of variable names never reach a worker, dropped rather than blanked so your code reads them as absent instead of as "configured, but empty". diff --git a/content/(dynamic)/zero-runtime.mdx b/content/(dynamic)/zero-runtime.mdx index 5ac90570..88611871 100644 --- a/content/(dynamic)/zero-runtime.mdx +++ b/content/(dynamic)/zero-runtime.mdx @@ -60,8 +60,8 @@ The scaffold's `package.json` pins the package to the compiler's own version. No | Import | Use it from | What it holds | | --- | --- | --- | -| `@spacefast/zero/server` | `server/` | `capsule`, `query`, `mutation`, `endpoint`, `table`, field constructors, response helpers | -| `@spacefast/zero/client` | `client/` | `useQuery`, `usePaginatedQuery`, `useMutation`, `useAuth`, `storage`, `Router` and friends | +| `@spacefast/zero/server` | `server/` | `capsule`, `query`, `mutation`, `action`, `endpoint`, `table`, field constructors, response helpers | +| `@spacefast/zero/client` | `client/` | `useQuery`, `usePaginatedQuery`, `useMutation`, `useAction`, `useAuth`, `storage`, `Router` and friends | | `@spacefast/zero/kit` | `client/` | The component set: `Button`, `Card`, `Input`, `Dialog`, `Table`, and about 30 more | | `@spacefast/zero/charts` | `client/` | `LineChart`, `BarChart`, `Sparkline`, `StatTile` | @@ -139,7 +139,7 @@ Every query, mutation, action, and endpoint handler takes `ctx` first. A query can be replayed to satisfy a subscription, so anything that leaves a mark outside the database is withheld from it. That is why `invalidate` is write-side only. -An action runs without a transaction. It gets a read-only `db`, can call `fetch()`, and has no `invalidate`. Use an action when an external API call can take time or cause an external effect. Send the result to a mutation when the capsule must save it. +An action runs without a transaction. It reads the database but cannot write to it, and it has no `invalidate`. That makes it the place for slow or side-effecting calls to other services. To keep what it fetched, hand the result to a mutation. The client calls one with `useAction(name)`. `invalidate()` takes **query names, not table names**. `ctx.invalidate("notes")` refreshes every `notes` subscription whatever arguments it carries. Calling it with nothing is the safe default and refreshes every live query on the page. @@ -180,68 +180,75 @@ Platform modules do not count against your client cap. The SDK, kit, charts, pre ### Outbound HTTP -Every Zero handler can call `fetch()`. Use an action for a slow call or an external side effect, because an action does not hold a database transaction. +`fetch()` works in every Zero handler, HTTPS only. Reach for an action when the call is slow or does something out in the world. A mutation or write endpoint holds its database transaction open while it waits, so its `fetch()` gives up after 5 seconds. Everywhere else it gets 10. -A claimed space can reach any public HTTPS host; Zero does not allow plain HTTP. An unclaimed space can reach only the trusted host list. Functions uses the same claimed/unclaimed host policy but supports public HTTP and HTTPS. Private visitor access is separate from outbound access: a call to an API does not make the space public or change its Grants. +How far `fetch()` reaches depends on who owns the space: -There is no `ctx.people` service. To create or resend a private-space Person invitation, call the public Spacefast API from an action. Keep a narrowly scoped API key in a server-only variable. The key needs `spaces:write` and an active `access.manage` Grant that covers the requested path and target. +| Space | Reaches | +| --- | --- | +| Claimed | Any public HTTPS host | +| Unclaimed | Only the trusted list: `api.anthropic.com`, `api.openai.com`, `api.github.com`, `api.stripe.com`, `api.groq.com`, `api.mistral.ai`, `api.together.xyz`, `generativelanguage.googleapis.com`, `openrouter.ai` | -Actions are client-visible entry points. Check `ctx.auth` and your app's own authorization rules before using the management key. Put the actions in the configured server entry. This example keeps an explicit server-side allowlist of operators in `PARTNER_INVITER_USER_IDS`; private-space viewer access alone is not enough. +Anything else from an unclaimed space answers 403 with "This host isn't reachable from an unclaimed space. Claim the space to reach any public host." Claiming opens it up without a republish. Private addresses and cloud metadata stay blocked either way. -```ts server/index.ts -import { action, capsule } from "@spacefast/zero/server"; +Outbound access has nothing to do with who can open your space. Calling an API does not make the space public or touch its Grants. -const apiOrigin = "https://api.spacefast.com"; +### Example: invite People from your app -export const invitePartner = action(async (ctx, email: string) => { - const authorizedUserIds = ctx.env.PARTNER_INVITER_USER_IDS?.split(",").map((id) => id.trim()); - if (!ctx.auth.isAuthenticated || !authorizedUserIds?.includes(ctx.auth.userId)) { - throw new Error("You cannot manage partner invitations."); - } +There is no `ctx.people`. To invite someone to a private space, or resend their invite, call the People API from an action with a [machine credential](/access#machine-credentials). - const credential = ctx.env.PARTNER_TRACKER_API_KEY; - const spaceId = ctx.env.PARTNER_SPACE_ID; - if (!credential || !spaceId) throw new Error("Partner invitation is not configured."); +```bash +sf share token create --name invites --path '/**' --role manager --show-secret +sf env set INVITE_TOKEN --value-from-stdin < token.txt +sf env set INVITE_SPACE_ID spc_123 +sf env set INVITE_ADMINS user-id-1,user-id-2 +``` - const response = await fetch(`${apiOrigin}/v1/spaces/${encodeURIComponent(spaceId)}/people`, { - method: "POST", - headers: { - authorization: `Bearer ${credential}`, - "content-type": "application/json", - }, - body: JSON.stringify({ - email, - grants: [{ scope: "/**", role: "viewer", target: { kind: "live" } }], - }), - }); - if (!response.ok) throw new Error(`Person invitation failed with ${response.status}.`); - return response.json(); -}); +The `manager` role carries `access.manage`, which the People routes require. Narrow `--path` to limit where the token can invite people. `INVITE_ADMINS` lists the `ctx.auth.userId` values allowed to send invites. The People API only works on a claimed space. -export const resendPartnerInvitation = action(async (ctx, personId: string) => { - const authorizedUserIds = ctx.env.PARTNER_INVITER_USER_IDS?.split(",").map((id) => id.trim()); - if (!ctx.auth.isAuthenticated || !authorizedUserIds?.includes(ctx.auth.userId)) { - throw new Error("You cannot manage partner invitations."); - } +An action is callable by anyone who can load your app, so gate it before it spends that token. - const credential = ctx.env.PARTNER_TRACKER_API_KEY; - const spaceId = ctx.env.PARTNER_SPACE_ID; - if (!credential || !spaceId) throw new Error("Partner invitation is not configured."); +```ts server/index.ts +import { action, capsule } from "@spacefast/zero/server"; + +type Ctx = { + auth: { isSignedIn: boolean; userId: string | null }; + env: Record; +}; +async function callPeople(ctx: Ctx, path: string, body?: unknown) { + const admins = (ctx.env.INVITE_ADMINS ?? "").split(",").map((id) => id.trim()); + if (!ctx.auth.isSignedIn || !admins.includes(ctx.auth.userId ?? "")) { + throw new Error("You can't send invites."); + } const response = await fetch( - `${apiOrigin}/v1/spaces/${encodeURIComponent(spaceId)}/people/${encodeURIComponent(personId)}/resend`, - { method: "POST", headers: { authorization: `Bearer ${credential}` } }, + `https://api.spacefast.com/v1/spaces/${ctx.env.INVITE_SPACE_ID}/people${path}`, + { + method: "POST", + headers: { + authorization: `Bearer ${ctx.env.INVITE_TOKEN}`, + "content-type": "application/json", + }, + body: body === undefined ? undefined : JSON.stringify(body), + }, ); - if (!response.ok) throw new Error(`Person invitation resend failed with ${response.status}.`); - return response.json(); -}); + if (!response.ok) throw new Error(`People API answered ${response.status}.`); + return (await response.json()).data; +} export default capsule({ - actions: { invitePartner, resendPartnerInvitation }, + actions: { + invite: action((ctx, email: string) => + callPeople(ctx, "", { email, grants: [{ scope: "/", role: "viewer" }] }), + ), + resendInvite: action((ctx, personId: string) => + callPeople(ctx, `/${encodeURIComponent(personId)}/resend`), + ), + }, }); ``` -The invitation routes return `{ data }`. They never return the invitation credential. Email delivery owns that credential. +Call them from the client with `useAction("invite")`. `scope: "/"` covers the whole space; `/docs` covers one subtree. Both routes return the Person in `{ data }`. The invite link itself only ever travels by email. ## Styling diff --git a/content/(reference)/config-file.mdx b/content/(reference)/config-file.mdx index f07c2c8e..ef4585e6 100644 --- a/content/(reference)/config-file.mdx +++ b/content/(reference)/config-file.mdx @@ -194,8 +194,6 @@ Both are required. Zero is never inferred. See [Dynamic sites with Zero](/zero-r | Key | Type | Default | What it does | | --- | --- | --- | --- | | `runtime.entry` | string | detected | Worker entry. Naming a file that does not exist fails the publish | -| `runtime.database` | boolean | `false` | Adds `env.DB` | -| `runtime.fetch` | boolean | `true` | Allows outbound HTTP. Set it to `false` to disable fetch for either an OpenNext build or a hand-written handler | | `runtime.compatibilityDate` | `YYYY-MM-DD` | `2026-07-01` | Runtime semantics this worker was written against | See [Functions](/functions). From e76cc3090cb9d58c453ef2110e66a0386a4d64df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Sat, 26 Sep 2026 17:12:49 +0000 Subject: [PATCH 08/15] Gate Zero sign-in on isAuthenticated, not userId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A signed-out visitor reaches a handler as a guest with a real guest:… userId, so a userId truthiness check never rejects anyone. Say so next to the handler context and use isAuthenticated in the invite example. --- content/(dynamic)/zero-runtime.mdx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/content/(dynamic)/zero-runtime.mdx b/content/(dynamic)/zero-runtime.mdx index 88611871..1eae1fba 100644 --- a/content/(dynamic)/zero-runtime.mdx +++ b/content/(dynamic)/zero-runtime.mdx @@ -137,6 +137,8 @@ Every query, mutation, action, and endpoint handler takes `ctx` first. | `log` | yes | yes | yes | `info`, `warn`, `error`, each with an optional data bag | | `invalidate` | no | yes | no | Names the live queries this mutation changed | +A signed-out visitor is a guest with a real `userId` (`guest:…`), so `!ctx.auth.userId` never rejects anyone. Gate sign-in on `ctx.auth.isAuthenticated`. + A query can be replayed to satisfy a subscription, so anything that leaves a mark outside the database is withheld from it. That is why `invalidate` is write-side only. An action runs without a transaction. It reads the database but cannot write to it, and it has no `invalidate`. That makes it the place for slow or side-effecting calls to other services. To keep what it fetched, hand the result to a mutation. The client calls one with `useAction(name)`. @@ -212,13 +214,13 @@ An action is callable by anyone who can load your app, so gate it before it spen import { action, capsule } from "@spacefast/zero/server"; type Ctx = { - auth: { isSignedIn: boolean; userId: string | null }; + auth: { isAuthenticated: boolean; userId: string | null }; env: Record; }; async function callPeople(ctx: Ctx, path: string, body?: unknown) { const admins = (ctx.env.INVITE_ADMINS ?? "").split(",").map((id) => id.trim()); - if (!ctx.auth.isSignedIn || !admins.includes(ctx.auth.userId ?? "")) { + if (!ctx.auth.isAuthenticated || !admins.includes(ctx.auth.userId ?? "")) { throw new Error("You can't send invites."); } const response = await fetch( From 97f4f0a4b78ca4c61642da6d3e8e875aa413af57 Mon Sep 17 00:00:00 2001 From: batuhan Date: Sat, 26 Sep 2026 17:14:53 +0000 Subject: [PATCH 09/15] Document Zero action deadlines --- content/(dynamic)/zero-runtime.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/(dynamic)/zero-runtime.mdx b/content/(dynamic)/zero-runtime.mdx index 1eae1fba..7d37125f 100644 --- a/content/(dynamic)/zero-runtime.mdx +++ b/content/(dynamic)/zero-runtime.mdx @@ -141,7 +141,7 @@ A signed-out visitor is a guest with a real `userId` (`guest:…`), so `!ctx.aut A query can be replayed to satisfy a subscription, so anything that leaves a mark outside the database is withheld from it. That is why `invalidate` is write-side only. -An action runs without a transaction. It reads the database but cannot write to it, and it has no `invalidate`. That makes it the place for slow or side-effecting calls to other services. To keep what it fetched, hand the result to a mutation. The client calls one with `useAction(name)`. +An action runs without a transaction. It reads the database but cannot write to it, and it has no `invalidate`. Use it when a call causes an external effect, not to gain more runtime: the whole action has a 5-second deadline. To keep what it fetched, hand the result to a mutation. The client calls one with `useAction(name)`. `invalidate()` takes **query names, not table names**. `ctx.invalidate("notes")` refreshes every `notes` subscription whatever arguments it carries. Calling it with nothing is the safe default and refreshes every live query on the page. @@ -182,7 +182,7 @@ Platform modules do not count against your client cap. The SDK, kit, charts, pre ### Outbound HTTP -`fetch()` works in every Zero handler, HTTPS only. Reach for an action when the call is slow or does something out in the world. A mutation or write endpoint holds its database transaction open while it waits, so its `fetch()` gives up after 5 seconds. Everywhere else it gets 10. +`fetch()` works in every Zero handler, HTTPS only. A mutation or write endpoint holds its database transaction open while it waits, so each fetch has a 5-second timeout. Queries and read endpoints get 10 seconds per fetch. An action gets 5 seconds for the whole invocation, shared across every fetch it makes. How far `fetch()` reaches depends on who owns the space: From d9b3738f5c98cbac1bad1fa17bd4b94bdb4fc9db Mon Sep 17 00:00:00 2001 From: batuhan Date: Sat, 26 Sep 2026 17:17:37 +0000 Subject: [PATCH 10/15] Document Zero fetch refusal errors --- content/(dynamic)/zero-runtime.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/(dynamic)/zero-runtime.mdx b/content/(dynamic)/zero-runtime.mdx index 7d37125f..7440db7d 100644 --- a/content/(dynamic)/zero-runtime.mdx +++ b/content/(dynamic)/zero-runtime.mdx @@ -191,7 +191,7 @@ How far `fetch()` reaches depends on who owns the space: | Claimed | Any public HTTPS host | | Unclaimed | Only the trusted list: `api.anthropic.com`, `api.openai.com`, `api.github.com`, `api.stripe.com`, `api.groq.com`, `api.mistral.ai`, `api.together.xyz`, `generativelanguage.googleapis.com`, `openrouter.ai` | -Anything else from an unclaimed space answers 403 with "This host isn't reachable from an unclaimed space. Claim the space to reach any public host." Claiming opens it up without a republish. Private addresses and cloud metadata stay blocked either way. +From an unclaimed space, `fetch()` to any other host throws a `SpacefastFetchError` with code `zero_fetch_host_untrusted` and the message "This host isn't reachable from an unclaimed space. Claim the space to reach any public host." Claiming opens it up without a republish. Private addresses and cloud metadata stay blocked either way. Outbound access has nothing to do with who can open your space. Calling an API does not make the space public or touch its Grants. From 37571b6aea5b80e6267f0b0b1a49ea5f5273addc Mon Sep 17 00:00:00 2001 From: batuhan Date: Sat, 26 Sep 2026 17:21:18 +0000 Subject: [PATCH 11/15] Pipe invitation tokens into the environment --- content/(dynamic)/zero-runtime.mdx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/content/(dynamic)/zero-runtime.mdx b/content/(dynamic)/zero-runtime.mdx index 7440db7d..30132044 100644 --- a/content/(dynamic)/zero-runtime.mdx +++ b/content/(dynamic)/zero-runtime.mdx @@ -200,13 +200,14 @@ Outbound access has nothing to do with who can open your space. Calling an API d There is no `ctx.people`. To invite someone to a private space, or resend their invite, call the People API from an action with a [machine credential](/access#machine-credentials). ```bash -sf share token create --name invites --path '/**' --role manager --show-secret -sf env set INVITE_TOKEN --value-from-stdin < token.txt +sf share token create --name invites --path '/**' --role manager --show-secret --json \ + | jq -r '.data.token' \ + | sf env set INVITE_TOKEN --value-from-stdin sf env set INVITE_SPACE_ID spc_123 sf env set INVITE_ADMINS user-id-1,user-id-2 ``` -The `manager` role carries `access.manage`, which the People routes require. Narrow `--path` to limit where the token can invite people. `INVITE_ADMINS` lists the `ctx.auth.userId` values allowed to send invites. The People API only works on a claimed space. +The `manager` role carries `access.manage`, which the People routes require. Narrow `--path` to limit where the token can invite people. `INVITE_ADMINS` lists the `ctx.auth.userId` values allowed to send invites. The People API only works on a claimed space. Each `sf env set` queues a settings-only finalize; the action sees the new value after that operation finishes. An action is callable by anyone who can load your app, so gate it before it spends that token. From 1f728cd5b92085c84d54787d4078630fd590fef7 Mon Sep 17 00:00:00 2001 From: batuhan Date: Sat, 26 Sep 2026 17:22:44 +0000 Subject: [PATCH 12/15] List the Zero signed-in auth field --- content/(dynamic)/zero-runtime.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/(dynamic)/zero-runtime.mdx b/content/(dynamic)/zero-runtime.mdx index 30132044..0cf6a359 100644 --- a/content/(dynamic)/zero-runtime.mdx +++ b/content/(dynamic)/zero-runtime.mdx @@ -132,7 +132,7 @@ Every query, mutation, action, and endpoint handler takes `ctx` first. | On `ctx` | Queries and read endpoints | Mutations and write endpoints | Actions | What it is | | --- | --- | --- | --- | --- | | `db` | read only | read and write | read only | `get`, `withIndex`, and on the write side `insert`, `update`, `delete` | -| `auth` | yes | yes | yes | `userId`, `displayName`, `isGuest`, `isAuthenticated`, `provider` | +| `auth` | yes | yes | yes | `userId`, `displayName`, `isGuest`, `isSignedIn`, `isAuthenticated`, `provider` | | `env` | yes | yes | yes | Server-only variables. See [Environment variables](/environment-variables) | | `log` | yes | yes | yes | `info`, `warn`, `error`, each with an optional data bag | | `invalidate` | no | yes | no | Names the live queries this mutation changed | From 40917399a76fdbdf555894d8f92abda68bad0c7e Mon Sep 17 00:00:00 2001 From: batuhan Date: Sat, 26 Sep 2026 17:24:37 +0000 Subject: [PATCH 13/15] Warn that Zero queries repeat fetches --- content/(dynamic)/zero-runtime.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/(dynamic)/zero-runtime.mdx b/content/(dynamic)/zero-runtime.mdx index 0cf6a359..49de26e7 100644 --- a/content/(dynamic)/zero-runtime.mdx +++ b/content/(dynamic)/zero-runtime.mdx @@ -139,7 +139,7 @@ Every query, mutation, action, and endpoint handler takes `ctx` first. A signed-out visitor is a guest with a real `userId` (`guest:…`), so `!ctx.auth.userId` never rejects anyone. Gate sign-in on `ctx.auth.isAuthenticated`. -A query can be replayed to satisfy a subscription, so anything that leaves a mark outside the database is withheld from it. That is why `invalidate` is write-side only. +A query can run again when it satisfies a subscription, so any `fetch()` it makes can run again too. Queries have no `invalidate`; only write handlers tell subscriptions to refresh. An action runs without a transaction. It reads the database but cannot write to it, and it has no `invalidate`. Use it when a call causes an external effect, not to gain more runtime: the whole action has a 5-second deadline. To keep what it fetched, hand the result to a mutation. The client calls one with `useAction(name)`. From 7abc5d3b0d736e58fe9d0c571b63ab0ac606fb22 Mon Sep 17 00:00:00 2001 From: batuhan Date: Sat, 26 Sep 2026 17:26:00 +0000 Subject: [PATCH 14/15] Explain invitation credential subtrees --- content/(dynamic)/zero-runtime.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/(dynamic)/zero-runtime.mdx b/content/(dynamic)/zero-runtime.mdx index 49de26e7..48fdf4f3 100644 --- a/content/(dynamic)/zero-runtime.mdx +++ b/content/(dynamic)/zero-runtime.mdx @@ -207,7 +207,7 @@ sf env set INVITE_SPACE_ID spc_123 sf env set INVITE_ADMINS user-id-1,user-id-2 ``` -The `manager` role carries `access.manage`, which the People routes require. Narrow `--path` to limit where the token can invite people. `INVITE_ADMINS` lists the `ctx.auth.userId` values allowed to send invites. The People API only works on a claimed space. Each `sf env set` queues a settings-only finalize; the action sees the new value after that operation finishes. +The `manager` role carries `access.manage`, which the People routes require. Narrow `--path` to limit where the token can invite people. An invite with `scope: "/docs"` requires `--path '/docs/**'`; the bare `/docs` path does not cover that subtree. `INVITE_ADMINS` lists the `ctx.auth.userId` values allowed to send invites. The People API only works on a claimed space. Each `sf env set` queues a settings-only finalize; the action sees the new value after that operation finishes. An action is callable by anyone who can load your app, so gate it before it spends that token. From e8ce66eea73107b613cec048fc346b09a06799d3 Mon Sep 17 00:00:00 2001 From: batuhan Date: Sat, 3 Oct 2026 23:19:39 +0000 Subject: [PATCH 15/15] Document the Zero action minimum version --- content/(dynamic)/zero-runtime.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/content/(dynamic)/zero-runtime.mdx b/content/(dynamic)/zero-runtime.mdx index 0f36484b..faef910e 100644 --- a/content/(dynamic)/zero-runtime.mdx +++ b/content/(dynamic)/zero-runtime.mdx @@ -65,6 +65,8 @@ The scaffold's `package.json` pins the package to the compiler's own version. No | `@spacefast/zero/kit` | `client/` | The component set: `Button`, `Card`, `Input`, `Dialog`, `Table`, and about 30 more | | `@spacefast/zero/charts` | `client/` | `LineChart`, `BarChart`, `Sparkline`, `StatTile` | +`action`, `useAction`, and the `actions` capsule field require compiler 0.5.0 or later. + ## The minimal app Server code declares the schema and the handlers.