Quickback Docs

Complete Example

See what you define and what Quickback generates

This example shows a complete resource definition using all five security layers, and the production code Quickback generates from it.

What You Define

A candidates resource for a multi-tenant recruitment application with:

  • Organization-level data isolation
  • Role-based write permissions
  • Field-level write protection
  • PII masking for sensitive fields
  • Column-level views for different access levels
quickback/features/candidates/candidates.ts
import { feature, q } from '@quickback/compiler';

export default feature('candidates', {
  columns: {
    id:             q.id(),
    name:           q.text().required(),
    email:          q.text().required(),
    phone:          q.text().optional(),
    resumeUrl:      q.text().optional(),
    source:         q.text().optional(),  // e.g. "linkedin", "referral", "careers-page"
    internalNotes:  q.text().optional(),
    organizationId: q.scope("organization"),
    // Note: createdAt, createdBy, modifiedAt, modifiedBy are auto-injected
  },

  // firewall — auto-derived from q.scope("organization") + soft-delete deletedAt;
  // explicit declaration is optional once a scope column is declared.

  // GUARDS — Field-level protection
  guards: {
    createable: ['name', 'email', 'phone', 'resumeUrl', 'source'],
    updatable:  ['name', 'phone'],
  },

  // MASKING — PII redaction (query.roles defaults to show.roles)
  masking: {
    email: { type: 'email', show: { roles: ['hiring-manager', 'recruiter'] } },
    phone: { type: 'phone', show: { roles: ['hiring-manager', 'recruiter'] } },
  },

  // READ — Collection + per-id GET, plus named views
  read: {
    access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },
    views: {
      pipeline: {
        fields: ['id', 'name', 'source'],
        access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },
      },
      full: {
        fields: ['id', 'name', 'email', 'phone', 'resumeUrl', 'source', 'internalNotes'],
        access: { roles: ['hiring-manager', 'recruiter'] },
      },
    },
  },

  // WRITE OPERATIONS
  create: { access: { roles: ['hiring-manager', 'recruiter'] } },
  update: { access: { roles: ['hiring-manager', 'recruiter'] } },
  delete: { access: { roles: ['hiring-manager'] }, mode: 'hard' },
});

Prefer the explicit two-export form (or interop with existing Drizzle schemas)? See feature() — single-export sugar and Schema: choosing between q and Drizzle. All forms compile identically; mix freely per file.


What Quickback Generates

Run quickback compile and Quickback generates production-ready code.

Security Helpers

src/features/candidates/candidates.resource.ts
import { eq, and } from 'drizzle-orm';
import { candidates } from './schema';
import type { AppContext } from '../../lib/types';
import { masks } from '../../lib/masks';

/**
 * Firewall conditions for candidates
 * Auto-derived from q.scope("organization") + audit-injected deletedAt.
 * 2 predicates: tenant scope AND soft-delete filter.
 */
export function buildFirewallConditions(ctx: AppContext) {
  return and(
    eq(candidates.organizationId, ctx.activeOrgId!),
    isNull(candidates.deletedAt),
  );
}

/**
 * Guards configuration for field modification rules.
 * organizationId is added to systemManaged because q.scope("organization")
 * is bound to ctx.activeOrgId — clients cannot submit it.
 */
export const GUARDS_CONFIG = {
  createable: new Set(['name', 'email', 'phone', 'resumeUrl', 'source']),
  updatable: new Set(['name', 'phone']),
  immutable: new Set([]),
  protected: {},
  systemManaged: new Set([
    'createdAt', 'createdBy',
    'modifiedAt', 'modifiedBy',
    'deletedAt', 'deletedBy',
    'organizationId',  // ← added by q.scope("organization")
  ]),
};

/**
 * Validates field input for CREATE operations.
 */
export function validateCreate(input: Record<string, any>) {
  const systemManaged: string[] = [];
  const protectedFields: Array<{ field: string; actions: string[] }> = [];
  const notCreateable: string[] = [];

  for (const field of Object.keys(input)) {
    if (GUARDS_CONFIG.systemManaged.has(field)) {
      systemManaged.push(field);
      continue;
    }
    if (field in GUARDS_CONFIG.protected) {
      const actions = [...GUARDS_CONFIG.protected[field]];
      protectedFields.push({ field, actions });
      continue;
    }
    if (!GUARDS_CONFIG.createable.has(field) && !GUARDS_CONFIG.immutable.has(field)) {
      notCreateable.push(field);
    }
  }

  const valid = systemManaged.length === 0 &&
                protectedFields.length === 0 &&
                notCreateable.length === 0;

  return { valid, systemManaged, protected: protectedFields, notCreateable };
}

