<!--
Sitemap:
- [rxfy](/index): Typed, normalized, reactive state — built on RxJS
- [Comparison](/comparison): rxfy versus Redux Toolkit, MobX, Jotai, TanStack Query, and TanStack DB
- [Inspired by](/inspired-by): the libraries and ideas rxfy grew out of
- [Agent Skills](/agent-skills): Accurate rxfy context for AI coding assistants
- [Examples](/examples): Runnable apps, from client-only to fully synced
- [Changelog](/changelog)
- [Getting Started](/getting-started)
- [Create Store](/getting-started/create-store): Normalized reactive state in a client-only app
- [Add SSR](/getting-started/add-ssr): Render the first paint on the server, hydrate with no refetch
- [Add Sync Client](/getting-started/add-sync-client): The full stack: the server publishes, the client syncs
- [Core Concepts](/core-concepts): The ideas rxfy is built on
- [Observables](/core-concepts/observables): A value that changes over time, that you can subscribe to
- [Normalization](/core-concepts/normalization): Store each entity once, reference it by id
- [Late Unwrapping](/core-concepts/late-unwrapping): Unwrap async state at the leaf, not the trunk
- [Server-Side Rendering](/core-concepts/ssr): Dehydrate on the server, hydrate with no refetch
- [rxfy](/rxfy): The core package: atoms, lenses, models, and states
- [createModel](/rxfy/create-model): Typed entities in normalized storage
- [defineState](/rxfy/define-state): Typed, normalized state shapes with fetching and mutations
- [createAtom](/rxfy/create-atom): A reactive cell with synchronous get, set, and modify
- [createLens](/rxfy/create-lens): A two-way view into part of an Atom
- [React Bindings](/react): Hooks and helpers for using rxfy in React
- [useStateData](/react/use-state-data): Fetch, normalize, and subscribe to a query
- [useStatePagedData](/react/use-state-paged-data): Paginated and infinite-scroll lists
- [useModelStore](/react/use-model-store): Subscribe to one normalized entity by id
- [useAtom](/react/use-atom): Two-way binding for any IAtom
- [Pending](/react/pending): Render pending, rejected, and fulfilled UI for any observable
- [usePending](/react/use-pending): The status value behind Pending
- [useObservable](/react/use-observable): Bind a raw Observable to React
- [Sync Client in React](/react/sync-client): StoreProvider, useSyncClient, and update handles
- [rxfy-client](/framework/client): The framework-agnostic browser sync runtime
- [createSyncClient](/framework/client/create-sync-client): Connect a transport and drive the sync loop
- [readSsrGrants](/framework/client/read-ssr-grants): Lift SSR-embedded channel grants
- [rxfy-server](/framework/server): Bind Drizzle tables, write, and publish sync updates
- [defineResource](/framework/server/define-resource): Tie a Drizzle table to an rxfy model
- [createSync](/framework/server/create-server): Wire a storage adapter, hub, and secret into a Live object
- [createInMemoryHub](/framework/server/hub): The socket-keyed pub/sub backbone
- [Writes](/framework/server/writes): sync.create / sync.update / sync.delete and touch
- [Storage adapters](/framework/server/storage-adapters): Persist writes with Drizzle or in memory
- [Sync messages](/framework/server/messages): What travels between server and client
- [Grants](/framework/server/grants): The server signs what it serves; the client subscribes with the token
- [rxfy-ws](/framework/ws): The default WebSocket transport
- [createWsServer](/framework/ws/server): Attach a Hub to WebSocket connections
- [createWsClient](/framework/ws/client): The browser transport with reconnect and replay
- [Custom transports](/framework/ws/custom-transport): Bring your own ClientTransport
- [Guides](/guides): Task-focused walkthroughs of common rxfy patterns
- [Pagination and infinite scroll](/guides/pagination): Load and append pages into one normalized list
-->

# Grants \[The server signs what it serves; the client subscribes with the token]

Sync subscriptions ride **stateless signed channel grants**. The server records nothing at serve
time — it *signs*. `sync.serve` returns the parsed payload plus a reserved `$grant` field: a JWT
whose claims are the state's channel and an expiry. The client lifts that grant automatically,
subscribes with it over the WebSocket, and renews it before it expires. There is no session, no
server-held subscription table, no header to inject.

## Serving = signing

A read endpoint wraps its result in `sync.serve` — it parses the raw payload (the state's *input*
shape, e.g. DB rows with unbranded ids and extra columns) through the state's schemas, signs a
grant for `stateChannel(state, params)`, and returns the parsed shape (ids branded, unknown keys
stripped) with the grant attached as `$grant`. Raw Drizzle rows go in with no casts, and there is
**no `req` argument** — serving is stateless, the hub is never touched:

```ts [server/api.ts]
.get("/todos", async (c) => {
  const rows = await db.select().from(todos);
  // Parses the rows through the state's schemas and attaches a signed channel grant as `$grant`.
  return c.json(sync.serve(todosState, {}, { todos: rows }));
})
```

Pass the **same** `params` you pass to `useStateData` — window keys are stripped internally, so the
signed channel always matches the one the client's `updatesAvailable$` counts. A consumer that
ignores `$grant` (curl, server-to-server) just sees one extra string field.

