QUESTPIE
SchemaAccess control

Field-level access

The `fields` key inside `.access()` allows or denies one field at a time, independently of the rule that guards the row.

View markdown

Some columns should never leave the server, and some should never change after the first write. A row rule cannot express either, because it decides about the whole row. access.fields decides about one field.

Per field, per operation

Keys are field paths. A dotted path reaches into a nested field. Each entry takes read, create and update, and each of those is a boolean or a predicate.

src/questpie/server/collections/customers.ts
.access({
	read: true,
	fields: {
		// Only staff see the internal note.
		internalNotes: { read: ({ user }) => user?.role === "admin" },
		// The VAT number can be set once and never edited.
		"billing.vatId": { update: false },
	},
})

Field rules only allow or deny. They cannot return a row filter, because the row has already been decided by then.

What a denial does

FlagWhen it resolves to false
read: falsethe field is dropped from the response
create: falsesending the field on create throws forbidden with the fieldPath
update: falsechanging the field throws forbidden with the fieldPath

Only fields actually present in the input are checked. On update, a value equal to the one already stored is skipped, so resubmitting a whole record without touching a frozen field goes through.

The context a field rule gets

A field rule receives a small object, not the full app context: user is your generated session user, doc is the row on read and update and is undefined on create, operation is "read", "create" or "update", and req is the incoming request when there is one. Anything you added in appConfig({ context }) is spread in alongside them.

fields: {
	salary: {
		read: ({ user, doc }) => user?.role === "hr" || doc?.userId === user?.id,
	},
}

Meta fields are never read-filtered

id, _title, createdAt, updatedAt and deletedAt skip the read filter, so a rule on one of them has no effect on the response.

Precedence with field modifiers

Three sources feed the same map, and for a given field and flag the later one wins:

  1. .access({ ... }) on the field definition itself.
  2. access.fields on the collection.
  3. .inputFalse() and .outputFalse() on the field definition.

.inputFalse() contributes { create: false, update: false } and .outputFalse() contributes { read: false }. Both are applied last, so a field marked .inputFalse() stays unwritable even if access.fields allows it. Keep the collection's access.fields as your policy surface and use the modifiers for fields that are structurally read-only, like an autoNow() timestamp.

Where the checks sit in a write

A create runs beforeOperation, the row rule, beforeValidate, the field write rules, validation, beforeChange, the INSERT.

An update runs beforeOperation, loads the rows, tests each against the row rule, then beforeValidate, validation, the field write rules, beforeChange, the UPDATE.

The two differ on one point. A denied field on create never reaches the Zod schema. On update the schema has already parsed the patch by the time the field rule denies it.

The read filter runs on the way out, and its position differs by direction. On a plain read the filter strips denied fields before afterRead sees the row. On a write the order is reversed, and afterRead sees the full row before the filter runs. An afterRead hook that depends on a denied field therefore works after a create and not after a read.

Globals

A global takes the same fields key, with read and update only. There is no create on a singleton.

  • Access control for the row rules these sit beside.
  • Fields for the modifiers named above.
  • Hooks for the full lifecycle the ordering belongs to.

On this page