Quickback Docs

Definitions Overview

Understand how Quickback's security layers work together. Learn the mental model for firewall, access, guards, masking, and rate limiting to build secure APIs.

Before diving into specific features, let's understand how Quickback's pieces connect. This page gives you the mental model for everything that follows.

The Big Picture

Quickback is a backend compiler. You write definition files, and Quickback compiles them into a production-ready API.

  1. You write definitions - Table files with schema and security config using feature()
  2. Quickback compiles them - Analyzes your definitions at build time
  3. You get a production API - GET /jobs, POST /jobs, PATCH /jobs/:id, DELETE /jobs/:id, batch operations, plus custom actions

File Structure

Your definitions live in a quickback/features/ folder organized by feature:

my-app/
├── quickback/
│   ├── quickback.config.ts    # Compiler configuration
│   └── features/
│       └── {feature-name}/
│           ├── candidates.ts       # Table + config (feature())
│           ├── applications.ts     # Secondary table + config
│           ├── interview-scores.ts # Internal table (no routes)
│           ├── actions/            # Custom actions — one file per action
│           │   └── advanceStage.ts
│           ├── lib/                # Feature-local helpers (optional)
│           └── pages/              # CMS page definitions (optional)
├── src/                       # Generated code (output)
└── package.json

actions/, lib/, and pages/ are the reserved sub-directories of a feature — nothing else inside a feature folder is loaded, and a stray subdirectory that contains defineTable(...) / defineAction(...) files fails the compile (files silently disappearing after a mis-move is the failure mode this guards against).

Features can also be grouped into areas: a folder carrying an _area.ts (export default defineArea({...})) declares a route prefix + admission gate and a shared authz vocabulary, and every unreserved child directory becomes a child feature (nesting to arbitrary depth):

quickback/features/
├── workspace/                 # AREA (has _area.ts)
│   ├── _area.ts               # defineArea({...}) — prefix, via lanes, vocabulary
│   ├── projects.ts            # the area folder may itself be a feature
│   └── tasks/                 # child feature
│       ├── tasks.ts
│       └── actions/addTask.ts
└── billing/                   # flat feature (unchanged)

Area projects must declare requires: ['feature-areas'] in quickback.config.ts. See Feature areas for the full model.

Table files use feature() to combine schema and security config in one call:

// features/candidates/candidates.ts
import { feature, q } from "@quickback/compiler";

export default feature("candidates", {
  columns: {
    id:             q.id(),
    organizationId: q.scope("organization"),
    name:           q.text().required(),
    email:          q.text().required(),
    phone:          q.text().optional(),
    source:         q.text().optional(),
    ...q.audit(),
    ...q.softDelete(),
  },
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  guards: {
    createable: ["name", "email", "phone", "source"],
    updatable: ["name", "phone"],
  },
  masking: {
    email: { type: 'email', show: { roles: ['owner', 'hiring-manager', 'recruiter'] } },
    phone: { type: 'phone', show: { roles: ['owner', 'hiring-manager', 'recruiter'] } },
  },
  read: {
    access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] },
  },
  create: { access: { roles: ["owner", "hiring-manager", "recruiter"] } },
  update: { access: { roles: ["owner", "hiring-manager", "recruiter"] } },
  delete: { access: { roles: ["owner", "hiring-manager"] } },
});

Consumers get the row type from the default export — type Candidate = typeof import("./candidates").default.$infer.

Key points:

  • Tables with export default feature(...) (or defineTable(...)) → resource routes generated
  • Tables without a default export → internal/junction tables (no routes)
  • Route path derived from filename: applications.ts/api/v1/applications

1 Resource = 1 Security Boundary

Each feature() / defineTable() defines its own complete security boundary — its own firewall (who sees the data), access rules (role requirements), guards (field validation), and masking (redaction). These are compiled into a single resource file that wraps all generated routes for that table.

A table without a default export is a supporting table — used internally by actions, joins, or background jobs, but never exposed as its own API endpoint. See Internal Tables for details.

The Five Security Layers

Every API request passes through five security layers, in order:

Request → Rate Limit → Firewall → Access → Guards → Masking → Response
              │           │         │        │         │
              │           │         │        │         └── Hide sensitive fields
              │           │         │        └── Block field modifications
              │           │         └── Check roles & conditions
              │           └── Isolate data by owner/org/team
              └── Cap requests per user / op

Security Applies Everywhere

These security layers protect both API responses and Realtime broadcasts. When you enable Realtime, the same masking rules apply to WebSocket messages - ensuring users only see data they're authorized to view.

1. Firewall (Data Isolation)

The firewall controls which records a user can see. It automatically adds WHERE clauses to every query based on your schema columns:

Column in SchemaWhat happens
organizationId (or organisationId / orgId / organization / organisation / org)Data isolated by organization — WHERE organizationId = ctx.activeOrgId
ownerIdData isolated by user — WHERE ownerId = ctx.userId, and the column is auto-stamped on insert
teamIdData isolated by team — WHERE teamId = ctx.activeTeamId
userIdNot auto-detected. On a feature table it usually means a user this row references (member, contact, invitee), not the row's owner.

Detection matches either spelling of a column — the JS key (organizationId) or its SQL name (organization_id).