/**
 * Validates field input for UPDATE operations.
 */
export function validateUpdate(input: Record<string, any>) {
  const systemManaged: string[] = [];
  const immutable: string[] = [];
  const protectedFields: Array<{ field: string; actions: string[] }> = [];
  const notUpdatable: string[] = [];

  for (const field of Object.keys(input)) {
    if (GUARDS_CONFIG.systemManaged.has(field)) {
      systemManaged.push(field);
      continue;
    }
    if (GUARDS_CONFIG.immutable.has(field)) {
      immutable.push(field);
      continue;
    }
    if (field in GUARDS_CONFIG.protected) {
      const actions = [...GUARDS_CONFIG.protected[field]];
      protectedFields.push({ field, actions });
      continue;
    }
    if (!GUARDS_CONFIG.updatable.has(field)) {
      notUpdatable.push(field);
    }
  }

  const valid = systemManaged.length === 0 &&
                immutable.length === 0 &&
                protectedFields.length === 0 &&
                notUpdatable.length === 0;

  return { valid, systemManaged, immutable, protected: protectedFields, notUpdatable };
}

/**
 * Masks sensitive fields based on user role
 */
export function maskCandidate<T extends Record<string, any>>(
  record: T,
  ctx: AppContext
): T {
  const masked: any = { ...record };

  // email: show to hiring-manager, recruiter
  if (!ctx.roles?.some(r => ['hiring-manager', 'recruiter'].includes(r))) {
    if (masked['email'] != null) {
      masked['email'] = masks.email(masked['email']);
    }
  }

  // phone: show to hiring-manager, recruiter
  if (!ctx.roles?.some(r => ['hiring-manager', 'recruiter'].includes(r))) {
    if (masked['phone'] != null) {
      masked['phone'] = masks.phone(masked['phone']);
    }
  }

  return masked as T;
}

export function maskCandidates<T extends Record<string, any>>(
  records: T[],
  ctx: AppContext
): T[] {
  return records.map(r => maskCandidate(r, ctx));
}

// Write access configuration (internal normalized shape)
export const CRUD_ACCESS = {
  create: { access: { roles: ['hiring-manager', 'recruiter'] } },
  update: { access: { roles: ['hiring-manager', 'recruiter'] } },
  delete: { access: { roles: ['hiring-manager'] }, mode: 'hard' },
};

// Views configuration (read pipeline)
export const VIEWS_CONFIG = {
  pipeline: {
    fields: ['id', 'name', 'source'],
    access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },
  },
  full: {
    fields: ['id', 'name', 'email', 'phone', 'resumeUrl', 'source', 'internalNotes'],
    access: { roles: ['hiring-manager', 'recruiter'] },
  },
} as const;

API Routes

The generated routes wire everything together:

src/features/candidates/candidates.routes.ts
import { Hono } from 'hono';
import { eq, and } from 'drizzle-orm';
import { candidates } from './schema';
import {
  buildFirewallConditions,
  validateCreate,
  validateUpdate,
  maskCandidate,
  maskCandidates,
  VIEWS_CONFIG,
  CRUD_ACCESS,
} from './candidates.resource';
import { evaluateAccess } from '../../lib/access';
import { AuthErrors, AccessErrors, GuardErrors } from '../../lib/errors';

const app = new Hono();

// GET /candidates/views/:viewName — named view projection
const READ_ACCESS = { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] };

app.get('/views/:viewName', async (c) => {
  const ctx = c.get('ctx');
  const db = c.get('db');

  const viewName = c.req.param('viewName');
  const params = Object.fromEntries(new URL(c.req.url).searchParams);
  const view = VIEWS_CONFIG[viewName as keyof typeof VIEWS_CONFIG];
  if (!view) return c.json({ error: 'Unknown view', code: 'VIEW_NOT_FOUND' }, 400);

  // Per-view access gate (falls back to read-level access)
  if (!await evaluateAccess(view.access ?? READ_ACCESS, ctx)) {
    return c.json(AccessErrors.roleRequired(view.access?.roles ?? READ_ACCESS.roles, ctx.roles), 403);
  }

  // Query with firewall conditions, project view fields, mask, return.
  const results = await db.select(/* projected fields */).from(candidates)
    .where(buildFirewallConditions(ctx));

  return c.json({ data: maskCandidates(results, ctx), view: viewName });
});

