# @spacefast/zero

> Author-facing client and server helpers for Spacefast Zero apps.

Latest version **0.5.0** (published 2026-09-29) · MIT license · 0 weekly downloads

## Install

```sh
npm install @spacefast/zero
pnpm add @spacefast/zero
yarn add @spacefast/zero
bun add @spacefast/zero
```

## Health

**Score 60/100 (C)** — status: active.

Positive: esm support; no vulnerabilities; recently updated; high maintenance score.

Warnings: low downloads; no types; pre 1.0.

## Facts

| | |
|---|---|
| Version | 0.5.0 |
| Published | 2026-09-29 |
| First published | 2026-06-30 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | none |
| Module format | ESM |
| Node | >=20 |
| Dependencies | 4 |
| Unpacked size | 557.1 KB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| Maintainers | bi |

## Links

- npm: https://www.npmjs.com/package/@spacefast/zero
- Repository: https://github.com/spacefast/monorepo
- Homepage: https://github.com/spacefast/monorepo#readme
- Issues: https://github.com/spacefast/monorepo/issues
- npm.io page: https://npm.io/package/@spacefast/zero

## Dependencies (4)

- [zod](https://npm.io/package/zod.md) 4.3.6
- [preact](https://npm.io/package/preact.md) ^10.29.7
- [lucide-preact](https://npm.io/package/lucide-preact.md) ^0.575.0
- [@spacefast/common](https://npm.io/package/@spacefast/common.md) 0.5.0

## Recent versions

- 0.5.0 (latest) — 2026-09-29
- 0.4.1 — 2026-09-10
- 0.2.2 — 2026-08-28
- 0.0.26 — 2026-08-22
- 0.0.24 — 2026-08-12
- 0.0.23 — 2026-08-06
- 0.0.21 — 2026-08-05
- 0.0.20 — 2026-08-05
- 0.0.19 — 2026-08-05
- 0.0.18 — 2026-08-05
- 0.0.17 — 2026-08-05
- 0.0.13 — 2026-07-31
- 0.0.12 — 2026-07-27
- 0.0.11 — 2026-07-19
- 0.0.10 — 2026-07-19
- … 7 more at https://npm.io/package/@spacefast/zero/versions

## README

# Zero

A Zero capsule can use an authored page tree or a standalone browser app. Put addressable pages in `pages/`, collection data in `content/`, and reusable browser components in `client/`.

```text
pages/
  index.md
  about.tsx
  docs/getting-started.html
  projects/[id].tsx
client/
  index.tsx
  components/counter.tsx
content/
  posts/hello.md
server/
  index.ts
```

## Documents and interactive pages

Markdown and HTML are editable documents. TSX is a document by default: the compiler turns its JSX into Gutenberg block markup. Import a component from `client/` to add an island without turning the whole document into a browser application.

```tsx
// pages/about.tsx
import { Counter } from "../client/components/counter";

export default function About() {
  return (
    <>
      <h1>About</h1>
      <Counter />
    </>
  );
}
```

An interactive page declares its browser execution boundary explicitly:

```tsx
// pages/projects/[id].tsx
"use client";

export default function Project({ params }: { params: { id: string } }) {
  return <main>Project {params.id}</main>;
}
```

`index` names the containing route. `[id]` names one path segment; `[...rest]` names a nonempty remaining path. Parameterized routes require client rendering. Document pages have literal URLs, including `/` and nested paths.

When the capsule has authored pages, the optional `client/index.tsx` module may export a named `Layout` for interactive pages. Its default export or an `App` export does not own `/` in this mode; write `pages/index.tsx` for that page.

Without authored pages, `client/index.tsx` can instead export a default component or a named `App`. Zero mounts that component as a standalone browser application, which owns its client routes. The component can use `Router`, `Routes`, and `Route` from `@spacefast/zero/client`.

## One owner per route

Both renderers appear in the same compiled inventory. Literal routes take precedence over parameters, and parameters over catchalls. Equivalent page patterns, endpoint collisions, and authored static files that would shadow a page are build errors.

Public native posts bound to the `content` field also get document routes in this inventory, at their bare slug (`content/posts/hello.md` becomes `/hello`). Private collections do not gain public routes. A post and an authored page cannot both own the same URL. Editor-created documents acquire their routes through the ordinary source commit, build, and publish path, not an undeclared WordPress fallback.

The browser uses the same ownership rules as a hard reload. Navigation between client pages stays in the browser router. Document and unknown destinations use normal document navigation; they are never swallowed by a generic SPA fallback. Client shells live under the platform asset namespace, not at a generated root `index.html`.

## Editing documents

A page's identity comes from its canonical route, not its source extension. Editing a document authored in TSX creates canonical HTML beside the TSX source. The HTML owns the same page; subsequent builds do not execute the superseded TSX. This is an ownership change, not a claim that arbitrary TypeScript can roundtrip through Gutenberg.

Client pages remain code-owned. Put content those pages edit or display in a collection or document binding.

Publication seals each document's exact Markdown/HTML source or compiled TSX markup into the content-model release. Activation verifies those bytes and reconciles the WordPress document before advancing the active model. A request never seeds or mutates content just because someone viewed a page. Immutable version URLs render the sealed document under that version's model; dynamic blocks may still query live data through the pinned model and code.

## Local development

The local Zero dev server serves declared client routes and returns 404 for unknown routes. Full document rendering requires WordPress. Without it, document routes return `501 zero_dev_document_rendering_unsupported`; append `?spacefast_view=1` for an explicit compiled-markup preview. That preview does not execute WordPress dynamic blocks or hydrate islands and is not a production-rendering check.

`_pages/` is separate because its files are platform response templates, such as an access gate or a 404 response. They are not independently addressable application pages.

## Data and server handlers

The server entry exports one capsule. Queries expose reads, mutations perform database writes, and actions run without a database transaction. Handler arguments become the browser client's argument types.

```ts
// server/index.ts
import {
  action,
  boolean,
  capsule,
  endpoint,
  json,
  mutation,
  number,
  query,
  string,
  table,
  userId,
} from "@spacefast/zero/server";

export default capsule({
  name: "Task board",
  schema: {
    tasks: table({
      owner: userId(),
      title: string(),
      estimate: number().default(0),
      note: string().optional(),
      done: boolean().default(false),
    }).index("by_owner", ["owner"]),
  },
  auth: {
    onGuestUpgrade(ctx, { guestUserId, userId }) {
      ctx.log.info("Guest signed in", { guestUserId, userId });
    },
  },
  queries: {
    tasks: query(async (ctx) => {
      const { userId } = ctx.auth.requireIdentity();
      return ctx.db.tasks.withIndex("by_owner", (range) => range.eq("owner", userId)).collect();
    }),
    taskCount: query(async (ctx) => {
      const { userId } = ctx.auth.requireIdentity();
      return ctx.db.tasks.withIndex("by_owner", (range) => range.eq("owner", userId)).count();
    }),
  },
  mutations: {
    addTask: mutation(async (ctx, title: string) => {
      const { userId } = ctx.auth.requireIdentity();
      if (!title.trim()) throw new Error("Enter a task title.");
      const task = await ctx.db.tasks.insert({ owner: userId, title });
      ctx.invalidate("tasks", "taskCount");
      return task;
    }),
  },
  actions: {
    notify: action(async (ctx, message: string) => {
      ctx.auth.requireSignedIn();
      const url = ctx.env.NOTIFY_URL;
      if (!url) throw new Error("Set NOTIFY_URL before sending notifications.");
      const response = await fetch(url, {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ message }),
      });
      if (!response.ok) throw new Error("The notification service rejected the request.");
      return { sent: true };
    }),
  },
  endpoints: {
    health: endpoint({ method: "GET", path: "/api/health" }, () => json({ ok: true })),
  },
});
```

`userId()` stores an identity reference. It does not fill the field or restrict reads automatically. The example assigns the caller's identity and filters every read by that identity. Handlers must also check ownership before updating or deleting a caller-supplied row ID.

| Field         | Stored value                                         |
| ------------- | ---------------------------------------------------- |
| `string()`    | String                                               |
| `boolean()`   | Boolean                                              |
| `number()`    | Finite number, including decimals                    |
| `id("tasks")` | Reference to a row in the named table                |
| `userId()`    | Nonempty identity string, included in guest upgrades |

Required fields must be supplied on insert. `.default(value)` supplies an omitted field. `.optional()` permits omission and permits `null` on insert or update to clear the field; reads return an absent property rather than `null`. When an optional field also has a default, absence or `null` resolves to that default.

Rows include `id`, `createdAt`, and `updatedAt`. `insert()` returns the row; `get()` and `update()` return a row or `null`; `delete()` returns a boolean. Named indexes use `.index("name", ["field"])`. Every table also has `by_creation`.

An indexed read supports `eq`, `gt`, `gte`, `lt`, and `lte` range bounds, followed by `collect()`, `first()`, `take(n)`, `paginate({ cursor, numItems })`, or `count()`. `count()` counts the selected range without collecting its rows.

## Browser calls and loading

The browser imports the capsule as a type, so server code and environment variables stay out of the browser bundle. This example uses standalone mode with no `pages/` directory. With authored pages, put the component in `pages/index.tsx` and add `"use client"` at the top.

```tsx
// client/index.tsx
import { createClient, SignInWithGoogle, useAuth } from "@spacefast/zero/client";
import type app from "../server/index";

const api = createClient<typeof app>();

export default function App() {
  const auth = useAuth();
  const tasks = api.useQuery("tasks");
  const count = api.useQuery("taskCount");
  const addTask = api.useMutation("addTask");
  const notify = api.useAction("notify");

  if (auth.error) return <p role="alert">{auth.error}</p>;
  if (auth.isLoading || tasks === undefined || count === undefined) {
    return <p>Loading tasks...</p>;
  }

  return (
    <main>
      {!auth.isSignedIn && <SignInWithGoogle />}
      <h1>Tasks ({count})</h1>
      <ul>
        {tasks.map((task) => (
          <li key={task.id}>{task.title}</li>
        ))}
      </ul>
      <button onClick={() => void addTask("New task")}>Add task</button>
      <button disabled={!auth.isSignedIn} onClick={() => void notify("Tasks updated")}>
        Notify
      </button>
    </main>
  );
}
```

`useQuery()` returns `undefined` while its initial result is loading. A query returning a number, object, or `null` keeps that result shape; an empty array means the query actually returned an empty array. Handle loading before reading properties or calling array methods.

Mutation and action hooks return promise-returning functions. Mutations refresh live queries after a successful write. `ctx.invalidate(...queryNames)` narrows the refresh; omitting it refreshes every live query. Actions neither refresh subscriptions automatically nor replay as subscriptions. Display rejected calls through your app's error handling.

An action runs without a database transaction and has read-only database access. It can send mail through `ctx.email`, but cannot insert, update, or delete rows. It has no `ctx.query`, `ctx.mutate`, or `ctx.runQuery` helper. Action arguments are limited to 16 KiB, serialized results to 48 KiB, and execution to five seconds. Use mutations or write endpoints for database writes.

Global `fetch()` is available in queries, mutations, actions, and endpoints. Outbound requests follow the Space's egress permissions and runtime limits.

## Using React

Building your front end outside the capsule, say an Astro site with React islands? Import the hooks from `@spacefast/zero/react`. They talk to the same client, cache, and realtime socket as `@spacefast/zero/client`, and `createClient<typeof app>()` infers the same way.

```tsx
import { createClient, useAuth, useQuery } from "@spacefast/zero/react";
import type app from "../server/index";

const api = createClient<typeof app>();

export function Tasks() {
  const auth = useAuth();
  const tasks = useQuery(api.tasks());
  const addTask = api.useMutation("addTask");

  if (auth.isLoading || tasks === undefined) return <p>Loading tasks...</p>;
  return (
    <>
      <ul>
        {tasks.map((task) => (
          <li key={task.id}>{task.title}</li>
        ))}
      </ul>
      <button onClick={() => void addTask("New task")}>Add task</button>
    </>
  );
}
```

You get `useQuery`, `useMutation`, `useAction`, `usePaginatedQuery`, and `useAuth`, plus `storage` and the `signIn*`/`signOut` functions. React 18 and 19 both work; `react` is an optional peer dependency. A server render shows the loading state (`undefined` queries, `isLoading` auth) and the browser takes over on hydration.

There's no React router or `<SignIn*>` components. Your framework owns routing, and a sign-in button is `<button onClick={() => void signInWithGoogle()}>`.

## Authentication and guest upgrades

`useAuth()` exposes `isLoading`, the effective `requireSignIn` policy, and an optional `error` alongside these identity states:

| State      | `userId` | `isGuest` | `isSignedIn` |
| ---------- | -------- | --------- | ------------ |
| Signed out | `null`   | `false`   | `false`      |
| Guest      | String   | `true`    | `false`      |
| Signed in  | String   | `false`   | `true`       |

`ctx.auth.requireIdentity()` accepts a guest or signed-in identity. `ctx.auth.requireSignedIn()` requires a signed-in identity. Both return the narrowed identity and reject an unsuitable caller. Provider metadata comes from the authenticated session; an avatar URL is not proof of a provider.

A capsule permits guest use by default. Set `auth: { requireSignIn: true }` to require sign-in for application handlers. Google sign-in uses the deployment's configured Google provider. `SignInWithGoogle` and `signInWithGoogle()` start that flow; `signOut()` returns a promise. Space access rules still apply to the caller.

When a verified guest signs in, Zero transfers fields declared with `userId()` from the guest ID to the signed-in ID. The `onGuestUpgrade(ctx, { guestUserId, userId })` hook runs once before that transfer, within the same database transaction. If the hook throws, its database writes, the reference transfer, and the completion receipt roll back together. Plain `string()` fields are not transferred.

The browser client initializes authentication and guest continuity automatically. A custom HTTP client must initialize authentication before expecting guest-owned data to transfer. Local `sf auth as` identities are development guests and cannot impersonate signed-in accounts.

## App users

Enable **Users** in your Space dashboard, choose your sign-in methods, and publish. Google uses Spacefast's managed provider by default; your own Google client is an advanced setting. Google, Gravatar, and Spacefast have separate sign-in helpers. Spacefast sign-in uses native Access; app signup creates no dashboard, team, or billing permission.

In a Zero client page or island:

```tsx
import { useAuth, SignInWithGoogle, signOut } from "@spacefast/zero/client";

export function Account() {
  const user = useAuth();
  if (!user.isAuthenticated) return <SignInWithGoogle />;
  return <button onClick={() => void signOut()}>Sign out {user.displayName}</button>;
}
```

For plain JavaScript, load the Space's script and use `Spacefast.users`:

```html
<script src="/__spacefast/sdk.js"></script>
<button id="login">Sign in with Google</button>
<script>
  document.querySelector("#login").onclick = () =>
    Spacefast.users.signInWithGoogle({ returnTo: "/app" });
  Spacefast.users.getCurrentUser().then((user) => {
    if (user) console.log(`Hello, ${user.displayName}`);
  });
</script>
```

`signInWithGravatar()` and `signInWithSpacefast()` select those methods. `getCurrentUser()` returns `{ id, displayName }` or `null`; `signOut()` revokes the current app session. Send protected requests with `Spacefast.users.fetch("/api/notes", options)` or `authenticatedFetch` from `@spacefast/zero/client`. These helpers use the app's HttpOnly session cookie and refuse cross-origin destinations. Keep secrets and authorization checks on the server.

Zero handlers receive the verified app identity as `ctx.auth`. Scope each user's records to `ctx.auth.userId` and reject requests when `ctx.auth.isAuthenticated` is false. PHP handlers use the same identity:

```php
<?php
$user = sf_auth();
if (!$user['isAuthenticated']) {
    http_response_code(401);
    exit;
}
header('Content-Type: application/json');
echo json_encode(['userId' => $user['userId']]);
```

An external SSR server can check the live app session through the configured Space origin:

```ts
import { verifyUser } from "@spacefast/zero/server";

export async function GET(request: Request) {
  const user = await verifyUser(request, { origin: "https://your-app.spacefast.com" });
  if (!user) return new Response("Sign in to continue", { status: 401 });
  return Response.json({ userId: user.id }, { headers: { "Cache-Control": "private, no-store" } });
}
```

Set `origin` from your server configuration, never from an unchecked request header. Browser writes must carry the exact app Origin. Verification errors fail the request; do not treat them as a successful login. Suspension, session revocation, and disabling Users take effect on the next protected request. App IDs remain stable across your Space's custom domains; each hostname has its own sign-in cookie.

The hosted `/identity` account page supports verified secondary emails, provider linking, sessions, passkeys, and authenticator codes. Linking requires a recent sign-in to the existing account. Matching email addresses never silently combine provider accounts. Owners inspect accounts, suspend access, revoke sessions, and complete requested deletions from **Users** in the dashboard or the canonical Users API, CLI, and MCP operations.

## Storage

```ts
import { storage } from "@spacefast/zero/client";

export async function uploadAttachment(file: File) {
  const object = await storage.upload(file, { public: false });
  return { id: object.id, url: object.url };
}
```

Uploads are private by default. `{ public: true }` returns a shareable URL with a revocable read key. Private objects use authenticated reads. `storage.get(id)` returns a `Response` or `null`; `storage.delete(id)` returns `Promise<void>`. The upload receipt contains `id`, `public`, `contentType`, `size`, and `url`.

Store the object's `id` for later API calls. A public URL grants read access to anyone who has it. Deletion remains an authenticated operation. Authenticated reads use `private, no-store` caching.

## Signing tokens and checking signatures

`ctx.jwt.sign` mints short-lived `at+jwt` access tokens, for example a customer token for the Spacefast Partner API. `ctx.crypto` hashes and checks MACs. Both run in the runtime, and the key stays there.

```ts
issueToken: action(async (ctx) => {
  const { userId } = ctx.auth.requireIdentity();
  return ctx.jwt.sign({
    key: "PARTNER_SIGNING_KEY",
    kid: "webhosting-1",
    claims: {
      iss: "https://webhosting.example",
      aud: "your-partner-audience",
      sub: (await ctx.crypto.sha256(userId)).slice(0, 32),
      client_id: "webhosting",
    },
    ttlSeconds: 600,
  });
}),
```

`key` names a secret variable and must be a string literal. Publish reads it and keeps that variable out of `ctx.env`. The header is always `{ alg: "EdDSA", typ: "at+jwt", kid }`. The runtime sets `iat`, `exp` and a random `jti`. `ttlSeconds` defaults to 300 and is capped at 1800.

The key is a base64url Ed25519 seed of 32 bytes. Generate one and the public key to register under `system.tokenIssuers`:

```sh
node -e 'const k=require("crypto").generateKeyPairSync("ed25519");console.log("seed", k.privateKey.export({format:"jwk"}).d);console.log("publicKey", k.publicKey.export({format:"jwk"}).x)'
printf %s "<seed>" | sf env set PARTNER_SIGNING_KEY --value-from-stdin --space my-app
```

| Call                                              | Returns                       |
| ------------------------------------------------- | ----------------------------- |
| `ctx.crypto.sha256(data)`                         | Lowercase hex digest          |
| `ctx.crypto.hmacSha256({ key: "SECRET", data })`  | Lowercase hex HMAC-SHA256     |
| `ctx.crypto.hmacSha256({ key: { value }, data })` | Same, with a non-secret key   |
| `ctx.crypto.timingSafeEqual(a, b)`                | `true` when the strings match |

Compare signatures with `timingSafeEqual`, never `===`. A key name the handler did not write literally fails with `crypto_key_unknown`; a declared key with no value fails with `crypto_key_unavailable`.

## HTTP endpoints

`endpoint({ method, path }, handler)` defaults GET and HEAD to read-only execution. Other methods default to write execution. `{ readOnly: true }` makes a POST or other method read-only; `{ readOnly: false }` selects write execution.

Read-only endpoints receive the query context. Write endpoints receive the mutation context. Response helpers include `json()`, `text()`, `empty()`, and `redirect()`. Endpoint routes must not collide with authored page routes.

## CLI workflow

```sh
sf init task-board --runtime zero --template guestbook
cd task-board
sf dev --port 4173
```

From another terminal in the project:

```sh
sf zero queries --port 4173
sf db list --port 4173
sf db export --port 4173 --out ./backup.json
sf auth as alice
sf auth reset
sf build
sf publish
```

`sf auth as <name>` selects a local `guest:<name>` identity. `sf dev run-many --count 3 --base-port 4173` starts independent instances with state held in memory. Each instance compiles its source at startup; the command does not watch for source changes.

Commands for a linked or selected hosted Space include:

```sh
sf env set NOTIFY_URL https://example.com/notify --space task-board
sf logs task-board
sf storage put ./photo.png --public --space task-board
sf storage ls --space task-board
sf storage get OBJECT_ID --out ./download.png --space task-board
```

`sf spaces archive --space task-board` stops serving while retaining versions, source, database, storage, and domains. `sf spaces restore --space task-board` resumes serving the retained Space.

`sf zero queries` lists declared query names. `sf zero call` invokes WordPress Abilities; it does not invoke capsule queries, mutations, or actions. Application calls use the typed browser client.

Zero uses native Space publishing, permissions, storage IDs, and database references. It also supports the document pages, islands, and content editing described above. The local runtime is useful for application development; the WordPress-backed runtime owns document rendering and hosted service behavior.

---
_Source: https://npm.io/package/@spacefast/zero · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
