branch:
readonly-connections.md
14763 bytesRaw
# Readonly Connections
This document describes the design of the readonly connections feature: what it does, the key decisions made, alternatives we considered, and known limitations.
## Problem
Agents are collaborative — multiple WebSocket clients connect to the same agent instance and share state. But not every client should be allowed to modify that state. A dashboard viewer shouldn't be able to change settings. A spectator in a game shouldn't be able to move pieces. A free-tier user shouldn't be able to trigger expensive mutations.
We need a way to mark certain connections as "readonly" and enforce that restriction at the framework level, not in userland.
## Design goals
1. **Declarative** — developers declare _which_ connections are readonly, not _how_ enforcement works
2. **Enforcement at the framework boundary** — readonly checks happen inside `setState()`, so they can't be bypassed by forgetting a check in a callable
3. **No boilerplate** — no manual permission checks needed in every `@callable()` method
4. **Survives hibernation** — readonly status persists when the Durable Object goes to sleep and wakes up
5. **Invisible to user code** — the internal flag can't be accidentally read, overwritten, or leaked through `connection.state`
## API surface
### Server-side (Agent class)
| Method | Purpose |
| ---------------------------------------------- | ------------------------------------------------------- |
| `shouldConnectionBeReadonly(connection, ctx)` | Hook called on connect. Return `true` to mark readonly. |
| `setConnectionReadonly(connection, readonly?)` | Dynamically change readonly status at any time. |
| `isConnectionReadonly(connection)` | Check a connection's current readonly status. |
### Client-side (useAgent / AgentClient)
| Option | Purpose |
| --------------------------- | --------------------------------------------------- |
| `onStateUpdateError(error)` | Callback when client-side `setState()` is rejected. |
RPC errors from blocked callables surface as rejected promises from `agent.call()`.
## How enforcement works
Readonly is enforced in two places:
### 1. Client-side `setState()` — in the message handler
When a client sends a `CF_AGENT_STATE` message, the `onMessage` wrapper checks `isConnectionReadonly(connection)` before processing it. If readonly, the server sends back a `CF_AGENT_STATE_ERROR` message and does **not** call `_setStateInternal`.
This path handles: `agent.setState(newState)` from client code (React hook, PartySocket, etc.).
### 2. Server-side `setState()` — in the public method
When a `@callable()` method calls `this.setState()`, the public `setState()` method checks `agentContext.getStore()` for the current connection. If the connection is readonly, `setState()` throws `Error("Connection is readonly")`.
The error propagates through the RPC handler's try/catch and is sent back as an RPC error response (`{ success: false, error: "Connection is readonly" }`).
This path handles: any `@callable()` that calls `this.setState()` internally.
### Why `setState()` and not the RPC handler?
We considered four options for blocking mutations from readonly connections:
| Approach | Pros | Cons |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| **A. Manual checks in each callable** | Works today, explicit | Boilerplate, easy to forget, security hole if missed |
| **B. `@callable({ mutates: true })` decorator flag** | Declarative per-method | Opt-in — developers have to remember to tag methods |
| **C. `shouldAllowRPC(connection, method)` hook** | Maximum flexibility | More work for developers, whitelist vs blacklist footgun |
| **D. Check inside `setState()`** | Single enforcement point, no decorator changes, read-only callables work automatically | Side effects before `setState()` still run (see Caveats) |
We chose **D** because it matches the mental model: "readonly" means "cannot change state." A readonly connection can still call RPCs that _read_ data — it just can't write anything. The framework enforces this automatically without requiring any annotation on callable methods.
### Why `setState()` and not `_setStateInternal()`?
There are two paths into state mutation:
1. **Client-side** — arrives as a `CF_AGENT_STATE` message, already has its own readonly guard before calling `_setStateInternal(state, connection)`
2. **Server-side** — `this.setState(state)` calls `_setStateInternal(state, "server")`
Putting the check in `setState()` keeps each entry point responsible for its own access control:
- Client message handler → checks readonly → calls `_setStateInternal`
- `setState()` → checks readonly via context → calls `_setStateInternal`
- `_setStateInternal` → focuses on validation (`validateStateChange`), persistence, and broadcast
This also means `validateStateChange` (data validity) and the readonly check (access control) live at different levels. Access control comes first, before we even look at the data.
The `state` getter also calls `_setStateInternal` for initialization (persisting `initialState` on first access). These are framework-level operations that must bypass the readonly check, which is another reason the check belongs in the public `setState()`, not in `_setStateInternal()`.
### What about workflows?
`_workflow_updateState` calls `this.setState()`. But workflows don't have a connection in `agentContext` — the store's `connection` is `undefined`. So the readonly check passes harmlessly.
## Storage: connection state wrapping
### Evolution
The readonly flag storage went through three designs:
1. **SQL table** (original PR) — `CREATE TABLE cf_agents_readonly_connections`. Worked but added schema, queries, and cleanup logic for a single boolean.
2. **`connection.setState({ _readonly: true })`** (first refactor) — leveraged partyserver's built-in per-connection state, which survives hibernation. Much simpler. But had a fatal flaw: any call to `connection.setState({ ... })` without the callback form would overwrite `_readonly`.
3. **Namespaced connection attachment** (current) — wraps `connection.state` and `connection.setState()` on each connection to hide the `_cf_readonly` key from user code.
### How the wrapping works
When the Agent first encounters a connection (in `onConnect` or `onMessage`), `_ensureConnectionWrapped(connection)` is called. This method:
1. **Detects** whether `state` is an accessor property (getter) or a data property via `Object.getOwnPropertyDescriptor`
2. **Captures** raw state access — for accessor properties, it binds the original getter directly; for data properties, it snapshots the current value into a closure variable to avoid a circular reference after the override
3. **Stores** the raw accessors in a `WeakMap<Connection, { getRaw, setRaw }>` (the `_rawStateAccessors` map)
4. **Overrides** `connection.state` (getter) to strip `_cf_readonly` from the returned value
5. **Overrides** `connection.setState` to preserve `_cf_readonly` when user code sets new state
The accessor vs. data property distinction matters because partyserver defines `state` as a getter (via `Object.defineProperties`), but we also need to handle non-partyserver connections or future implementations where `state` might be a plain data property. Without this, the fallback `() => connection.state` would call our overridden getter after the property is replaced, creating an infinite loop.
After wrapping:
- `connection.state` returns everything **except** `_cf_readonly`
- `connection.setState({ myData: "foo" })` stores `{ _cf_readonly: <current>, myData: "foo" }` in the raw attachment
- `connection.setState((prev) => ({ ...prev, count: 1 }))` receives `prev` without `_cf_readonly`, but the flag is merged back in
- `setConnectionReadonly` / `isConnectionReadonly` use `_rawStateAccessors` to read/write the flag directly
### Why this required a partyserver change
Partyserver defines `state` and `setState` on connection objects via `Object.defineProperties` — and prior to our patch, both properties had `configurable: false` (the default). This prevented us from redefining them with `Object.defineProperty`.
The fix was a two-line change in partyserver: add `configurable: true` to both the `state` and `setState` descriptors in `createLazyConnection`. The default behavior is unchanged — `configurable` only means the property _can_ be redefined, not that it behaves differently.
### Why `_cf_readonly` and not `_readonly`?
The `_cf_` prefix namespaces the key to avoid collisions. Without it, a user storing `{ _readonly: false }` in their connection state would accidentally disable the feature. The prefix makes accidental collision vanishingly unlikely. The key name is defined once as the module-level constant `CF_READONLY_KEY` so it stays consistent across `_ensureConnectionWrapped`, `setConnectionReadonly`, and `isConnectionReadonly`.
### Why not a completely separate namespace (e.g. `{ _cf: { ... }, _user: { ... } }`)?
We considered storing all user state under a `_user` sub-key so there could be zero collision. But this breaks when user state is `null` or a primitive (you'd need to wrap it in an object). It also means MCP transport code — which stores `_standaloneSse` and `requestIds` in connection state — would need to be rewritten to use the `_user` namespace.
The single-key approach (`_cf_readonly` alongside user keys) is simpler, handles all state types, and doesn't require changes to existing code that uses `connection.state`.
### What about `getConnections()`?
Connections returned by `getConnections()` are the same JavaScript objects that were wrapped in `onConnect`/`onMessage` (partyserver's `createLazyConnection` checks `isWrapped(ws)` and returns the existing wrapper). So our `Object.defineProperty` overrides persist.
After hibernation, the Durable Object creates new wrapper objects for rehydrated WebSockets. The first `onMessage` call re-wraps them via `_ensureConnectionWrapped`.
## Caveats
### Side effects in callables still run
The readonly check happens inside `this.setState()`, not at the start of the callable. If a method does work before calling `setState()`, that work still executes:
```typescript
@callable()
async processOrder(orderId: string) {
await sendEmail(orderId); // runs
await chargePayment(orderId); // runs
this.setState({ ... }); // throws — but damage is done
}
```
The recommended pattern is to put the state write first:
```typescript
@callable()
async processOrder(orderId: string) {
this.setState({ ... }); // throws immediately for readonly
await sendEmail(orderId); // only runs if setState succeeded
await chargePayment(orderId);
}
```
This is an inherent tradeoff of enforcing at the `setState` level rather than at the RPC handler level. We chose this approach because it doesn't require developers to annotate every callable, and most callables are simple state machines where `setState` is the primary operation. For the rare case of callables with expensive side effects, the "state write first" pattern is straightforward.
### Readonly is per-connection, not per-user
There's no built-in mapping from readonly status to user identity. If a user opens two tabs — one readonly, one writable — they have full write access from the second tab. Authentication and authorization are the developer's responsibility; readonly connections are a transport-level primitive.
### Readonly doesn't restrict `this.sql` or other side effects
Only `this.setState()` is gated. A callable can still write to SQL, send emails, call external APIs, or do anything else. Readonly means "cannot change the agent's shared state" — it's not a general permission system.
### HTTP requests bypass readonly entirely
Readonly is a WebSocket concept. HTTP requests (`onRequest`, `agentFetch`, `getAgentByName` + `agent.fetch()`) run with `connection: undefined` in the agent context, so the `setState()` check always passes.
This is by design:
- **Callables are WebSocket-only** — there's no HTTP callable path. `routeAgentRequest` only handles WebSocket upgrades; plain HTTP falls through to `onRequest`. So clients can't invoke `@callable()` methods over HTTP.
- **`onRequest` is developer-authored** — unlike the WebSocket message handler (which has automatic setState/RPC processing), `onRequest` is entirely custom code. There's no framework behavior to gate.
- **HTTP requests are stateless** — there's no persistent "connection" to mark as readonly. Each request stands alone. Standard HTTP auth (tokens, headers, cookies) is the right tool here.
If your `onRequest` handler calls `this.setState()`, it will always succeed. Protect HTTP endpoints with authentication/authorization in your `onRequest` implementation — this is standard practice and not something the readonly feature should absorb.
A future extension could add `shouldRequestBeReadonly(request)` to set a flag in the agent context for HTTP requests too, but that's essentially HTTP middleware/auth, which most frameworks leave to the developer.
## Testing
Tests live in `packages/agents/src/tests/readonly-connections.test.ts` and cover:
- `shouldConnectionBeReadonly` hook marking connections based on query params
- Client-side `setState()` blocked for readonly, allowed for writable
- Mutating RPCs (`incrementCount` → `this.setState()`) blocked for readonly
- Non-mutating RPCs (`getState`) allowed for readonly
- Mutating RPCs allowed for writable connections
- Dynamic readonly status changes at runtime
- State broadcasts reaching readonly connections (they can still observe)
- Readonly status restored after reconnection (hibernation survival)
- Multiple connections with mixed readonly states
The test agent (`TestReadonlyAgent` in `agents/readonly.ts`) has `incrementCount` (mutating) and `getState` (non-mutating) callables, plus `checkReadonly` and `setReadonly` for dynamic status changes.