Skip to content

API rules

Every base/auth collection has five rule slots: list_rule, view_rule, create_rule, update_rule, delete_rule. View collections have only list_rule and view_rule. Each slot is one of:

  • null — public; anyone can perform this action.
  • "" (empty string) — admin only.
  • An expression evaluated against the record + auth context.

Admins always bypass expression rules.

expr := operand op operand single comparison
| expr "&&" expr logical AND
| expr "||" expr logical OR
| "(" expr ")" grouping
op := scalar | array
scalar := "=" | "!=" | ">" | ">=" | "<" | "<=" | "~" | "!~"
array := "?=" | "?!=" | "?>" | "?>=" | "?<" | "?<=" | "?~" | "?!~"
operand := literal | field | atRef | funcCall | macro
literal := "string" | 123 | true | false | null
field := bareName ("." subPath)* (":" modifier)?
atRef := "@request.auth." prop
| "@request.method" | "@request.context"
| ("@request.headers." | "@request.query." | "@request.body.") word
| "@collection." name (":" alias)? "." path
| datetimeMacro
modifier := "isset" | "changed" | "length" | "each" | "lower"
prop := id | email | type
funcCall := "geoDistance(" lonA "," latA "," lonB "," latB ")"
| "strftime(" format "," time ("," modifier)* ")"
OpMeaning
= != > >= < <=Standard scalar compare. =/!= use loose equality with bool↔number coercion (SQL semantics).
~Substring match (case-sensitive).
!~NOT substring.

Prefix any scalar op with ? to switch to match-any-element semantics on multi-value fields (multi-select, multi-relation, multi-file, JSON arrays):

status ?= "draft" at least one tag equals "draft"
tags ?~ "feature" at least one tag contains "feature"
contributors ?!= @request.auth.id at least one contributor isn't me

Trailing :modifier on a field reference reshapes the operand:

ModifierNotes
:issetTrue when the request body submitted this field (independent of value).
:changedOn update — true when the body’s value differs from the existing record.
:lengthElement count for multi-fields (file/select/relation arrays).
:eachLifts the comparison so it must hold for every element of an array field.
:lowerCase-insensitive string compare.
ReferenceNotes
@request.auth.id / .email / .typeCaller identity; id is "" unauth. type is "user" or "admin".
@request.method"GET" / "POST" / etc.
@request.context"default" / "oauth2" / "otp" / "password" / "realtime" / "protectedFile".
@request.headers.<name>Header by lowercased name (- becomes _). authorization and cookie are scrubbed before rule eval.
@request.query.<name>URL query param.
@request.body.<name>Submitted body field — useful for :isset/:changed.
@collection.<name>.<field> lookup by relation
@collection.users:author.email alias the join (clarifies multi-hop joins)

The collection’s own view_rule is inherited into the join — you can’t escape access control by joining sideways.

Reference the records that point at this record:

list_rule: comments_via_post:length > 0

…matches posts with at least one comment whose post relation references the row.

@now, @yesterday, @tomorrow, @todayStart, @todayEnd, @monthStart, @monthEnd, @yearStart, @yearEnd, plus @second, @minute, @hour, @day, @weekday, @month, @year for the current time’s components.

list_rule: published_at <= @now && published_at >= @monthStart
FunctionUse
geoDistance(lonA, latA, lonB, latB)Distance in km between two points; pair with comparisons for radius search.
strftime(format, time, mods…)SQLite-compatible date formatting in filters.
SlotWhen evaluatedAgainst
list_ruleGET /api/<col>Compiled into SQL filter — applied as a WHERE so non-matching rows never load.
view_ruleGET /api/<col>/:idThe fetched record.
create_rulePOST /api/<col>The incoming body.
update_rulePATCH /api/<col>/:idThe existing record (pre-update).
delete_ruleDELETE /api/<col>/:idThe existing record.

Failure → 403 Forbidden.

list_rule: null (public)
view_rule: null (public)
create_rule: "" (admin only — empty string)
update_rule: ""
delete_rule: ""

Records have an author relation field. Logged-in users see only their own:

list_rule: @request.auth.id = author
view_rule: @request.auth.id = author

The list_rule becomes WHERE author = ? with the user’s id bound at query time. Non-matching rows never reach the application.

create_rule: @request.auth.id != ""
update_rule: @request.auth.id = author
delete_rule: @request.auth.id = author

Show published posts to everyone; let authors see their drafts too:

list_rule: published = true || @request.auth.id = author
list_rule: ""
view_rule: ""
create_rule: ""
update_rule: ""
delete_rule: ""

Block writes that mutate a forbidden field

Section titled “Block writes that mutate a forbidden field”
update_rule: @request.body.role:isset = false || @request.auth.type = "admin"

Non-admins cannot send a role in the body. Admins can.

Restrict updates to fields that didn’t change

Section titled “Restrict updates to fields that didn’t change”
update_rule: status:changed = false || @request.auth.type = "admin"

Posts have tags: select[]. Show posts visible to any of the user’s tag list:

list_rule: tags ?= @request.auth.id

(?= matches when at least one element of tags equals the user id.)

Locations have a point: geoPoint. Filter by distance:

?filter=geoDistance(point.lng, point.lat, -73.99, 40.74) < 5

A bare name in an expression refers to the record’s column. Special aliases:

  • id → record id
  • created / created_at → created_at column
  • updated / updated_at → updated_at column

Unknown fields evaluate to undefined — comparisons with undefined are “equal to empty string / null” via the loose-equality rule.

The schema editor includes a typed autocomplete for rules — start typing @request. and the popup lists auth.id, auth.email, auth.type. Bare names autocomplete against the collection’s actual fields. Operators are suggested on Tab / Enter.

Every records-API request records which rules evaluated and what they returned. The admin Logs page surfaces:

  • Rule name (list_rule, view_rule, etc.) and collection
  • Expression text (or (public) / (admin only))
  • Outcome: allow, deny, or filter (list rule applied as SQL filter)
  • Reason: public, admin only, admin bypass, rule passed, rule failed, applied as SQL filter

Invaluable for debugging “why am I getting 403 on this collection”.