QUESTPIE
Schema

Access control

One `.access({ ... })` call on a collection decides who may read, create, update and delete each row. The REST API, the typed client and the admin all obey the same object.

View markdown

Who is allowed to touch this row? The rule lives next to the fields it guards, so there is no second policy layer to keep in sync. This page covers the four CRUD rules, what each one receives, and what happens when you leave one out.

One rule per operation

.access() takes an object keyed by operation. Each value is a boolean, or a function that receives the request context and decides.

src/questpie/server/collections/posts.ts
import { collection } from "#questpie/factories";

export const posts = collection("posts")
	.fields(({ f }) => ({
		title: f.text(255).required(),
		authorId: f.relation("user"),
		status: f.select([
			{ value: "draft", label: "Draft" },
			{ value: "published", label: "Published" },
		]),
	}))
	.access({
		// Signed-in readers also see their own drafts.
		read: ({ session }) =>
			session?.user
				? { OR: [{ authorId: session.user.id }, { status: "published" }] }
				: { status: "published" },
		create: ({ session }) => !!session?.user,
		// `data` is the row as it exists in the database right now.
		update: ({ session, data }) => data.authorId === session?.user?.id,
		delete: ({ session, data }) => data.authorId === session?.user?.id,
	});

That object now governs GET/POST/PATCH/DELETE /api/posts, every client.collections.posts.* call, and the posts screen in the admin.

// Anonymous caller: published posts only, drafts are not in the result.
const { docs } = await client.collections.posts.find();

// Someone else's post.
await client.collections.posts.updateById({ id, data: { title: "Mine now" } });
// ApiError FORBIDDEN, HTTP 403

What a rule returns

ReturnEffect
trueAllow.
falseDeny with ApiError.forbidden, HTTP 403.
An objectA row filter. On read it becomes SQL. On update and delete the loaded row is tested against it.

Which rule runs

For one operation the framework takes the first of these it finds: the collection's own access[op], then the app-wide default from config/app.ts, then a built-in fallback that requires an authenticated session. System mode short-circuits ahead of all three. That is the chain for read, create, update and delete. The other four operations each fall back somewhere else.

Leaving an operation out does not make it public

An omitted rule requires a session. To open an operation to anonymous callers you have to say so: read: true. Routes work the other way round, so a route with no .access() is public until you add one. See Routes.

Calling .access() twice replaces the whole object rather than merging into it, so set every operation you care about in one call. a.merge(b) folds two definitions of the same collection together, and b's access wins key by key.

What each operation gets

Every rule function receives the app context (db, session, collections, queue, storage, and anything you added in appConfig({ context })) plus a few keys that depend on the operation.

OperationdatainputRow filter
readnot loadedthe query optionsmerged into the SQL WHERE
createnot loadedthe raw body, before validationignored, so the write goes through. Return a boolean
updatethe existing rowthe patchmatched against the loaded row
deletethe existing rowthe delete params, so { id }matched against the loaded row

ctx.request is set when the call arrives over HTTP and absent on direct server calls. With @questpie/admin enabled, isAdminRequest(request) from @questpie/admin/shared tells the admin panel apart from your own frontend.

Row filters

On read the object is compiled into the query and AND-merged with whatever where the caller sent. It accepts the same vocabulary as a where clause, so { status: "published" }, { views: { gt: 100 } } and relation quantifiers all work. A predicate the compiler cannot build throws instead of quietly returning rows.

On update and delete the row is already loaded, so the object is matched in JavaScript. That matcher understands field equality and AND / OR / NOT and nothing else, so { views: { gt: 100 } } never matches and the write is denied. Do the lookup in the rule body and return a boolean when you need more. Bulk writes check every row separately.

Field-level rules

The same object takes a fields key that allows or denies one field at a time. A denied read drops the field. A denied write throws forbidden carrying the field path. Field-level access has the syntax and the precedence rules.

App-wide defaults

config/app.ts sets the fallback for every collection and global that leaves an operation undefined.

src/questpie/server/config/app.ts
import { appConfig } from "#questpie/factories";

export default appConfig({
	access: {
		read: ({ session }) => !!session,
		create: ({ session }) => !!session,
		update: ({ session }) => !!session,
		delete: ({ session }) => !!session,
	},
});

It takes read, create, update, delete, transition, serve and introspect. There is no fields key, because per-field rules belong to the collection that owns the field. The starter module ships the object above, which is where secure-by-default comes from. Your own config/app.ts replaces that object whole rather than merging into it.

The system bypass

accessMode: "system" makes every check return true, row rules and field rules alike. A call from a script or a job runs that way by default. An HTTP request runs as "user", and everything it calls inherits that, so the public API stays enforced. Seeds already run in system mode.

src/questpie/server/lib/reports.ts
import { createContext } from "#questpie";

// No argument, so this context is system mode.
const ctx = await createContext();
const { docs } = await ctx.collections.posts.find({});

Every CRUD method takes a context argument, so a single call can run in a different mode from the code around it. That argument is the only reliable way to ask for user mode: ctx.collections.* does not inherit it from ctx.

Never derive the mode from a request

System mode is a complete bypass. Compute it from your own code, never from a header, a query parameter, or anything else the caller controls.

Globals

A global holds one row, so its surface is read, update, transition, introspect and fields. There is no create, delete or serve, and a global rule has to return a boolean, because there is nothing to filter. Anything else counts as a denial. On update, ctx.data is the current record, and it is undefined before the first write.

src/questpie/server/globals/site-settings.ts
import { global } from "#questpie/factories";

export const siteSettings = global("siteSettings")
	.fields(({ f }) => ({
		siteName: f.text().required(),
		maintenanceMode: f.boolean(),
	}))
	.access({
		read: true,
		update: ({ session }) => session?.user?.role === "admin",
	});

Next

  • Field-level access, per-field rules and where they sit in a write.
  • Beyond CRUD, the transition, serve and introspect rules.
  • Writing rules, throwing your own error and sharing one rule across collections.
  • Soft delete owns purge, which is default-deny and never inherits from delete.
  • Hooks run around these checks. Access decides, hooks act.

On this page