// POST /candidates - Create with guards
app.post('/', async (c) => {
  const ctx = c.get('ctx');
  const db = c.get('db');

  // Access check
  if (!await evaluateAccess(CRUD_ACCESS.create.access, ctx)) {
    return c.json(AccessErrors.roleRequired(
      CRUD_ACCESS.create.access.roles,
      ctx.roles
    ), 403);
  }

  const body = await c.req.json();

  // Guards validation
  const validation = validateCreate(body);
  if (!validation.valid) {
    if (validation.systemManaged.length > 0) {
      return c.json(GuardErrors.systemManaged(validation.systemManaged), 400);
    }
    if (validation.notCreateable.length > 0) {
      return c.json(GuardErrors.fieldNotCreateable(validation.notCreateable), 400);
    }
  }

  // Apply ownership and audit fields
  const data = {
    id: 'cnd_' + crypto.randomUUID().replace(/-/g, ''),
    ...body,
    organizationId: ctx.activeOrgId,
    createdAt: new Date().toISOString(),
    createdBy: ctx.userId,
    modifiedAt: new Date().toISOString(),
    modifiedBy: ctx.userId,
  };

  const result = await db.insert(candidates).values(data).returning();
  return c.json(maskCandidate(result[0], ctx), 201);
});

// PATCH /candidates/:id - Update with guards
app.patch('/:id', async (c) => {
  const ctx = c.get('ctx');
  const db = c.get('db');
  const id = c.req.param('id');

  // Fetch with firewall
  const [record] = await db.select().from(candidates)
    .where(and(buildFirewallConditions(ctx), eq(candidates.id, id)));

  if (!record) {
    return c.json({ error: 'Not found', code: 'NOT_FOUND' }, 404);
  }

  // Access check
  if (!await evaluateAccess(CRUD_ACCESS.update.access, ctx)) {
    return c.json(AccessErrors.roleRequired(
      CRUD_ACCESS.update.access.roles,
      ctx.roles
    ), 403);
  }

  const body = await c.req.json();

  // Guards validation
  const validation = validateUpdate(body);
  if (!validation.valid) {
    if (validation.notUpdatable.length > 0) {
      return c.json(GuardErrors.fieldNotUpdatable(validation.notUpdatable), 400);
    }
  }

  // Apply audit fields
  const data = {
    ...body,
    modifiedAt: new Date().toISOString(),
    modifiedBy: ctx.userId,
  };

  const result = await db.update(candidates).set(data)
    .where(eq(candidates.id, id)).returning();

  return c.json(maskCandidate(result[0], ctx));
});

export default app;

Runtime Behavior

Masking by Role

Roleemailphone
hiring-managerjane@example.com555-123-4567
recruiterjane@example.com555-123-4567
interviewerj***@e******.com******4567

Views + Masking Interaction

Views control which fields are returned. Masking controls what values are shown.

EndpointRolephone in response?phone value
/candidates/views/pipelineinterviewerNoN/A
/candidates/views/pipelinerecruiterNoN/A
/candidates/views/fullinterviewerNoN/A (no access)
/candidates/views/fullrecruiterYes555-123-4567

Guards Enforcement

# Allowed: Create with createable fields
POST /candidates
{ "name": "Jane Smith", "email": "jane@example.com", "source": "linkedin" }

# Rejected: Update non-updatable field
PATCH /candidates/cnd_123
{ "email": "new@example.com" }
# -> 400: "Field 'email' is not updatable"

# Rejected: Set system-managed field
POST /candidates
{ "name": "Jane Smith", "createdBy": "hacker" }
# -> 400: "System-managed fields cannot be set: createdBy"

Generated API Endpoints

MethodEndpointAccess
GET/candidatesowner, hiring-manager, recruiter, interviewer
GET/candidates/:idowner, hiring-manager, recruiter, interviewer
POST/candidateshiring-manager, recruiter
PATCH/candidates/:idhiring-manager, recruiter
DELETE/candidates/:idhiring-manager
GET/candidates/views/pipelineowner, hiring-manager, recruiter, interviewer
GET/candidates/views/fullhiring-manager, recruiter

Key Takeaways

  1. ~50 lines of configuration generates ~500+ lines of production code
  2. Security is declarative - you describe what you want, not how
  3. All layers work together - Firewall -> Access -> Guards -> Masking
  4. Code is readable and auditable - no magic, just TypeScript
  5. Type-safe from definition to API - Drizzle schema drives everything

On this page