FRL Reference
This page is a compact reference for Frontbacked Rule Language.
Declaration names
The system namespace is reserved for Frontbacked. Theme declarations cannot be named system or start with system_ or system., regardless of capitalization. For example, system_orders, SYSTEM_ALERTS, and system.new_messages are reserved; systematic_check is allowed.
This rule applies to named endpoints, notification channels, rate limits, helper functions, post types, and keys declared through $private / $secret aliases (including nested alias keys). FRL validation reports a reserved-namespace error before the declaration can be used. This rule does not add dotted-name support to declarations that otherwise require a single identifier.
Ordinary schema fields, function parameters, data keys, display labels, and endpoint URL paths are not declaration names and are unaffected. Notification labels New message and New messages are separately reserved to keep the built-in channel recognizable. Existing sign_in and sign_up rate-limit customization remains supported. Built-in declarations carry a gold ★ System badge in fb-admin.
File Shape
version 2;
fn helperName(arg) {
return arg;
}
post postType as "Post Type Label" {
before create, edit {
$post.pending.data.field = "value";
}
schema {
field: { type: "string", required: true }
status: { type: "enum", enums: ["draft", "published"], default: "draft" }
}
allow create: $actor.id
allow get: $admin.canRead("$this")
allow list(limit=20, max=100): $admin.canRead("$this")
allow edit: $admin.canEdit("$this")
allow delete: $admin.canDelete("$this")
after create {
post.createAsSystem("audit_logs", {
action: "post.created",
subject: $post.pending.id
});
}
}
user {
schema {
displayName: { type: "string", max: 120 }
}
after create {
post.createAsUser("profiles", { owner: $user.id });
}
}
endpoint.post endpointName("/path/:id") as "Endpoint Label" {
must($request.params.id, "Missing id", 422);
dedupe `${$request.params.id}:${$request.body.reference}`;
return { ok: true };
}
Top-Level Declarations
| Declaration | Purpose |
|---|---|
notification name as "Label"; | Define a notification channel for verified email and Telegram recipients. Leading comments provide the admin description. See Notification Channels. |
version 2; | Declare the current FRL grammar and permission semantics. It must be the first statement in the file. |
fn name(params) { ... } | Define a reusable helper function. Helpers can be called from allow rules, hooks, endpoint bodies, and other helpers. |
post name { ... } | Define one post type. Add as "Label" after the name when admin screens should display a friendlier label. |
user { ... } | Define custom user data and run user create, edit, delete, or sign-in hooks. Registration and profile access are controlled by the site admin. |
endpoint.get/post/put/patch/delete name("/path") { ... } | Define a custom guarded JSON endpoint. Add as "Label" after the path to expose endpoint.<name> alias metadata. |
FRL Names and Aliases
User-defined FRL names use one rule across functions, function parameters, post types, schema fields, endpoints, and $secret or $private config keys: start with a letter or _, then use letters, numbers, or _.
post product_reviews { ... }
fn normalize_name(value) { ... }
endpoint.post createLead("/leads/submit") { ... }
Underscore is allowed. Hyphen is not allowed in names because - is the subtraction operator. Use aliases for UI text that needs spaces, punctuation, title case, or other presentation wording. Alias text is not limited by the naming rule; quote it with "...", '...', or `...` when it contains punctuation or should be preserved exactly.
$secret.PAYMENT_SECRET as "Payment provider secret key";
$private.notificationEmail as `Notification recipient email`;
post product_reviews as "Product Reviews" {
schema {
product as "Product": { type: "post", postType: "products", required: true }
reviewer as "Reviewer": { type: "user", required: true }
body as The review text: { type: "string", max: 600 }
}
}
endpoint.post createLead("/leads/submit") as "Create Lead" {
must($request.body.email, "Email is required");
return { ok: true };
}
Aliases are UI metadata only. They do not rename the post type, field, endpoint, route path, config key, stored data, permission path, or query path. Alias keys are postType, postType.field, endpoint.<name>, $secret.<key>, and $private.<key>. The schema keyword itself cannot be aliased.
FRL keeps names strict on purpose:
| Rule | Why it helps |
|---|---|
| One identifier shape | The same naming rule works everywhere, so code, FQL paths, permissions, and admin labels do not drift. |
| Aliases are separate | Human labels can change without breaking stored data, route names, or theme JavaScript. |
| Duplicate declarations are rejected | Functions, post types, endpoints, profile rules, payment events, and endpoint dedupe rules cannot be declared twice. |
| Duplicate fields are rejected | A schema cannot quietly define the same field twice. |
| Duplicate object keys are rejected | Rule objects and schema rule objects cannot hide one value behind another value with the same key. |
| Reserved object names are rejected | Do not use __proto__, prototype, or constructor as function names, fields, params, object keys, or path properties. |
Use snake_case or camelCase for FRL names, then use as "Readable label" whenever humans will see the name.
Post Sections
| Section | Purpose |
|---|---|
before create/edit/delete/write { ... } | Normalize and compute values before validation. Use comma groups such as before create, edit. |
after create/edit/delete/write { ... } | Run follow-up work after validation and permission checks pass. Use comma groups such as after create, edit. |
schema { ... } | Define allowed fields and validation rules. |
allow list(limit=20, max=100): expression | Set list visibility and default/max list sizes. |
allow create: expression | Guard create actions. |
allow get: expression | Guard single-post fetches. |
allow read: expression | Guard both get and list reads. Because it also protects list requests, keep it request-scoped. |
allow edit: expression | Guard update actions. |
allow delete: expression | Guard delete actions. |
allow write: expression | Guard create, edit, and delete actions with the same expression. |
allow create, edit: expression | Guard multiple named actions with one expression. Use allow read, write: expression when one broad condition should cover get, list, create, edit, and delete. |
Before and After Hooks
Use before for write-side data preparation before validation and permission checks. Use after for follow-up work after validation and permissions pass. Both hooks only run for write actions: create, edit, and delete. write means all three write actions, and comma-separated groups let you share logic between selected actions.
post articles {
before create, edit {
$post.pending.data.title = str.trim($post.pending.data.title);
}
before write {
$context.actionName = $action.name;
}
before create {
$post.pending.data.slug = str.slug($post.pending.data.title);
}
after create, edit {
post.createAsSystem("audit_logs", {
action: "article.saved",
article: $post.pending.id
});
}
}
after blocks are useful for audit logs, notifications, counters, and other related writes that should only happen after an action is accepted.
When more than one hook matches the current write action, Frontbacked runs them in source order and passes the same $context through each one. When more than one allow rule matches, every matching rule must resolve truthy. If a concrete action has no matching allow rule, Frontbacked denies it.
List Policies
post articles {
allow get: $post.current.data.status == "published" || $admin.canRead("$this")
allow list(limit=20, max=100): true
}
| Property | Purpose |
|---|---|
allow list(...): expression | Controls whether the current user can list this post type. |
limit | Default list size when the frontend does not provide one. |
max | Largest list size this post type allows. |
Use allow list: to decide whether the request is allowed to ask for a list. Use FQL where or filter to decide which rows should appear in the list. allow read: and allow list: cannot use post-scoped values such as $post, $row, or $rowMeta, even through helper functions; use allow get: when the permission depends on one specific post.
Endpoint Blocks
endpoint.post createLead("/leads/submit") as "Create Lead" {
must($request.body.email, "Email is required", 422);
dedupe `${str.lower(str.trim($request.body.email))}:${$request.body.campaign || "default"}`;
$context.email = str.lower(str.trim($request.body.email));
return {
ok: true,
email: $context.email
};
}
| Section | Purpose |
|---|---|
must(expression, message?, status?, code?) | Assertion for required values. A falsy value stops the endpoint. |
mustNot(expression, message?, status?, code?) | Optional inverse guard that stops when the expression is truthy. |
dedupe expression | Optional stable key for repeat-safe requests. |
| Endpoint statements | Assign $context, call helpers, and return a JSON response directly inside the endpoint block. |
Use allow for post action/list permissions, and must(...) or mustNot(...) for endpoint and hook assertions. A failed must or mustNot stops the rule run; the optional second argument becomes the error message. The optional third argument sets the HTTP status returned by request-facing flows and must resolve to an integer from 400 to 599.
For endpoint failures, the optional fourth argument supplies a stable code alongside the human-readable error. Codes use lowercase letters, numbers, and underscores, start with a letter, and have at most 80 characters. For example:
must($context.hold.ok, "This quantity is temporarily unavailable.", 409, "reservation_unavailable");
Use the code to choose retry behavior instead of matching error-message text.
Theme JavaScript calls the example by name with Frontbacked.endpoints.call("createLead", { body }). Direct HTTP callers can use /fb-endpoints/leads/submit. Use the endpoint alias to give admin screens a clearer label for the endpoint. Use Endpoints and Dedupe for the full guide.
Schema Types
name: { type: "string", min: 1, max: 120, required: true }
email: { type: "email", required: true }
phone: { type: "phone" }
owner: { type: "user", required: true }
age: { type: "number", min: 18 }
balance: { type: "money", min: 0, default: 0, required: true }
active: { type: "boolean" }
startsAt: { type: "datetime" }
createdAt: { type: "serverTime" }
status: { type: "enum", enums: ["draft", "published"], default: "draft" }
price: { type: "money", min: 0.01, required: true }
profile: {
type: "object",
fields: {
bio: { type: "string", max: 400 }
}
}
tags: { type: "array", of: { type: "string", max: 40 }, max: 10, unique: true }
image: { type: "file", maxSize: 7000000, mimeTypes: ["image/jpeg", "image/png"] }
video: { type: "file", maxSize: 500000000, maxDurationSeconds: 300, mimeTypes: ["video/mp4"] }
category: { type: "post", postType: "categories", required: true }
answer: { anyOf: [{ type: "string", max: 100 }, { type: "number", min: 1, max: 10 }] }
type: "phone" accepts common phone formatting, then saves a normalized international number. type: "enum" saves one of the strings from enums. type: "money" stores exact decimal amounts in a multi-currency value; use money.getAmount(value), money.getCurrency(value), and money.format(value) inside FRL. Arithmetic helpers include money.add, money.subtract, money.compare, money.convertTo, and weighted money.allocate. On create, default fills a missing or null literal field before hooks and permissions run. serverTime fields are filled by Frontbacked before defaults and do not accept default. Inside FRL, $this.fields.status.enums.published returns "published" and $this.fields.status.hasEnum(value) checks whether a value belongs to that field.
type: "post" stores the generated ID of another post. Use postType to name the allowed target type. Frontbacked validates that the referenced post exists on the same site unless the field sets exists: false or validateExists: false. Use IDs from $.id or $state.item.id, not slugs.
type: "user" stores a generated user ID. Submit the user ID string, not a copied user object. When a theme page asks for a visible reference field, Frontbacked expands it as a resource envelope: read profile values through owner.data.name and the generated identifier through owner.id. Nested post references use the same shape, such as product.data.category.data.name. Reference paths can expand up to five hops, which is enough for rich connected pages while keeping them fast. Use visibility: "author" or visibility: "backend" on the reference field when those details should not be returned to normal page visitors.
Post references also work inside arrays and nested objects in a profile. Save an ID, then read current product details from $auth.data:
profile {
schema {
cart: {
type: "array", max: 50,
of: { type: "object", fields: {
product: { type: "post", postType: "products", required: true, exists: false }
quantity: { type: "number", min: 1, max: 99, required: true }
} }
}
}
}
For example, $auth.data.cart[0].product.data.name reads the current product name. Profile reads expand the post references declared anywhere inside the profile's objects and arrays, while stored profile values remain IDs. Product read permissions and field visibility still apply. A deleted or unreadable product returns { id, type, data: null }, so the UI can offer removal without showing stale details. exists: false lets a cart retain an unavailable product until its owner removes it. References within the returned product remain IDs unless requested separately through a product query.
Browser storage remains a saved value: opening a cart should request fresh products through FQL rather than display copied prices or images. Always calculate the payable total from saved product prices in the checkout endpoint.
Related writes follow the same schema rules as normal saves. If a related create or edit writes a post with required type: "post" or type: "user" fields, pass those IDs explicitly.
Schema Rules
| Rule | Works with | Example |
|---|---|---|
required | all field types | email: { type: "string", required: true } |
default | literal fields, money | balance: { type: "money", default: 0 } |
min | string, phone, number, money, array | balance: { type: "money", min: 0 } |
max | string, phone, number, money, array | tags: { type: "array", max: 10 } |
pattern | string, phone | slug: { type: "string", pattern: "^[a-z0-9-]+$" } |
enums | enum | status: { type: "enum", enums: ["draft", "published"] } |
minKeys | object | meta: { type: "object", minKeys: 1 } |
maxKeys | object | meta: { type: "object", maxKeys: 20 } |
maxDepth | object | meta: { type: "object", maxDepth: 2 } |
maxChars | object | meta: { type: "object", maxChars: 2000 } |
unique | array | tags: { type: "array", unique: true } |
maxSize | file | image: { type: "file", maxSize: 3000000 } |
mimeTypes | file | file: { type: "file", mimeTypes: ["application/pdf"] } |
maxDurationSeconds | video file | video: { type: "file", maxDurationSeconds: 300 } |
postType | post | category: { type: "post", postType: "categories" } |
exists | post, user | externalId: { type: "post", postType: "items", exists: false } |
validateExists | post, user | externalId: { type: "post", postType: "items", validateExists: false } |
visibility | all field types | privateNote: { type: "string", visibility: "author" } |
generated | all field types | last4: { type: "string", generated: true } |
immutable | edit validation | id: { type: "string", required: true, immutable: true } |
editableIf | edit validation | status: { type: "string", editableIf: $admin.canEdit("products.status") } |
visibility accepts public, author, or backend. public is the default. Author-visible fields are returned only to the post author, and backend-visible fields stay available to FRL rules and endpoint handlers without being returned to theme page data. Admin status does not bypass this in normal theme page responses.
generated: true marks a field as server-managed. Direct form and API values for that field are ignored before before hooks run. On edit, the saved value is preserved unless trusted FRL logic replaces it. Hooks, endpoints, payment events, and related post.* writes can supply generated values. The result still has to satisfy every declared field rule. generated does not change read access; combine it with visibility when necessary.
Runtime Variables
| Variable | Available in |
|---|---|
$post.current.id / $post.pending.id | Current generated post id. In a user hook, use $user.id. |
$post.current.data | saved post data during get, edit, and delete checks |
$post.current | post metadata for the saved post, such as id, type, author id, and status |
$post.pending.data | the data that will be saved during create and edit |
$post.pending | post metadata for the pending write, including the generated id during create |
$payment | payment event handlers |
$user | affected user inside user hooks: $user.id, safe $user.current/$user.pending identity and custom data, plus $user.changes during edit |
$action | current rule operation metadata, including requested read filters at $action.where, the current payment event at $action.payment, and booleans such as isCreate, isGet, isRead, isList, isEdit, isDelete, isWrite, and isSignIn |
$actor | requester data, including $actor.id, $actor.email, $actor.name, $actor.data, $actor.wallet, $actor.isGuest, $actor.isSignedIn, $actor.isAdmin, $actor.isEndpoint, and $actor.endpoint |
$admin | content permission helpers for FRL-managed posts |
$request | endpoint request data. Helpers that read $request can only be called from endpoint blocks. |
$secret | secret values referenced by FRL; fb-admin automatically creates Secret Keys fields for referenced keys |
$private | private rule settings referenced by FRL; fb-admin automatically creates Private Settings fields for referenced keys |
$page | page settings from the site's settings |
$siteInfo | public site information supplied by Frontbacked, including values such as $siteInfo.name and $siteInfo.logoUrl |
$currency | platform currency config; $currency.value is the selected code and $currency.mode is currently fixed |
$this | current post type metadata, including $this.name, $this.fields.status.enums.published, $this.fields.status.default, and $this.fields.status.hasEnum(value) |
$context | scratch data for the current rule run; writable in hooks, endpoint bodies, and event blocks, readable in allow rules and helpers |
$local | scratch data inside one helper function call |
| Action | $post.current | $post.pending |
|---|---|---|
create | Empty. | New post data and generated metadata. |
edit | Saved post before the edit. | Data that will be saved after the edit. |
delete | Saved post being deleted. | Empty. |
get | Saved post being fetched. | Empty. |
list | Not available in list permissions because rows are not loaded yet. | Empty. |
Admin-Supplied Variables
If a theme references $secret.PAYMENT_SECRET or $private.minimumDepositAmount, fb-admin infers those names from backend/index.rules and shows matching fields on the Secret Keys and Private Settings pages. Site admins fill the values there; theme developers do not need to create separate admin UI for them. When the rule file declares aliases for those keys, fb-admin displays the alias.
Operators
| Operator | Example |
|---|---|
| ` | |
&& | $post.pending.data.email && $post.pending.data.acceptedTerms |
==, != | $post.current.data.status == "published" |
>, <, >=, <= | $post.pending.data.price > 0 |
includes | $post.pending.data.role includes ["admin", "editor"] |
+, -, *, /, % | $post.pending.data.quantity * $post.pending.data.price |
!, not | !$post.current.data.archived |
Built-In Functions
| Function | Purpose |
|---|---|
str.lower(value) | Lowercase a string. |
str.upper(value) | Uppercase a string. |
str.trim(value) | Trim a string. |
str.includes(value, textOrArray) | Check whether a string contains one or more values. |
str.replace(value, search, replacement) | Replace all matching text. |
str.slice(value, start, end?) | Return part of a string. The start is inclusive, the optional end is exclusive, and negative positions count backward from the end. |
str.join(values, separator) | Join array values as strings. |
str.slug(...values) | Create a URL-friendly slug with a six-character random suffix to reduce collisions. |
str.id(length) | Generate a random id. |
arr.size(array) | Return array length. |
arr.map(array, fn) | Map array values. |
arr.join(array, separator) | Join array values. |
math.min(...values) | Return minimum. |
math.max(...values) | Return maximum. |
math.ceil(value) | Round up to the next whole number. |
time.now() | Return the current timestamp. |
time.add(value, { days, hours, minutes }) | Add time to a date value and return an ISO timestamp. |
random.id(length?) | Generate a secure alphanumeric id. |
random.uuid() | Generate a UUID. |
random.int(min, max) | Generate a secure integer in an inclusive range. With one argument, uses 0..max. |
random.number(min?, max?) | Generate a secure decimal number. With one argument, uses 0..max; with no arguments, uses 0..1. |
random.string(lengthOrOptions?, options?) | Generate a secure string from a named or custom alphabet. |
crypto.hash(algorithm, value, options?) | Hash a value with sha1, sha256, sha384, or sha512. |
crypto.sha256(value, options?) | Hash a value with SHA-256. |
crypto.sha384(value, options?) | Hash a value with SHA-384. |
crypto.sha512(value, options?) | Hash a value with SHA-512. |
crypto.hmac(algorithm, secret, payload, options?) | Sign a payload with HMAC. |
crypto.safeEqual(left, right, options?) | Compare signatures or tokens without leaking timing clues. |
crypto.verifyHmac(options) | Verify an HMAC signature and return true or false. |
http.get(url, options?) | Perform an approved HTTP GET request. |
http.post(url, options?) | Perform an approved HTTP POST request. |
http.put(url, options?) | Perform an approved HTTP PUT request. |
http.patch(url, options?) | Perform an approved HTTP PATCH request. |
http.delete(url, options?) | Perform an approved HTTP DELETE request. |
http.request(method, url, options?) | Perform an approved HTTP request with a dynamic method. |
console.log(...values) | Log from a rule run while developing. |
Use random.id(...) for new rules. str.id(length) remains available as a compatibility alias.
str.slice uses zero-based positions and safely clamps positions outside the string. Omit end to continue through the rest of the value.
str.slice("Frontbacked", 0, 5); // "Front"
str.slice("Frontbacked", -6); // "backed"
Random Values
Use random.* when the rule needs to create a token, reference, PIN, invite code, or tracking id:
before create {
$post.pending.data.publicId = random.id(18);
$post.pending.data.inviteCode = random.string(8, {
alphabet: "alphanumeric",
casing: "upper",
exclude: "0O1I"
});
$post.pending.data.pin = random.int(1000, 9999);
}
random.string(...) accepts either a fixed length or a length range:
random.string(12)
random.string({ min: 8, max: 16, alphabet: "letters" })
random.string(10, { alphabet: "numbers" })
random.string({ length: 24, chars: "abcdef0123456789" })
Named alphabets are alphanumeric, letters, lower, upper, numbers, numeric, hex, and base64url. You can also pass chars for a custom character set. Use letters: false, numbers: false, casing: "lower", casing: "upper", and exclude when a code needs to avoid confusing characters.
Random string lengths must be from 1 to 256. random.* is allowed in hooks, endpoint bodies, payment events, and helper functions used from those places. It is blocked in allow rules, list guards, and dedupe keys so permissions and repeat-safe endpoints stay stable.
Crypto Helpers
Use crypto.* for hashes, HMAC signatures, and signed webhook checks:
endpoint.post receiveWebhook("/webhooks/provider") {
must(crypto.verifyHmac({
algorithm: "sha256",
secret: $secret.WEBHOOK_SECRET,
payload: $request.rawBody,
signature: $request.headers["x-signature"],
prefix: "sha256="
}), "Invalid signature", 401);
post.createAsSystem("webhook_events", {
externalId: $request.body.id,
payloadHash: crypto.sha256($request.rawBody)
});
return { ok: true };
}
crypto.hash(...), crypto.sha256(...), crypto.sha384(...), crypto.sha512(...), and crypto.hmac(...) return encoded strings. output can be hex, base64, or base64url. Input encodings can be text, hex, base64, or base64url.
$context.digest = crypto.sha256($request.rawBody, { output: "hex" });
$context.signature = crypto.hmac("sha256", $secret.WEBHOOK_SECRET, $request.rawBody, {
output: "base64url"
});
crypto.verifyHmac(...) accepts:
| Option | Notes |
|---|---|
algorithm | sha1, sha256, sha384, or sha512. Defaults to sha256. |
secret | Secret key, usually from $secret. |
payload | The signed payload. For webhooks, use $request.rawBody unless the provider documents a different signed string. |
signature | Signature header or value to compare. |
prefix | Optional prefix to strip before comparing, such as sha256=. |
param | Optional key to extract from comma/semicolon header values, such as v1. |
encoding | Signature encoding: hex, base64, or base64url. Defaults to hex. |
secretEncoding | Encoding for the secret. Defaults to text. |
payloadEncoding | Encoding for the payload. Defaults to text. |
Use crypto.safeEqual(left, right, { encoding }) when you compute a signature manually. Prefer crypto.verifyHmac(...) for webhook guards because it signs and compares in one step.
Outbound HTTP Requests
FRL v2 can call approved external services from rule code:
$context.response = http.post("https://api.example.com/messages", {
headers: {
authorization: `Bearer ${$secret.NOTIFICATION_TOKEN}`
},
json: {
email: $request.body.email,
message: $request.body.message
},
timeout: 10000
});
HTTP helpers return { ok, status, statusText, headers, data, text }. data is parsed JSON when possible.
Literal external hosts are checked with the theme version. If a host is not approved yet, the version waits for external request review before other users can install it. URLs from $private or $secret are admin-controlled dynamic hosts; Frontbacked checks the resolved host when the rule runs. Rejected dynamic hosts are blocked for that site/request without disabling the whole theme.
FRL v2 blocks relative HTTP calls back into the same theme's endpoints, same-site full URLs, and proxy loops. It also rejects target URLs built from actor, request, post, payment, or other visitor-controlled data. Use a literal service URL or an admin-filled $private/$secret value for outbound hosts.
Rule Resource Helpers
These helpers are available inside FRL expressions and executable blocks.
| Helper | Purpose |
|---|---|
posts.findOne(args) | Read one visible post that matches the given filters. |
posts.findAll(args) | Read visible posts that match the given filters. |
posts.count(args) | Count visible posts that match the given filters. |
post.createAsActor(type, data) | Queue a related post create owned by the signed-in actor. It fails if there is no signed-in actor. |
post.createAsUser(type, data) | In a user lifecycle hook, queue a related post owned by the user being created, edited, deleted, or signed in. |
post.createAsSystem(type, data) | Queue a related post create owned by the system for automatic work such as logs, callbacks, and imports. |
post.edit(id, data) | Queue a related post edit by generated post id. |
post.delete(id) | Queue a related post delete by generated post id. |
post.increment(id, data) | Atomically increment top-level number or money fields on a post and refresh generated post indexes. |
chargeUser(args) | Request wallet confirmation before continuing a paid rule action. |
Every post.* target must be a declared post type. Related creates and edits require a declared schema and are checked against it before they are saved. Increments also require a declared schema and are checked against the target schema after the increment is applied. Deletes only require the target post type to be declared, because no new data is being saved. Related writes skip the target post type's before, after, and allow rules because the current rule has already accepted the action.
One rule run can queue up to 128 related writes. post.increment(id, data) accepts only top-level numeric fields, which keeps counters and balances clear.
posts.findOne, posts.findAll, and posts.count read posts that are available to the current rule run. Rule code can use the stored data it reads to decide what happens next. If an endpoint returns helper-read data, return only the fields the browser should see.
Query helper shape:
$context.posts = posts.findAll({
type: "reviews",
where: {
product: $request.params.productId,
rating: { gte: 4 },
status: { in: ["approved", "featured"] }
},
fields: ["product.title", "reviewer.name"],
orderBy: { createdOn: "desc" },
limit: 20
});
Helper query notes:
| Option | Notes |
|---|---|
type | Required for post helpers. It must name a declared post type. |
where | Object of field checks. Supported operators include gt, gte, lt, lte, in, between, contains, and includes. Money fields also accept { currency, amount } equality or { currency, gt/gte/lt/lte/ne }. |
AND, OR, NOT | Group where objects for compound checks. |
fields | Reference paths to expand, such as product.category.name. |
orderBy | Sort object. Use "asc" or "desc" values. |
limit | Defaults to 20 for findAll and cannot exceed 500. |
offset | Supported for small admin-style reads and capped at 10000. Prefer cursors for growing data. |
where.in accepts up to 50 values.
Money filters always name the currency being compared:
$context.accounts = posts.findAll({
type: "accounts",
where: {
balance: { currency: "USD", gte: "1000.00" }
}
});
Money comparisons use exact fixed-scale decimal arithmetic and compare only entries with the requested currency. Do not compare or aggregate amounts from different currencies in one expression.
When a rule reads a post and needs referenced details, include the reference paths in fields:
$context.review = posts.findOne({
type: "reviews",
where: { id: $request.params.id },
fields: ["product.category.name", "reviewer.name"]
});
Payment Events
Use top-level payment events when a theme needs rule-owned side effects after a site payment is created or reaches a terminal state.
payment created {
console.log("Payment created", $payment.id);
}
payment confirmed {
if ($payment.metadata.purpose == "product_purchase" && $payment.metadata.productId) {
post.increment($payment.metadata.productId, {
completedOrderCount: 1
});
}
}
payment failed {
console.log("Payment failed", $payment.id);
}
Supported FRL event names are created, confirmed, and failed. Use payment confirmed, not payment successful. Inside FRL, confirmed payments expose $payment.status == "confirmed" and keep the stored transaction status on $payment.rawStatus.
Money Fields
Use type: "money" for account balances, credits, debits, and other amounts that have already been recorded. Currency comes from the site's currency settings, so the schema does not declare a separate currency field:
post accounts {
schema {
owner: { type: "user", required: true, unique: true }
balance: { type: "money", min: 0, default: 0, required: true }
}
}
Single-currency sites accept their base currency only. Multi-currency sites accept only currencies enabled by the site admin. Empty currency settings safely fall back to the base currency. Use post.increment(id, { balance: change }) to change a balance atomically.
Use type: "money" for every monetary value, including product prices and investment ranges. Payment intent creation belongs in a guarded endpoint, not in the field type:
post products {
schema {
title: { type: "string", min: 1, max: 140, required: true }
price: { type: "money", min: 0.01, required: true }
}
allow create: $actor.id
allow get: true
allow list(limit=24, max=100): true
allow edit: $actor.id == $post.current.authorId
allow delete: $actor.id == $post.current.authorId
}
The frontend submits a money value as ordinary post data:
await Frontbacked.createPost({
type: "products",
post: {
title: "Starter Plan",
price: { amount: "500", currency: "USD" }
}
})
Use separate money fields such as minInvestment and maxInvestment when a product or service has a range. A payment endpoint can validate the submitted amount and call payment.create({ amount, metadata }).
Secure Rule Checklist
Before publishing a theme, check every post type:
- Every stored field is in
schema. - Owner, site, ids, and external reference fields are
immutable. - Workflow fields use
editableIfwhen only editors or trusted flows may change them. - Every protected action has a matching
allowrule. - Public create rules still require meaningful submitted fields.
- Edit/delete rules use
$post.current.dataor$post.currentfor ownership checks. - File fields declare
maxSize,mimeTypes, and videomaxDurationSecondswhere needed. - Trigger targets have their own post rules.
- Custom endpoints use
must(...)/mustNot(...)anddedupewhen repeated requests could duplicate work. - Outbound HTTP requests use literal approved hosts or admin-filled
$private/$secretURLs. - List policies use
allow list(...)with clear limits for list pages.
Create a payment
payment.create({ amount, metadata }) creates a payment for the authenticated user. amount is a money value; metadata is an optional object describing what the payment is for. Other options are rejected.
endpoint.post start_payment("/checkout") {
must($actor.id, "Sign in to continue.", 401);
dedupe `${$actor.id}:${$request.body.attemptId}`;
return payment.create({
amount: { amount: "25", currency: "USD" },
metadata: { purpose: "booking", bookingId: $request.body.bookingId }
});
}
Calculate the amount from trusted data and validate access before creating the payment. Metadata accompanies payment events; it does not reserve resources or enforce stock limits. Use dedupe to safely retry the same payment attempt, and create a new attempt ID for a new purchase. A completed deduplicated call returns its saved response even if the payment later expires; use the payment ID to load its current status and a new attempt ID to replace an expired payment. See Endpoints and dedupe.
Reservations and atomic actions
Use reservation.hold(resourceKey, reservationKey, { capacity, quantity, expiresIn }), reservation.get(resourceKey, reservationKey), and reservation.release(resourceKey, reservationKey) in endpoint processes and payment events. Capacity accepts a number, { post, field }, or { userData, field }. Use atomic { ... } to commit supported database operations together or roll them back on failure. See Reservations and Atomic Actions for capacity rules, response shapes, permissions, expiry, and transaction limits.
Rate limits
Declare reusable rateLimit policies, then attach them with rateLimit create, edit policy_name; inside a post or rateLimit policy_name; inside an endpoint. Comments immediately above a policy become its admin description. See Rate Limits and Abuse Protection for syntax, scopes, admin adjustments, and safe retry handling.