Who may type
A collaborative session has no rules of its own. It reads the access rules already on the collection, once for the document and once for every marked field.
Your document opened read-only and you did not ask it to. Or a field you marked throws the moment you touch it. Both answers are in the collection file, not in the CRDT config.
The document grant
The server resolves two grants when you call connect().
| Grant | Comes from |
|---|---|
view | the collection's read rule, evaluated against the loaded row |
edit | the collection's update rule, against the same row |
Both rules run with the stored row as data. The update rule also gets an
empty input, because there is no patch yet. So it has to decide on the row
alone. A rule that returns a row filter is matched against that one row. A rule
that throws is read as a refusal.
Fail the read rule and the open is refused outright. Pass read, fail
update, and a mode: "edit" open is refused too. Add fallback: "view" to
take the read-only session instead.
Anonymous is never an option
Opening a session needs a user session, an OAuth principal or an agent
credential. read: true opens the REST route to anonymous callers. It does
not open a collaborative session to them.
The field grants
Each marked field is graded separately, and the document grant caps it.
| What you declared | What the browser gets |
|---|---|
| nothing | edit, when the document has edit |
fields: { content: { read: false } } | no grant, and every call on it throws |
fields: { content: { update: false } } | view on that field |
.access({
read: ({ session }) => !!session?.user,
// `data` is the stored row, so this needs an `ownerId` field on it.
update: ({ session, data }) => data.ownerId === session?.user?.id,
fields: {
// Everyone signed in watches. Only the owner types.
content: { update: ({ user, doc }) => doc?.ownerId === user?.id },
},
})Field rules take read, create and update. Only two of them reach a
collaborative field. create is about a write the collaborative client never
makes. Field-level access covers the
context each rule receives.
Deny read on every marked field and the open is refused too. A session with nothing in it is not a session.
What a refusal looks like in the browser
Grants arrive with the session and land on fieldGrants in the ready state.
The ports throw rather than quietly doing nothing.
| Call | Throws |
|---|---|
write into a field you hold view on | CrdtMutationError FIELD_VIEW_ONLY |
| write into a field you were not given | CrdtMutationError FIELD_HIDDEN |
| read a field you were not given | CrdtReadError FIELD_HIDDEN |
So render from fieldGrants rather than from try and catch. The guide does
that in one line, setting readOnly on the textarea.
A refused open says nothing about why
Every failure on the open route answers the same way, HTTP 404 with
CRDT_UNAVAILABLE. A failed rule, a short secret and an anonymous caller
all look identical in the browser. Work out which one it was on the server.
Defaults
Write no .access() at all and each rule falls back to your app's
defaultAccess. Past that it requires a session. So every signed-in user gets
view and edit on every marked field, and nobody else gets in. That is the
right default for an internal tool and the wrong one for a public site.
The working result
You can say, per person and per field, who watches and who types. It is the same
object that already guards GET /api/articles. There is no second policy to
keep in sync, and no way for the two to disagree.
Deploy it and connect Claude
Claude's remote connectors run from Anthropic's cloud, so they cannot reach localhost. This is the public deployment they need and the checks that prove it works before you add the connector.
Comments
A comment points at a sentence, not at character 412. This stores the pin beside an ordinary comment row and resolves it after everybody else has typed.