Exactly one isolation column must match: zero is an error, two or more is an "ambiguous firewall" error that asks you to declare firewall: explicitly. userId is the one column that looks like isolation but isn't — see Firewall → ownerId vs userId.

For relationship-based row visibility (e.g. "user can see this event because they have a confirmed event_guests row pointing at it"), declare the relationship under authz.relationships and reference it from the firewall with a via: predicate. See Firewall → Relationship-based scoping.

Learn more about Firewall →

2. Access (CRUD Permissions)

Access controls which operations a user can perform. It checks roles and record conditions. Reads (GET / and GET /:id) live under read:; writes live under top-level create:, update:, delete:, and upsert:.

read: {
  access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] },
},
create: { access: { roles: ["owner", "hiring-manager", "recruiter"] } },
update: { access: { roles: ["owner", "hiring-manager", "recruiter"] } },
delete: { access: { roles: ["owner", "hiring-manager"] } },

Learn more about Access →

3. Guards (Field Modification Rules)

Guards control which fields can be modified in each operation.

Guard TypeWhat it means
createableFields that can be set when creating
updatableFields that can be changed when updating
protectedFields that can only be changed via specific actions
immutableFields that can never be changed after creation

Learn more about Guards →

4. Masking (Data Redaction)

Masking hides sensitive fields from users who shouldn't see them.

masking: {
  email: { type: 'email' },       // Shows: j***@e******.com
  phone: { type: 'phone' },       // Shows: ******4567
  salary: { type: 'redact' },     // Shows: [REDACTED]
}

Learn more about Masking →

5. Rate Limiting (Request Caps)

Rate limiting caps how often an operation can be called, backed by Cloudflare's native Rate Limiting binding — no extra storage, no third-party libraries. Unlike the other four layers, it's applied to every resource by default (read 1000/60s, writes 200/60s) and runs first, before any data is touched.

rateLimit: {
  delete: { limit: 5, period: 60 },  // tighter than the 200/60s default
},
// read / create / update keep shipped defaults

Override per-op, per-resource, or project-wide; set false at any scope to opt out. Limits are keyed by <resource>:<op>:<userId> (falling back to client IP for anonymous callers), and over-cap requests get a 429 with Retry-After.

Learn more about Rate Limiting →

How They Work Together

Scenario: An interviewer requests GET /candidates/cnd_123

  1. Rate Limit checks: Is this user under their read cap on candidates?

    • Yes → Continue
    • No → 429 Too Many Requests (with Retry-After)
  2. Firewall checks: Is candidate cnd_123 in the user's organization?

    • Yes → Continue
    • No → 404 Not Found (as if it doesn't exist)
  3. Access checks: Can interviewers perform GET?

    • Yes → Continue
    • No → 403 Forbidden
  4. Guards don't apply to GET (they're for writes)

  5. Masking applies: User is an interviewer, not recruiter or hiring-manager

    • Email: jane@company.comj***@c******.com
    • Phone: 555-123-4567******4567
  6. Response sent with masked data

Locked Down by Default

Quickback is secure by default. Nothing is accessible until you explicitly allow it.

LayerDefaultWhat you must do
FirewallAUTOAuto-detects from an organizationId / ownerId / teamId column (userId is not a candidate). Declare firewall: explicitly when none matches, when two do, or to opt out with [{ exception: true }].
AccessDENIEDExplicitly define access rules with roles
GuardsLOCKEDExplicitly list createable, updatable fields
Rate LimitONApplied to every resource (read 1000/60s, write 200/60s). Override or set false to opt out.
ActionsBLOCKEDExplicitly define access for each action

You must deliberately open each door. This prevents accidental data exposure.

Other Resource Options

Beyond the five security layers, defineTable accepts a handful of declarative hints that shape the generated API and the CMS without touching your security posture.

OptionWhat it doesFull reference
viewsNamed column projections — GET /resource/views/{name} returns a subset of columns under its own access rule.Views
referencesExplicit FK target table for columns that don't follow the "Id"-stripped convention (e.g. vendorId → contact).CMS Schema Registry
displayColumnColumn to use as the human-readable label in FK resolutions and CMS list views (auto-detected as name/title/label if not set).CMS Table Views
defaultSortDefault sort order for CMS list rendering ({ field, order: "asc" | "desc" }).CMS Table Views
inputHintsPer-column CMS input type: select, richtext, textarea, lookup, currency, etc. Pure CMS hint, no runtime effect.CMS Record Layouts
layoutsNamed record-page layouts — group fields into sections (collapsed, multi-column). Falls back to auto-grouping if unset.CMS Record Layouts
embeddingsAuto-generate vector embeddings for specified fields on insert/update.Stack → Embeddings
realtimePer-table broadcast config (enabled, onInsert, onUpdate, onDelete, requiredRoles, fields).Stack → Realtime
pathOverride the route base path (defaults to feature name).

All options are optional — omit them and Quickback uses sensible defaults (auto-detected display column, auto-grouped layout, no embeddings, no realtime).

Next Steps

  1. Database Schema — Define your tables
  2. Firewall — Set up data isolation
  3. Access — Configure read and write permissions
  4. Guards — Control field modifications
  5. Masking — Hide sensitive data
  6. Rate Limit — Per-operation request caps
  7. Views — Column-level security
  8. Validation — Field validation rules
  9. Actions — Add custom business logic

On this page