Frontbacked Docs

FRL Overview

A Frontbacked theme is the reusable website package you ship: pages, styles, scripts, assets, optional skins, and optional rules for the data and actions the site supports.

Frontbacked Rule Language (FRL) gives that theme a protected data layer without making every theme become a server project. It answers four questions for every post type:

  1. What fields are allowed?
  2. How should incoming data be cleaned or computed?
  3. Who can create, get, list, edit, or delete this data?
  4. Should this action create related writes or async work?

FRL lives in the theme's single rules file: backend/index.rules.

What FRL Unlocks

FRL is more than a validation file. It is the place where a theme describes its data powers:

FeatureWhat it lets your theme do
version 2;Declare the current FRL grammar. Put it before any other statement.
post blocksDefine records such as articles, products, comments, plans, tickets, reviews, portfolios, or lessons.
profile blockShape and validate custom signed-in user data.
schemaReject unknown fields and validate nested objects, arrays, enums, files, prices, defaults, and immutable fields.
before hooksTrim, normalize, compute slugs, set derived status, and prepare trusted fields before validation.
allow rulesDecide who can create, get, list, edit, delete, read, or write each post type.
after hooksRun follow-up work after validation and permissions pass.
Related writesCreate, edit, delete, or increment related posts after a successful action.
allow list(...) policiesControl who can list a post type and set default/max list limits.
endpoint.* blocksAdd custom guarded JSON endpoints for workflows that are not plain CRUD.
http.* helpersContact approved outside services from FRL with host review and loop protection.
must(...) and mustNot(...)Stop a hook or endpoint when a required assertion fails.
dedupeMake repeat endpoint requests return the same outcome instead of duplicating work.
$secret and $privateReference admin-filled private values without building custom settings forms.
as aliasesGive admin UIs friendlier labels for post types, fields, endpoints, secret keys, and private settings without renaming the stored data.

A Minimal Rule File

version 2;

post leads {
  before create {
    $post.pending.data.email = str.lower(str.trim($post.pending.data.email));
  }

  schema {
    name: { type: "string", min: 1, max: 120, required: true }
    email: { type: "email", required: true }
    message: { type: "string", max: 1000 }
    createdAt: { type: "serverTime" }
  }

  allow create: $post.pending.data.email

  allow get: $admin.canRead("$this")

  allow list(limit=20, max=100): $admin.canRead("$this")

  allow edit: $admin.canEdit("$this")

  allow delete: $admin.canDelete("$this")
}

This defines a leads post type. When a frontend page calls Frontbacked.createPost({ type: "leads", post }), Frontbacked applies the rule and either stores a clean, valid post or returns a useful error.

Rule Flow

For create, edit, and delete actions, Frontbacked evaluates a post rule in this order:

  1. Prepare rule values such as $post, $actor, $action, $admin, $secret, $private, $page, $currency, and $this.
  2. Fill server-generated fields, then schema defaults for missing or null fields during create.
  3. Run every matching before hook in source order. before write runs for create, edit, and delete, and comma groups such as before create, edit share setup across selected actions.
  4. Validate the schema.
  5. Evaluate every matching allow rule. All of them must pass.
  6. Run matching after hooks for write actions when present.
  7. Materialize any related writes queued by post.* helpers.

get and list do not run before or after hooks. get checks single-post permissions, while list checks whether the visitor may request the list and what size the list may be. Use FQL where or filter to choose which rows appear in a list. Because allow read: also applies to list requests, allow read: and allow list: cannot use post-scoped values such as $post, $row, or $rowMeta, even through helper functions. If an action has no matching allow rule, Frontbacked denies it.

For webhook callbacks and delayed external work, use endpoint.* with dedupe and post.* helpers. That keeps callback handling in the same clear endpoint model as every other custom route.

Post State

FRL separates saved data from the data being written, so rules stay easy to reason about.

PathMeaning
$post.current.dataThe saved data before this request. Use this for ownership, status, and previous-value checks.
$post.currentPost metadata for the saved post, such as id, type, author id, and status.
$post.pending.dataThe data this request wants to save. before create and before edit hooks write here.
$post.pendingPost metadata for the pending write, including the generated id during create.
ActionCurrent statePending state
createEmpty.The new post data and generated metadata.
editThe saved post before the edit.The merged post data that will be saved.
deleteThe saved post being deleted.Empty.
getThe saved post being fetched.Empty.
listNot available in list permissions because list checks run before rows are loaded.Empty.

Post Blocks

Every data record you want a theme to write or read is declared as a post.

version 2;