SSR renders sign everything at once: `useStateData` logs each rendered state's channel into
`registry.channels` during SSR, and [`sync.hydration(registry)`](/framework/server/create-server)
signs one grant per logged channel and embeds them in the hydration payload as `grants: string[]`.
One call at the end of the render hands the whole thing to the client:

```tsx [entry-server.tsx]
onAllReady() {
  // collect pipe into `html`, then:
  resolve({ html, state: sync.hydration(registry) });
}
```

The entry implements the shared `RenderFn` contract from `server/render-types.ts` — it receives
`live` and `apiFetch` (hono's in-process `app.request`) as parameters rather than importing them:
in dev, Vite's SSR module graph is separate from the server's, and the hub, the db, and the api
must stay single instances across both. `server/render.ts` calls `render(url, live, api.request)`
— `request` is a bound class-field arrow, safe to detach — and the entry builds
`createApiClient(apiFetch)` from it, so SSR data fetching goes through the server's own endpoints
in-process.

## The client lifts and subscribes

Because the grant rides the data, the client needs **zero plumbing** — no session header, no fetch
wrapping. `useStateData` already returns the payload to the framework; after `fetchFn` resolves the
Sync Client strips `$grant`, normalizes the rest, and sends one `subscribe` frame carrying just the
grant. The grant's claims authorize both the channel and the exact entity topics it was signed for,
so the server subscribes the socket to precisely those. SSR grants take the same path via
[`readSsrGrants`](/framework/client/read-ssr-grants).

The API client is therefore plain — no session header to attach:

```ts [src/api-client.tsx]
export function createApiClient(serverFetch?: ApiFetch) {
  return serverFetch
    ? hc<AppType>("http://ssr.internal", { fetch: serverFetch }) // SSR: hono's in-process app.request
    : hc<AppType>("/api"); // browser: a real network trip, nothing extra to carry
}
```

```ts [src/entry-client.tsx]
const syncClient = createSyncClient({
  registry,
  transport: createWsClient({ url }),
  renewUrl: "/api/live/renew", // where the client posts grants nearing expiry
});
```

Client-only first fetches subscribe the moment the socket opens — grants are client-held, so there
is no ordering requirement and no correlator race.

## Renewal

Grants expire (default TTL 15 minutes). The client runs one renewal timer: shortly before the
soonest expiry it POSTs the expiring grants to `renewUrl` and replaces them. Mount that endpoint
**behind the app's own auth middleware** — this is where revocation actually bites. A user whose
access was withdrawn fails the middleware, the reissue never happens, and their real-time sync ends at
`exp`. `sync.renew` verifies each grant (accepting tokens expired within a grace window, default 5
minutes) and reissues it, or returns `null` when denied:

```ts [server/api.ts]
.post("/live/renew", authed, async (c) => {
  const { grants } = await c.req.json<{ grants: string[] }>();
  return c.json({ grants: grants.map((g) => sync.renew(g)) });
})
```

A denied renewal (revoked access, or a rotated secret) drops that grant on the client: updates for
its state end, the data goes quietly static, and the recovery is a refetch — which returns a fresh
grant or an error.

## The signing secret

`createSync` requires a `secret` — the HMAC key it signs grants with. The WebSocket server
([`createWsServer`](/framework/ws/server)) verifies grants with the **same** secret, so the two
must share it:

```ts [server/live.ts]
export const SECRET = process.env.RXFY_SECRET ?? "dev-secret-change-me";
export const sync = createSync({ storage: drizzleStorage(db), hub, secret: SECRET });
```

Rotating the secret invalidates every outstanding grant at once: renewals fail, clients degrade to
static data, and refetches mint fresh grants. Graceful by construction — no restart choreography.

:::note\[Entity authorization is bound into the grant]
`sync.serve` signs the served payload's exact entity topics (`name:id`) into the grant's claims, and
the WebSocket server subscribes a socket to **only** the channel + entities its grant enumerates.
A grant is therefore a precise capability — it can watch exactly the rows it was served, nothing
more — so entity ids need not be unguessable (serial integer PKs are fine).

For large payloads the grant grows with the entity count; it rides the data plane (smaller than the
rows it accompanies) and, on reconnect, the same id list the subscribe frame already carried. Enable
WebSocket `permessage-deflate` in production — a JWT of repeated `name:id` strings compresses well.
:::

:::warning\[Cache-Control on state endpoints]
Endpoints that return `$grant` should send `Cache-Control: private, no-store`. A cached
personalized response already leaks a data snapshot; with grants it would also leak a **sync
capability** — a token that keeps pushing updates until it expires.
:::

## What stays manual

* [`touch(stateDescriptor, params)`](/framework/server/writes) on writes — only the app knows which
  lists a write invalidates.
* One `sync.serve` per read endpoint — the server can't see what a plain Drizzle read served.
* One mounted `renew` route — behind your own auth.

See [`createSync`](/framework/server/create-server) for `sync.serve` / `sync.renew` /
`sync.hydration` in the full `Live` object, [`createInMemoryHub`](/framework/server/hub) for the
socket-keyed hub, and [rxfy-client](/framework/client) for `createSyncClient` and `readSsrGrants`
on the browser side.