post products {
  schema {
    title: { type: "string", min: 1, max: 140, required: true }
    status: { type: "enum", enums: ["draft", "published"], default: "draft", required: true }
    price: { type: "money", min: 0.01, required: true }
  }

  allow create: $admin.canCreate("products")
  allow get: $post.current.data.status == "published" || $admin.canRead("products")
  allow list(limit=24, max=100): true
  allow edit: $admin.canEdit("products")
  allow delete: $admin.canDelete("products")
}

The post name is the type used by the frontend API:

await Frontbacked.createPost({
    type: "products",
  post: {
    title: "Starter Plan",
    price: { amount: "5000", currency: "USD" }
  }
});

type: "money" stores an exact amount and currency. FQL returns a rich money object with safe methods such as $.price.getAmount(), $.price.getCurrency(), and $.price.format().

User Data And Lifecycle

Use user to validate custom user data and connect other records to the user lifecycle. The site admin decides whether registration, profile changes, and profile-photo changes are available, so a theme cannot grant those capabilities from FRL.

user {
  schema {
    phone: { type: "phone" }
    country: { type: "string", max: 80 }
  }

  after create {
    post.createAsUser("customer_accounts", {
      owner: $user.id
    });
  }
}

$user.id always identifies the affected user. $actor.id identifies the person who initiated the action, which can be different when an admin deletes a user. $user.current, $user.pending, and $user.changes let hooks inspect safe identity fields and custom data without exposing passwords, hashes, or tokens.

The user schema validates data being created or edited; delete and sign-in hooks do not revalidate older stored data. Every account creation path—including email signup, provider signup, and accounts created by a site admin—runs after create. If required user data is missing or a related write fails, the account is not partially created. Use after create, after edit, and after delete for related writes. after signIn runs after successful authentication and cannot turn valid credentials into a failed sign-in. The older profile spelling remains available while themes migrate, but user is the current form.

The frontend updates custom data with Frontbacked.updateUserData({ data, merge }). With merge: true, the submitted object is merged into existing data; with merge: false, it replaces existing data after schema validation.

Custom Endpoints

Use endpoint.get, endpoint.post, endpoint.put, endpoint.patch, or endpoint.delete when a theme needs a guarded JSON route that is not just storing one post.

endpoint.post estimateReturn("/investments/estimate") as "Estimate Return" {
  must($request.body.amount > 0, "Amount is required", 422);
  dedupe `${$actor.id || "guest"}:${$request.body.amount}:${$request.body.plan}`;

  $context.rate = math.max($private.dailyRate || 0, 0);

  return {
    ok: true,
    dailyEstimate: $request.body.amount * $context.rate
  };
}

The theme calls it by name with Frontbacked.endpoints.call("estimateReturn", { body }). Direct HTTP callers can use /fb-endpoints/investments/estimate. See Endpoints and Dedupe for request fields, repeat-request behavior, path params, cancellation, and endpoint write helpers.

Payment Events

Use payment created, payment confirmed, and payment failed when a theme needs to react to payment lifecycle events.

payment confirmed {
  if ($payment.metadata.purpose == "product_purchase" && $payment.metadata.productId) {
    post.increment($payment.metadata.productId, {
      completedOrderCount: 1
    });
  }
}

Use confirmed, not successful, in FRL event names. Stored transaction rows can still expose successful to FQL, but $payment.status is normalized to confirmed inside FRL payment events.

Rule Context

FRL expressions use rule variables. The most common are:

VariableMeaning
$post.current.id / $post.pending.idThe generated id for the current post. In a user hook, use $user.id.
$post.current.dataSaved post data for get, edit, and delete checks. Empty during create.
$post.currentPost metadata for the saved post.
$post.pending.dataNew or edited data that will be saved. Write this in before create and before edit.
$post.pendingPost metadata for the pending write, including the generated id during create.
$paymentThe current payment during payment created, payment confirmed, and payment failed events.
$userThe affected user inside user hooks. Read $user.id, safe $user.current and $user.pending state, and $user.changes during edits.
$actionMetadata for the current operation, 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.
$actorThe requester causing the rule run. Use $actor.id, $actor.email, $actor.data, $actor.wallet, $actor.isGuest, $actor.isSignedIn, $actor.isAdmin, $actor.isEndpoint, and $actor.endpoint.
$adminContent permission helpers such as $admin.canEdit("products") or $admin.canEdit("$this.name") for FRL-managed posts.
$secretSecret values referenced by the theme, such as $secret.PAYMENT_SECRET. fb-admin creates fields for referenced secret keys automatically.
$privatePrivate rule settings referenced by the theme, such as $private.minimumDepositAmount. fb-admin creates fields for referenced private settings automatically.
$pagePage settings from the site settings. These are edited through theme/page settings rather than the FRL admin forms.
$currencyPlatform currency config. $currency.value is a code such as USD or NGN; $currency.mode is currently fixed.
$thisCurrent post type metadata, including field labels, defaults, and enum helpers such as $this.fields.status.enums.published.
$contextScratch data for the current rule run. Write it in hooks, endpoint bodies, and event blocks; read it in allow rules and helpers.
$localScratch data inside one helper function call.

Admin Fields from $secret and $private

When a theme references $secret.someKey or $private.someKey in backend/index.rules, fb-admin automatically shows those keys on the Secret Keys or Private Settings pages for the site admin to fill.

allow create: $secret.PAYSTACK_SECRET && $private.minimumDepositAmount && $actor.id

Theme developers only need to reference the keys in FRL. They do not need to create custom admin form fields for those values.

Use as to give generated admin UIs a clearer label:

$secret.PAYMENT_SECRET as "Payment provider secret key";
$private.notificationEmail as "Notification recipient email";

post reviews as "Product Reviews" {
  schema {
    product as "Product": { type: "post", postType: "products", required: true }
    reviewer as "Reviewer": { type: "user", required: true }
    body as "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 do not rename the underlying post type, field, endpoint, route, secret key, private key, query path, or permission path. The schema keyword itself cannot be aliased.

Expressions

FRL expressions support paths, strings, numbers, arrays, objects, function calls, comparisons, logical operators, math operators, unary operators, and template strings.

$post.pending.data.slug = str.slug($post.pending.data.title);
$post.pending.data.total = $post.pending.data.price * $post.pending.data.quantity;
$post.pending.data.ownerCanEdit = $actor.id == $post.current.authorId || $admin.canEdit("articles");
$context.message = `New order ${$post.pending.id} for ${$post.pending.data.email}`;

Use semicolons inside hooks, endpoint bodies, and function bodies.

Helper Functions

Built-in helpers include:

FunctionExamplePurpose
str.lower(value)str.lower($post.pending.data.email)Lowercase a string.
str.upper(value)str.upper($post.pending.data.code)Uppercase a string.
str.trim(value)str.trim($post.pending.data.name)Trim whitespace.
str.includes(value, textOrArray)str.includes($post.pending.data.title, "pro")Check string contents.
str.slice(value, start, end?)str.slice("Frontbacked", -6)Return part of a string; negative positions count backward from the end.
str.slug(...values)str.slug($post.pending.data.title)Build a URL-friendly slug with a six-character random suffix to reduce collisions.
random.id(length)random.id(18)Generate a secure alphanumeric id.
random.string(lengthOrOptions?, options?)random.string(8, { alphabet: "numbers" })Generate invite codes, PINs, and custom tokens.
arr.size(array)arr.size($post.pending.data.tags)Count array items.
math.min(...)math.min($post.pending.data.price, 100)Return the minimum value.
math.max(...)math.max($post.pending.data.price, 0)Return the maximum value.
crypto.verifyHmac(options)crypto.verifyHmac({ secret, payload, signature })Verify signed webhook requests.

Theme rules can also declare local functions with fn.

fn normalizeEmail(email) {
  return str.lower(str.trim(email));
}

fn isSubscribed() {
  $local.matches = posts.count({
    type: "subscriptions",
    where: {
      user: $actor.id,
      status: "active"
    }
  });

  return matches > 0;
}

post signups {
  before create {
    $post.pending.data.email = normalizeEmail($post.pending.data.email);
  }
}

Helper functions can be used inside allow rules, hooks, endpoint bodies, and other helpers. They can read the current $context, $actor, $admin, and resource helpers such as posts.count(...). Helpers called from endpoint blocks can also read $request. Use $local for temporary values inside the helper, then return the result.

Secure Defaults

If a concrete post action has no matching allow rule, it is denied. This is intentional. A theme should explicitly say who can create, get, edit, and delete each protected post type.

For public form submissions, use a condition based on the submitted data instead of leaving the rule open by accident.

allow create: $post.pending.data.email && $post.pending.data.acceptedTerms

For site-owner actions, prefer $admin permission helpers scoped to the content being managed.

allow edit: $admin.canEdit("products")

Named declarations follow the FRL naming rules. The system, system_…, and system.… names are reserved for Frontbacked, case-insensitively. Ordinary data fields are unaffected.