@kardoe/quickback 0.5.15 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -67,7 +67,7 @@ export const DOCS = {
67
67
  },
68
68
  "changelog": {
69
69
  "title": "Changelog",
70
- "content": "# Changelog\n\nRelease notes for the Quickback compiler, CLI, and platform.\n\n---\n\n## v0.5.11 — February 24, 2026\n\n### Email OTP & Auth Fixes\n\n- Fixed email-OTP magic links not working correctly\n- Removed deprecated `/internal/validate` endpoint — use standard Better Auth session validation instead\n- Auth is now required for all API routes when running locally (previously some routes were unprotected in dev)\n\n### Multiple Table Exports Fix\n\n- Fixed a compiler error when a single file exports multiple Drizzle tables alongside a `defineTable()` default export\n- The CLI now properly detects and reports this with a clear error message pointing to the fix\n\n### Headless Drizzle Rename Hints\n\n- Added `compiler.migrations.renames` configuration for CI/CD environments where Drizzle's interactive rename prompts would block compilation\n- Compile errors now include explicit rename key paths and fail fast on malformed rename config keys\n- See [Configuration](/compiler/config) for details\n\n### Security Contract Report Artifacts\n\n- Added generated security contract artifacts to compiler output:\n - `reports/security-contracts.report.json`\n - `reports/security-contracts.report.sig.json`\n- Added config-driven signing controls:\n - `compiler.securityContracts.report.signature.enabled`\n - `compiler.securityContracts.report.signature.required`\n - `compiler.securityContracts.report.signature.key` / `keyEnv` / `keyId`\n- Missing required signing keys now fail loudly with explicit remediation guidance\n- Added strict config validation for report/signature paths and signing options\n\n### Mandatory Unsafe Action Audit Trail\n\n- Added structured unsafe action config (`unsafe: { reason, adminOnly, crossTenant, targetScope }`)\n- Cross-tenant unsafe actions now require Better Auth authentication plus platform admin role (`ctx.userRole === \"admin\"`)\n- Added mandatory audit logging for unsafe cross-tenant actions (success, denial, and error paths)\n- Cloudflare output now includes optional `AUDIT_DB` wiring, `drizzle.audit.config.ts`, and `db:migrate:audit:*` scripts when unsafe actions are present\n- Added compile-time raw SQL guard for actions and handlers (`allowRawSql: true` required per action)\n\n---\n\n## v0.5.10 — February 19, 2026\n\n### Compiler Page Parsing & CLI Output\n\n- Improved compiler page parsing for `definePage()` definitions\n- Better CLI output formatting during compilation\n- Added `apiPath` to schema registry for CMS integration\n\n### Bug Fixes\n\n- Fixed hyphenated action names not being properly quoted in generated code (e.g., `mark-complete` now generates valid JavaScript)\n- API key authentication (`x-api-key` header) is now handled separately from session tokens (`Bearer` header)\n\n---\n\n## v0.5.9 — February 16, 2026\n\n### CMS Pages & CLI Page Support\n\n- Added `definePage()` support for CMS-managed pages\n- Auth middleware improvements for page routes\n- CLI now supports page definitions alongside table and action definitions\n\n---\n\n## v0.5.8 — February 14, 2026\n\n### CMS App\n\n- Introduced the Quickback CMS — a schema-driven admin panel that connects to your generated API\n- CMS namespace added to actions for admin-specific operations\n- Fixed `guard` → `access` naming inconsistency in CMS action definitions\n\n### Schema Registry & Firewall Improvements\n\n- Added schema registry generator — the compiler now outputs a JSON schema registry used by the CMS\n- Firewall error modes: choose between `reveal` (403 with details) and `hide` (opaque 404) for security-sensitive deployments\n\n### Bug Fixes\n\n- Fixed anonymous user email format generation\n- Organization selector improvements in Account UI\n- Config validation now catches more errors at compile time\n- Better CRUD error handling with structured error responses\n- Fixed masking to use representative star counts instead of fixed formatting\n\n---\n\n## v0.5.7 — February 12, 2026\n\n### Scoped Database for Actions\n\nActions now receive a **security-scoped database** instead of a raw Drizzle instance. The compiler generates a proxy wrapper that automatically enforces org isolation, owner filtering, and soft-delete visibility — the same protections that CRUD routes have always had.\n\nThe scoped DB uses duck-typed column detection at runtime:\n\n| Column Detected | SELECT / UPDATE / DELETE | INSERT |\n|---|---|---|\n| `organizationId` | Adds `WHERE organizationId = ?` | Auto-injects `organizationId` from context |\n| `ownerId` | Adds `WHERE ownerId = ?` | Auto-injects `ownerId` from context |\n| `deletedAt` | Adds `WHERE deletedAt IS NULL` | — |\n\nThis means **every action is secure by default** — no manual `WHERE` clauses needed.\n\n```typescript\ndefineActions(todos, {\n complete: {\n type: \"record\",\n execute: async ({ db, ctx, record, input }) => {\n // db is scoped — only sees records in user's org, excludes soft-deleted\n const siblings = await db.select().from(todos);\n // ↑ automatically filtered to ctx.activeOrgId + deletedAt IS NULL\n },\n },\n});\n```\n\n#### Unsafe Mode\n\nActions that intentionally need to bypass security (admin reports, cross-org queries, migrations) can declare `unsafe: true` to receive a raw, unscoped database handle:\n\n```typescript\ndefineActions(analytics, {\n globalReport: {\n unsafe: true,\n execute: async ({ db, rawDb, ctx, input }) => {\n // db → still scoped (safety net)\n // rawDb → bypasses all security filters\n const allOrgs = await rawDb.select().from(organizations);\n },\n },\n});\n```\n\nWithout `unsafe: true`, `rawDb` is `undefined`.\n\n**Related docs:** [Actions](/compiler/definitions/actions), [Actions API](/compiler/using-the-api/actions-api)\n\n---\n\n### Cascading Soft Delete\n\nSoft-deleting a parent record now **automatically cascades** to child and junction tables within the same feature. The compiler detects foreign key references at build time and generates cascade UPDATE statements.\n\n```\nDELETE /api/v1/projects/:id\n```\n\nGenerated behavior:\n```typescript\n// 1. Soft delete the parent\nawait db.update(projects)\n .set({ deletedAt: now, deletedBy: userId })\n .where(eq(projects.id, id));\n\n// 2. Auto-cascade to children (compiler-generated)\nawait db.update(projectMembers)\n .set({ deletedAt: now, deletedBy: userId })\n .where(eq(projectMembers.projectId, id));\n\nawait db.update(projectTasks)\n .set({ deletedAt: now, deletedBy: userId })\n .where(eq(projectTasks.projectId, id));\n```\n\nRules:\n- Only applies to **soft delete** (the default). Hard delete relies on database-level `ON DELETE CASCADE`.\n- Only cascades within the **same feature** — cross-feature references are not affected.\n- Child tables must have `deletedAt` / `deletedBy` columns (auto-added by the compiler's audit fields).\n\n**Related docs:** [Actions API — Cascading Soft Delete](/compiler/using-the-api/actions-api#cascading-soft-delete)\n\n---\n\n### Advanced Query Parameters\n\nNew query parameter capabilities for all list endpoints:\n\n- **Field selection** — `?fields=id,name,status` returns only the columns you need\n- **Multi-sort** — `?sort=status:asc,createdAt:desc` sorts by multiple fields\n- **Total count** — `?count=true` returns total matching records in response headers (`X-Total-Count`)\n- **Full-text search** — `?search=keyword` searches across all text columns\n\n```bash\n# Get only names and statuses, sorted by status then date, with total count\nGET /api/v1/todos?fields=id,name,status&sort=status:asc,createdAt:desc&count=true\n\n# Search across all text fields\nGET /api/v1/todos?search=urgent\n```\n\n**Related docs:** [Query Parameters](/compiler/using-the-api/query-params)\n\n---\n\n### Audit Field Improvements\n\n- `deletedAt` and `deletedBy` fields are now **always injected** by the compiler for tables with soft delete enabled — no need to define them in your schema\n- All audit fields (`createdAt`, `createdBy`, `modifiedAt`, `modifiedBy`, `deletedAt`, `deletedBy`) are auto-managed\n\n---\n\n## v0.5.6 — February 8, 2026\n\n### Database Naming Conventions\n\n- Default table and column naming changed to **snake_case** with `usePlurals: false`\n- Table names derived from generated Better Auth schema for consistency\n- Removed legacy single-database mode — split databases (auth + features) is now the standard\n\n### Auth Variable Shadowing Fix\n\n- Fixed `member` variable in auth middleware that shadowed the Drizzle `member` table import\n- Renamed to `sessionMember` to avoid conflicts in generated routes\n\n---\n\n## v0.5.5 — February 5, 2026\n\n### Better Auth Plugins\n\n- Published `@kardoe/better-auth-upgrade-anonymous` v1.1.0 — post-passkey email collection flow\n- Published `@kardoe/better-auth-combo-auth` — combined email + password + OTP authentication\n- Published `@kardoe/better-auth-aws-ses` — AWS SES email provider for Better Auth\n\n### OpenAPI Spec Generation\n\n- Generated APIs now include a full OpenAPI specification at `/openapi.json`\n- Better Auth endpoints included in the spec\n- Runtime route: `GET /openapi.json`\n\n### Security Hardening\n\n- Global error handler prevents leaking internal error details\n- Security headers middleware (CSP, HSTS, X-Frame-Options, X-Content-Type-Options)\n- `BETTER_AUTH_SECRET` properly passed to generated config\n\n---\n\n## v0.5.4 — January 30, 2026\n\n### Account UI\n\n- Pre-built authentication UI deployed as Cloudflare Workers\n- Features: sessions, organizations, passkeys, passwordless, admin panel, API keys\n- Dual-mode: standalone (degit template) or embedded with Quickback projects\n\n### Webhook System\n\n- Inbound webhook endpoints with signature verification\n- Outbound webhooks via Cloudflare Queues with automatic retries\n- Configurable per-feature webhook events\n\n### Realtime & Vector Search\n\n- Durable Objects + WebSocket realtime subscriptions\n- Vector embeddings via Cloudflare Vectorize\n- KV and R2 storage integrations\n\n---\n\n## v0.5.0 — January 2026\n\n### Initial Release\n\n- **Quickback Compiler** — TypeScript-first backend compiler\n- **Four Security Pillars** — Firewall, Access, Guards, Masking\n- **Combined Mode** — `defineTable()` with schema + security in a single file\n- **Templates** — Cloudflare Workers, Bun standalone, B2B SaaS\n- **Cloud Compiler** — Remote compilation via `compiler.quickback.dev`\n- **CLI** — `quickback create`, `quickback compile`, `quickback init`\n- **Better Auth Integration** — Organizations, roles, sessions\n- **Drizzle ORM** — Schema-first with automatic migrations\n- **Cloudflare D1** — Split database support (auth + features)"
70
+ "content": "# Changelog\n\nRelease notes for the Quickback compiler, CLI, and platform.\n\n---\n\n## v0.5.11 — February 24, 2026\n\n### Email OTP & Auth Fixes\n\n- Fixed email-OTP magic links not working correctly\n- Removed deprecated `/internal/validate` endpoint — use standard Better Auth session validation instead\n- Auth is now required for all API routes when running locally (previously some routes were unprotected in dev)\n\n### Multiple Table Exports Fix\n\n- Fixed a compiler error when a single file exports multiple Drizzle tables alongside a `defineTable()` default export\n- The CLI now properly detects and reports this with a clear error message pointing to the fix\n\n### Headless Drizzle Rename Hints\n\n- Added `compiler.migrations.renames` configuration for CI/CD environments where Drizzle's interactive rename prompts would block compilation\n- Compile errors now include explicit rename key paths and fail fast on malformed rename config keys\n- See [Configuration](/compiler/config) for details\n\n### Security Contract Report Artifacts\n\n- Added generated security contract artifacts to compiler output:\n - `reports/security-contracts.report.json`\n - `reports/security-contracts.report.sig.json`\n- Added config-driven signing controls:\n - `compiler.securityContracts.report.signature.enabled`\n - `compiler.securityContracts.report.signature.required`\n - `compiler.securityContracts.report.signature.key` / `keyEnv` / `keyId`\n- Missing required signing keys now fail loudly with explicit remediation guidance\n- Added strict config validation for report/signature paths and signing options\n\n### Mandatory Unsafe Action Audit Trail\n\n- Added structured unsafe action config (`unsafe: { reason, adminOnly, crossTenant, targetScope }`)\n- Cross-tenant unsafe actions now require Better Auth authentication plus platform admin role (`ctx.userRole === \"admin\"`)\n- Added mandatory audit logging for unsafe cross-tenant actions (success, denial, and error paths)\n- Cloudflare output now includes optional `AUDIT_DB` wiring, `drizzle.audit.config.ts`, and `db:migrate:audit:*` scripts when unsafe actions are present\n- Added compile-time raw SQL guard for actions and handlers (`allowRawSql: true` required per action)\n\n---\n\n## v0.5.10 — February 19, 2026\n\n### Compiler Page Parsing & CLI Output\n\n- Improved compiler page parsing for `definePage()` definitions\n- Better CLI output formatting during compilation\n- Added `apiPath` to schema registry for CMS integration\n\n### Bug Fixes\n\n- Fixed hyphenated action names not being properly quoted in generated code (e.g., `mark-complete` now generates valid JavaScript)\n- API key authentication (`x-api-key` header) is now handled separately from session tokens (`Bearer` header)\n\n---\n\n## v0.5.9 — February 16, 2026\n\n### CMS Pages & CLI Page Support\n\n- Added `definePage()` support for CMS-managed pages\n- Auth middleware improvements for page routes\n- CLI now supports page definitions alongside table and action definitions\n\n---\n\n## v0.5.8 — February 14, 2026\n\n### CMS App\n\n- Introduced the Quickback CMS — a schema-driven admin panel that connects to your generated API\n- CMS namespace added to actions for admin-specific operations\n- Fixed `guard` → `access` naming inconsistency in CMS action definitions\n\n### Schema Registry & Firewall Improvements\n\n- Added schema registry generator — the compiler now outputs a JSON schema registry used by the CMS\n- Firewall error modes: choose between `reveal` (403 with details) and `hide` (opaque 404) for security-sensitive deployments\n\n### Bug Fixes\n\n- Fixed anonymous user email format generation\n- Organization selector improvements in Account UI\n- Config validation now catches more errors at compile time\n- Better CRUD error handling with structured error responses\n- Fixed masking to use representative star counts instead of fixed formatting\n\n---\n\n## v0.5.7 — February 12, 2026\n\n### Scoped Database for Actions\n\nActions now receive a **security-scoped database** instead of a raw Drizzle instance. The compiler generates a proxy wrapper that automatically enforces org isolation, owner filtering, and soft-delete visibility — the same protections that CRUD routes have always had.\n\nThe scoped DB uses duck-typed column detection at runtime:\n\n| Column Detected | SELECT / UPDATE / DELETE | INSERT |\n|---|---|---|\n| `organizationId` | Adds `WHERE organizationId = ?` | Auto-injects `organizationId` from context |\n| `ownerId` | Adds `WHERE ownerId = ?` | Auto-injects `ownerId` from context |\n| `deletedAt` | Adds `WHERE deletedAt IS NULL` | — |\n\nThis means **every action is secure by default** — no manual `WHERE` clauses needed.\n\n```typescript\ndefineActions(todos, {\n complete: {\n type: \"record\",\n execute: async ({ db, ctx, record, input }) => {\n // db is scoped — only sees records in user's org, excludes soft-deleted\n const siblings = await db.select().from(todos);\n // ↑ automatically filtered to ctx.activeOrgId + deletedAt IS NULL\n },\n },\n});\n```\n\n#### Unsafe Mode\n\nActions that intentionally need to bypass security (admin reports, cross-org queries, migrations) can declare `unsafe: true` to receive a raw, unscoped database handle:\n\n```typescript\ndefineActions(analytics, {\n globalReport: {\n unsafe: true,\n execute: async ({ db, rawDb, ctx, input }) => {\n // db → still scoped (safety net)\n // rawDb → bypasses all security filters\n const allOrgs = await rawDb.select().from(organizations);\n },\n },\n});\n```\n\nWithout `unsafe: true`, `rawDb` is `undefined`.\n\n**Related docs:** [Actions](/compiler/definitions/actions), [Actions API](/compiler/using-the-api/actions-api)\n\n---\n\n### Cascading Soft Delete\n\nSoft-deleting a parent record now **automatically cascades** to child and junction tables within the same feature. The compiler detects foreign key references at build time and generates cascade UPDATE statements.\n\n```\nDELETE /api/v1/projects/:id\n```\n\nGenerated behavior:\n```typescript\n// 1. Soft delete the parent\nawait db.update(projects)\n .set({ deletedAt: now, deletedBy: userId })\n .where(eq(projects.id, id));\n\n// 2. Auto-cascade to children (compiler-generated)\nawait db.update(projectMembers)\n .set({ deletedAt: now, deletedBy: userId })\n .where(eq(projectMembers.projectId, id));\n\nawait db.update(projectTasks)\n .set({ deletedAt: now, deletedBy: userId })\n .where(eq(projectTasks.projectId, id));\n```\n\nRules:\n- Only applies to **soft delete** (the default). Hard delete relies on database-level `ON DELETE CASCADE`.\n- Only cascades within the **same feature** — cross-feature references are not affected.\n- Child tables must have `deletedAt` / `deletedBy` columns (auto-added by the compiler's audit fields).\n\n**Related docs:** [Actions API — Cascading Soft Delete](/compiler/using-the-api/actions-api#cascading-soft-delete)\n\n---\n\n### Advanced Query Parameters\n\nNew query parameter capabilities for all list endpoints:\n\n- **Field selection** — `?fields=id,name,status` returns only the columns you need\n- **Multi-sort** — `?sort=status:asc,createdAt:desc` sorts by multiple fields\n- **Total count** — `?count=true` returns total matching records in response headers (`X-Total-Count`)\n- **Full-text search** — `?search=keyword` searches across all text columns\n\n```bash\n# Get only names and statuses, sorted by status then date, with total count\nGET /api/v1/todos?fields=id,name,status&sort=status:asc,createdAt:desc&count=true\n\n# Search across all text fields\nGET /api/v1/todos?search=urgent\n```\n\n**Related docs:** [Query Parameters](/compiler/using-the-api/query-params)\n\n---\n\n### Audit Field Improvements\n\n- `deletedAt` and `deletedBy` fields are now **always injected** by the compiler for tables with soft delete enabled — no need to define them in your schema\n- All audit fields (`createdAt`, `createdBy`, `modifiedAt`, `modifiedBy`, `deletedAt`, `deletedBy`) are auto-managed\n\n---\n\n## v0.5.6 — February 8, 2026\n\n### Database Naming Conventions\n\n- Default table and column naming changed to **snake_case** with `usePlurals: false`\n- Table names derived from generated Better Auth schema for consistency\n- Removed legacy single-database mode — split databases (auth + features) is now the standard\n\n### Auth Variable Shadowing Fix\n\n- Fixed `member` variable in auth middleware that shadowed the Drizzle `member` table import\n- Renamed to `sessionMember` to avoid conflicts in generated routes\n\n---\n\n## v0.5.5 — February 5, 2026\n\n### Better Auth Plugins\n\n- Published `@kardoe/better-auth-upgrade-anonymous` v1.1.0 — post-passkey email collection flow\n- Published `@kardoe/better-auth-combo-auth` — combined email + password + OTP authentication\n- Published `@kardoe/better-auth-aws-ses` — AWS SES email provider for Better Auth\n\n### OpenAPI Spec Generation\n\n- Generated APIs now include a full OpenAPI specification at `/openapi.json`\n- Better Auth endpoints included in the spec\n- Runtime route: `GET /openapi.json`\n\n### Security Hardening\n\n- Global error handler prevents leaking internal error details\n- Security headers middleware (CSP, HSTS, X-Frame-Options, X-Content-Type-Options)\n- `BETTER_AUTH_SECRET` properly passed to generated config\n\n---\n\n## v0.5.4 — January 30, 2026\n\n### Account UI\n\n- Pre-built authentication UI deployed as Cloudflare Workers\n- Features: sessions, organizations, passkeys, passwordless, admin panel, API keys\n- Dual-mode: standalone (degit template) or embedded with Quickback projects\n\n### Webhook System\n\n- Inbound webhook endpoints with signature verification\n- Outbound webhooks via Cloudflare Queues with automatic retries\n- Configurable per-feature webhook events\n\n### Realtime & Vector Search\n\n- Durable Objects + WebSocket realtime subscriptions\n- Vector embeddings via Cloudflare Vectorize\n- KV and R2 storage integrations\n\n---\n\n## v0.5.0 — January 2026\n\n### Initial Release\n\n- **Quickback Compiler** — TypeScript-first backend compiler\n- **Four Security Pillars** — Firewall, Access, Guards, Masking\n- **`defineTable()`** — Schema + security configuration in a single file\n- **Templates** — Cloudflare Workers, Bun standalone, B2B SaaS\n- **Cloud Compiler** — Remote compilation via `compiler.quickback.dev`\n- **CLI** — `quickback create`, `quickback compile`, `quickback init`\n- **Better Auth Integration** — Organizations, roles, sessions\n- **Drizzle ORM** — Schema-first with automatic migrations\n- **Cloudflare D1** — Split database support (auth + features)"
71
71
  },
72
72
  "cms/actions": {
73
73
  "title": "Actions",
@@ -83,7 +83,7 @@ export const DOCS = {
83
83
  },
84
84
  "cms": {
85
85
  "title": "Quickback CMS",
86
- "content": "# Quickback CMS\n\nA schema-driven admin interface that reads `schema-registry.json` generated by the Quickback compiler. Every table, column, action, view, and security rule is rendered automatically. Zero UI code per table.\n\n## Overview\n\nThe CMS generates its entire UI from your Quickback definitions. Define a table with columns, guards, masking, views, and actions in your feature files. Run the compiler. The CMS reads the resulting schema registry and renders a complete admin interface — data tables, inline editing, action dialogs, role-based access, and field masking — all without writing a single line of UI code.\n\n## Key Features\n\n- **Schema-driven** — Zero UI code per table. Add a table, recompile, and it appears in the CMS.\n- **Dual view modes** — Table browse mode for navigation and Data Table mode for spreadsheet-style editing.\n- **Role-based access** — Owner, admin, and member roles with live switching. CRUD buttons hidden when unauthorized.\n- **Inline spreadsheet editing** — Excel/Google Sheets-like editing with keyboard navigation (arrows, Tab, Enter, Escape).\n- **FK typeahead** — Server-side search for foreign key fields with debounced queries and keyboard navigation.\n- **Field masking** — Email, phone, SSN, and redaction patterns applied per role. Masked fields show a lock icon.\n- **Custom actions** — Action dialogs with auto-generated input forms, access filtering, CMS metadata (icons, categories, confirmations), and side effects warnings.\n- **Views** — Named column-level projections per role. \"All Fields\" plus custom views in the toolbar.\n- **Auto-form generation** — Create and edit forms built from guards (createable/updatable fields).\n- **Display column auto-detection** — FK labels resolved automatically from `name`, `title`, `label`, `code`, and other common patterns.\n\n## Architecture\n\nThe CMS sits at the end of the Quickback compilation pipeline:\n\n```\nQuickback Definitions (feature files)\n |\n v\n Compiler\n |\n v\n schema-registry.json\n |\n v\n CMS reads it\n |\n v\n Renders admin UI\n```\n\nYour feature definitions are the single source of truth. The compiler extracts all metadata — columns, types, guards, masking rules, views, actions, validation, and firewall config — into a static JSON file. The CMS consumes that file and renders the appropriate UI for each table.\n\n## Quick Start\n\n### 1. Enable Schema Registry Generation\n\nAdd `schemaRegistry` to your `quickback.config.ts`:\n\n```typescript title=\"quickback/quickback.config.ts\"\nexport default defineConfig({\n schemaRegistry: { generate: true },\n // ... rest of your config\n});\n```\n\n### 2. Compile\n\n```bash\nquickback compile\n```\n\nThis generates `schema-registry.json` alongside your compiled API output.\n\n### 3. Point the CMS at Your Schema\n\nThe CMS reads the generated `schema-registry.json` to discover all tables, columns, security rules, and actions. In development, it can also run in demo mode with mock data and a role switcher for testing different access levels.\n\n## Next Steps\n\n- **[Schema Registry](/cms/schema-registry)** — Understand the JSON format the compiler generates\n- **[Connecting](/cms/connecting)** — Demo mode vs. live mode setup\n- **[Table Views](/cms/table-views)** — Browse and Data Table view modes\n- **[Inline Editing](/cms/inline-editing)** — Spreadsheet-style editing and FK typeahead\n- **[Security](/cms/security)** — How the CMS enforces all four security layers\n- **[Actions](/cms/actions)** — Custom actions with input forms, access filtering, and CMS metadata\n- **[Schema Format Reference](/cms/schema-format)** — Full TypeScript types for schema-registry.json\n- **[Components Reference](/cms/components)** — All CMS components and their props"
86
+ "content": "# Quickback CMS\n\nA schema-driven admin interface that reads `schema-registry.json` generated by the Quickback compiler. Every table, column, action, view, and security rule is rendered automatically. Zero UI code per table.\n\n## Overview\n\nThe CMS generates its entire UI from your Quickback definitions. Define a table with columns, guards, masking, views, and actions in your feature files. Run the compiler. The CMS reads the resulting schema registry and renders a complete admin interface — data tables, inline editing, action dialogs, role-based access, and field masking — all without writing a single line of UI code.\n\n## Key Features\n\n- **Schema-driven** — Zero UI code per table. Add a table, recompile, and it appears in the CMS.\n- **Dual view modes** — Table browse mode for navigation and Data Table mode for spreadsheet-style editing.\n- **Role-based access** — Owner, admin, and member roles with live switching. CRUD buttons hidden when unauthorized.\n- **Inline spreadsheet editing** — Excel/Google Sheets-like editing with keyboard navigation (arrows, Tab, Enter, Escape).\n- **FK typeahead** — Server-side search for foreign key fields with debounced queries and keyboard navigation.\n- **Field masking** — Email, phone, SSN, and redaction patterns applied per role. Masked fields show a lock icon.\n- **Custom actions** — Action dialogs with auto-generated input forms, access filtering, CMS metadata (icons, categories, confirmations), and side effects warnings.\n- **Views** — Named column-level projections per role. \"All Fields\" plus custom views in the toolbar.\n- **Auto-form generation** — Create and edit forms built from guards (createable/updatable fields).\n- **Display column auto-detection** — FK labels resolved automatically from `name`, `title`, `label`, `code`, and other common patterns.\n\n## Architecture\n\nThe CMS sits at the end of the Quickback compilation pipeline:\n\n```\nQuickback Definitions (feature files)\n |\n v\n Compiler\n |\n v\n schema-registry.json\n |\n v\n CMS reads it\n |\n v\n Renders admin UI\n```\n\nYour feature definitions are the single source of truth. The compiler extracts all metadata — columns, types, guards, masking rules, views, actions, validation, and firewall config — into a static JSON file. The CMS consumes that file and renders the appropriate UI for each table.\n\n## Quick Start\n\n### 1. Compile\n\n```bash\nquickback compile\n```\n\nThe compiler automatically generates `schema-registry.json` alongside your compiled API output.\n\n### 3. Point the CMS at Your Schema\n\nThe CMS reads the generated `schema-registry.json` to discover all tables, columns, security rules, and actions. In development, it can also run in demo mode with mock data and a role switcher for testing different access levels.\n\n## Next Steps\n\n- **[Schema Registry](/cms/schema-registry)** — Understand the JSON format the compiler generates\n- **[Connecting](/cms/connecting)** — Demo mode vs. live mode setup\n- **[Table Views](/cms/table-views)** — Browse and Data Table view modes\n- **[Inline Editing](/cms/inline-editing)** — Spreadsheet-style editing and FK typeahead\n- **[Security](/cms/security)** — How the CMS enforces all four security layers\n- **[Actions](/cms/actions)** — Custom actions with input forms, access filtering, and CMS metadata\n- **[Schema Format Reference](/cms/schema-format)** — Full TypeScript types for schema-registry.json\n- **[Components Reference](/cms/components)** — All CMS components and their props"
87
87
  },
88
88
  "cms/inline-editing": {
89
89
  "title": "Inline Editing",
@@ -91,7 +91,7 @@ export const DOCS = {
91
91
  },
92
92
  "cms/record-layouts": {
93
93
  "title": "Record Layouts",
94
- "content": "# Record Layouts\n\nThe CMS record detail page groups fields into collapsible sections. By default, fields are auto-grouped by naming heuristics (Identity, Contact Info, Financial, etc.). With **record layouts**, you control the exact grouping.\n\nThere are two layers:\n\n1. **Code-defined layouts** — developers configure named layouts in `defineTable()`\n2. **User-created views** — end-users create and save custom views via the CMS UI\n\n## Code-Defined Layouts\n\nAdd a `layouts` property to your `defineTable()` config:\n\n```typescript\nexport default defineTable(contacts, {\n firewall: { organization: {} },\n crud: { /* ... */ },\n layouts: {\n default: {\n sections: [\n { label: \"Contact Info\", columns: 2, fields: [\"name\", \"email\", \"phone\", \"mobile\"] },\n { label: \"Address\", columns: 2, fields: [\"address1\", \"address2\", \"city\", \"state\", \"zip\"] },\n { label: \"Internal Notes\", collapsed: true, fields: [\"notes\", \"internalNotes\"] }\n ]\n },\n compact: {\n sections: [\n { label: \"Summary\", fields: [\"name\", \"status\", \"email\"] }\n ]\n }\n }\n});\n```\n\nEach layout has an ordered list of sections. Each section specifies:\n\n| Property | Type | Default | Description |\n|----------|------|---------|-------------|\n| `label` | string | required | Section header text |\n| `fields` | string[] | required | Column names to display |\n| `columns` | `1 \\| 2` | `1` | Number of columns for field layout |\n| `collapsed` | boolean | `false` | Whether the section starts collapsed |\n\nFields not assigned to any section are collected into an \"Other Fields\" section at the bottom.\n\n### Layout Switcher\n\nWhen a table has multiple named layouts, a dropdown appears in the record detail header. Selections persist per table using `localStorage`.\n\nIf only one layout is defined, it's used automatically without showing a dropdown.\n\n## User-Created Custom Views\n\nEnd-users can create their own record layouts via the CMS UI. These are stored in the database and can be shared with other organization members.\n\n### Creating a View\n\n1. Open any record detail page\n2. Click the **+ View** button in the header\n3. In the view builder dialog:\n - Name your view\n - Add sections and assign fields from a dropdown\n - Set columns (1 or 2) and collapsed state per section\n - Optionally share with your organization\n4. Click **Create View**\n\nThe new view appears in the layout dropdown alongside code-defined layouts.\n\n### Editing and Deleting Views\n\n- Click the **gear icon** next to the dropdown to edit the current custom view\n- Click the **trash icon** to delete it (with confirmation)\n- Code-defined layouts cannot be edited or deleted from the CMS\n\n### Access Control\n\n| Operation | Who Can Do It |\n|-----------|---------------|\n| Create a view | Any member, admin, or owner |\n| Edit/delete own views | The creator |\n| Edit/delete any view | Admins and owners |\n| View shared views | All organization members |\n\n### Setting Up the Custom View Feature\n\nTo enable user-created views, add a `customView` table to your Quickback project:\n\n```typescript\n// quickback/features/cms/custom-view.ts\n\nexport const customView = sqliteTable(\"custom_view\", {\n id: text(\"id\").primaryKey(),\n organizationId: text(\"organization_id\").notNull(),\n tableName: text(\"table_name\").notNull(),\n name: text(\"name\").notNull(),\n description: text(\"description\"),\n layoutConfig: text(\"layout_config\").notNull(),\n isShared: integer(\"is_shared\", { mode: \"boolean\" }).default(false),\n});\n\nexport default defineTable(customView, {\n displayColumn: \"name\",\n firewall: { organization: {} },\n crud: {\n list: { access: { roles: [\"owner\", \"admin\", \"member\"] } },\n get: { access: { roles: [\"owner\", \"admin\", \"member\"] } },\n create: { access: { roles: [\"owner\", \"admin\", \"member\"] } },\n update: {\n access: {\n or: [\n { roles: [\"owner\", \"admin\"] },\n { roles: [\"member\"], record: { createdBy: { equals: \"$userId\" } } }\n ]\n }\n },\n delete: {\n access: {\n or: [\n { roles: [\"owner\", \"admin\"] },\n { roles: [\"member\"], record: { createdBy: { equals: \"$userId\" } } }\n ]\n },\n mode: \"hard\"\n }\n },\n guards: {\n createable: [\"tableName\", \"name\", \"description\", \"layoutConfig\", \"isShared\"],\n updatable: [\"name\", \"description\", \"layoutConfig\", \"isShared\"]\n }\n});\n```\n\nCompile your project to generate the API endpoints. The CMS will automatically detect the `customView` table and enable the view builder UI.\n\n## Fallback Behavior\n\n| Scenario | Result |\n|----------|--------|\n| No `layouts` config, no custom views | Auto-grouping by naming heuristics |\n| `layouts` config defined | Uses \"default\" layout or first available |\n| Multiple layouts | Dropdown for switching, persisted per table |\n| Custom views created | Appear in dropdown below code-defined layouts |\n\n## Next Steps\n\n- **[Table Views](/cms/table-views)** — Column projections for list views\n- **[Schema Format](/cms/schema-format)** — Full TypeScript type reference\n- **[Database Schema](/compiler/definitions/database-schema)** — defineTable() configuration reference"
94
+ "content": "# Record Layouts\n\nThe CMS record detail page groups fields into collapsible sections. By default, fields are auto-grouped by naming heuristics (Identity, Contact Info, Financial, etc.). With **record layouts**, you control the exact grouping.\n\nThere are two layers:\n\n1. **Code-defined layouts** — developers configure named layouts in `defineTable()`\n2. **User-created views** — end-users create and save custom views via the CMS UI\n\n## Code-Defined Layouts\n\nAdd a `layouts` property to your `defineTable()` config:\n\n```typescript\nexport default defineTable(contacts, {\n firewall: { organization: {} },\n crud: { /* ... */ },\n layouts: {\n default: {\n sections: [\n { label: \"Contact Info\", columns: 2, fields: [\"name\", \"email\", \"phone\", \"mobile\"] },\n { label: \"Address\", columns: 2, fields: [\"address1\", \"address2\", \"city\", \"state\", \"zip\"] },\n { label: \"Internal Notes\", collapsed: true, fields: [\"notes\", \"internalNotes\"] }\n ]\n },\n compact: {\n sections: [\n { label: \"Summary\", fields: [\"name\", \"status\", \"email\"] }\n ]\n }\n }\n});\n```\n\nEach layout has an ordered list of sections. Each section specifies:\n\n| Property | Type | Default | Description |\n|----------|------|---------|-------------|\n| `label` | string | required | Section header text |\n| `fields` | string[] | required | Column names to display |\n| `columns` | `1 \\| 2` | `1` | Number of columns for field layout |\n| `collapsed` | boolean | `false` | Whether the section starts collapsed |\n\nFields not assigned to any section are collected into an \"Other Fields\" section at the bottom.\n\n### Layout Switcher\n\nWhen a table has multiple named layouts, a dropdown appears in the record detail header. Selections persist per table using `localStorage`.\n\nIf only one layout is defined, it's used automatically without showing a dropdown.\n\n## User-Created Custom Views\n\nEnd-users can create their own record layouts via the CMS UI. These are stored in the database and can be shared with other organization members.\n\n### Creating a View\n\n1. Open any record detail page\n2. Click the **+ View** button in the header\n3. In the view builder dialog:\n - Name your view\n - Add sections and assign fields from a dropdown\n - Set columns (1 or 2) and collapsed state per section\n - Optionally share with your organization\n4. Click **Create View**\n\nThe new view appears in the layout dropdown alongside code-defined layouts.\n\n### Editing and Deleting Views\n\n- Click the **gear icon** next to the dropdown to edit the current custom view\n- Click the **trash icon** to delete it (with confirmation)\n- Code-defined layouts cannot be edited or deleted from the CMS\n\n### Access Control\n\n| Operation | Who Can Do It |\n|-----------|---------------|\n| Create a view | Any member, admin, or owner |\n| Edit/delete own views | The creator |\n| Edit/delete any view | Admins and owners |\n| View shared views | All organization members |\n\n### Setting Up the Custom View Feature\n\nTo enable user-created views, add a `customView` table to your Quickback project:\n\n```typescript\n// quickback/features/cms/custom-view.ts\n\nexport const customView = sqliteTable(\"custom_view\", {\n id: text(\"id\").primaryKey(),\n organizationId: text(\"organization_id\").notNull(),\n tableName: text(\"table_name\").notNull(),\n name: text(\"name\").notNull(),\n description: text(\"description\"),\n layoutConfig: text(\"layout_config\").notNull(),\n isShared: integer(\"is_shared\", { mode: \"boolean\" }).default(false),\n});\n\nexport default defineTable(customView, {\n displayColumn: \"name\",\n firewall: { organization: {} },\n crud: {\n list: { access: { roles: [\"owner\", \"admin\", \"member\"] } },\n get: { access: { roles: [\"owner\", \"admin\", \"member\"] } },\n create: { access: { roles: [\"owner\", \"admin\", \"member\"] } },\n update: {\n access: {\n or: [\n { roles: [\"owner\", \"admin\"] },\n { roles: [\"member\"], record: { createdBy: { equals: \"$userId\" } } }\n ]\n }\n },\n delete: {\n access: {\n or: [\n { roles: [\"owner\", \"admin\"] },\n { roles: [\"member\"], record: { createdBy: { equals: \"$userId\" } } }\n ]\n },\n mode: \"hard\"\n }\n },\n guards: {\n createable: [\"tableName\", \"name\", \"description\", \"layoutConfig\", \"isShared\"],\n updatable: [\"name\", \"description\", \"layoutConfig\", \"isShared\"]\n }\n});\n```\n\nCompile your project to generate the API endpoints. The CMS will automatically detect the `customView` table and enable the view builder UI.\n\n## Fallback Behavior\n\n| Scenario | Result |\n|----------|--------|\n| No `layouts` config, no custom views | Auto-grouping by naming heuristics |\n| `layouts` config defined | Uses \"default\" layout or first available |\n| Multiple layouts | Dropdown for switching, persisted per table |\n| Custom views created | Appear in dropdown below code-defined layouts |\n\n## Next Steps\n\n- **[Table Views](/cms/table-views)** — Column projections for list views\n- **[Schema Format](/cms/schema-format)** — Full TypeScript type reference\n- **[Database Schema](/compiler/definitions/schema)** — defineTable() configuration reference"
95
95
  },
96
96
  "cms/schema-format": {
97
97
  "title": "Schema Format Reference",
@@ -99,7 +99,7 @@ export const DOCS = {
99
99
  },
100
100
  "cms/schema-registry": {
101
101
  "title": "Schema Registry",
102
- "content": "# Schema Registry\n\nThe schema registry is a static JSON file generated by the Quickback compiler. It contains full metadata about every table in your project — columns, types, guards, masking rules, views, actions, validation, and firewall config. The CMS reads this file to render its entire UI.\n\n## Enabling Generation\n\nAdd `schemaRegistry` to your config:\n\n```typescript title=\"quickback/quickback.config.ts\"\nexport default defineConfig({\n schemaRegistry: { generate: true },\n // ... rest of your config\n});\n```\n\nRun `quickback compile` and the compiler outputs `schema-registry.json` alongside your compiled API files.\n\n## Output Shape\n\nThe top-level structure of `schema-registry.json`:\n\n```json\n{\n \"generatedAt\": \"2026-02-14T06:26:53.554Z\",\n \"generatedBy\": \"quickback-compiler\",\n \"version\": \"1.0.0\",\n \"features\": { ... },\n \"tables\": { ... },\n \"tablesByFeature\": { ... }\n}\n```\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `generatedAt` | `string` | ISO timestamp of generation |\n| `generatedBy` | `string` | Always `\"quickback-compiler\"` |\n| `version` | `string` | Compiler version used |\n| `features` | `Record<string, string[]>` | Feature name to file list mapping |\n| `tables` | `Record<string, TableMeta>` | Table name to full metadata |\n| `tablesByFeature` | `Record<string, string[]>` | Feature name to table name list |\n\n## TableMeta\n\nEach table entry contains everything the CMS needs to render its UI:\n\n```typescript\ninterface TableMeta {\n name: string; // camelCase table name (e.g., \"accountCode\")\n dbName: string; // SQL table name (e.g., \"account_code\")\n feature: string; // Parent feature name\n columns: ColumnMeta[]; // All columns including audit fields\n firewall: Record<string, unknown>; // Tenant isolation config\n crud: Record<string, CrudConfig>; // Per-operation access rules\n guards: {\n createable: string[]; // Fields allowed on create\n updatable: string[]; // Fields allowed on update\n immutable: string[]; // Fields locked after creation\n protected: Record<string, string[]>; // Fields only updatable via actions\n };\n masking: Record<string, MaskingRule>; // Per-field masking rules\n views: Record<string, ViewConfig>; // Named column projections\n validation: Record<string, ValidationRule>; // Per-field validation\n actions: ActionMeta[]; // Available actions for this table\n displayColumn?: string; // Human-readable label column\n internal?: boolean; // Hidden from CMS sidebar when true\n}\n```\n\n## ColumnMeta\n\nEach column in the `columns` array:\n\n```typescript\ninterface ColumnMeta {\n name: string; // Property name (camelCase)\n dbName: string; // SQL column name (snake_case)\n type: \"text\" | \"integer\" | \"real\" | \"blob\"; // SQLite type\n mode?: \"boolean\"; // When an integer represents a boolean\n primaryKey: boolean;\n notNull: boolean;\n defaultValue?: string | number | boolean;\n fkTarget?: string; // Target table name for FK columns\n}\n```\n\nThe compiler automatically includes audit fields (`id`, `organizationId`, `createdAt`, `createdBy`, `modifiedAt`, `modifiedBy`, `deletedAt`) at the beginning of every table's column list.\n\n### fkTarget — FK Resolution\n\nColumns ending in `Id` may have a `fkTarget` property indicating which table they reference. This is resolved in priority order:\n\n1. **Explicit `references`** — from your `defineTable()` config (highest priority)\n2. **Drizzle `.references()`** — parsed from schema source code\n3. **Convention** — strip `Id` suffix, match table name directly\n\n```json\n{\n \"name\": \"vendorId\",\n \"type\": \"text\",\n \"fkTarget\": \"contact\"\n}\n```\n\nThe CMS uses `fkTarget` to render typeahead/lookup inputs that search the correct table instead of showing raw IDs.\n\n## Input Hints\n\nTables with `inputHints` configured in `defineTable()` include an `inputHints` map in their metadata:\n\n```json\n{\n \"name\": \"invoice\",\n \"inputHints\": {\n \"status\": \"select\",\n \"sortOrder\": \"radio\",\n \"isPartialPaymentDisabled\": \"checkbox\",\n \"headerMessage\": \"textarea\"\n }\n}\n```\n\nThe CMS reads these hints to render the appropriate form control for each field. See [Input Hints](/compiler/definitions/schema#input-hints) for the full list of supported values.\n\n## Display Column\n\nThe `displayColumn` field tells the CMS which column to use as a human-readable label for a record. This is used in:\n\n- FK typeahead dropdowns (showing names instead of IDs)\n- Record titles in detail views\n- Breadcrumb labels\n\n### Auto-Detection\n\nIf you don't explicitly set `displayColumn` in your resource config, the compiler auto-detects it by scanning column names in priority order:\n\n1. `name`\n2. `title`\n3. `label`\n4. `headline`\n5. `subject`\n6. `code`\n7. `displayName`\n8. `fullName`\n9. `description`\n\nThe first match wins. If no candidate matches, the table has no display column and the CMS falls back to showing IDs.\n\n### Explicit Config\n\nSet it explicitly in your table definition:\n\n```typescript\nexport default defineTable(contacts, {\n displayColumn: \"companyName\",\n // ...\n});\n```\n\n## FK Label Resolution\n\nWhen a table has foreign key columns (ending in `Id`), the API enriches list responses with `_label` fields. For example, a `roomTypeId` column gets a corresponding `roomType_label` field containing the display column value from the referenced table.\n\nThe CMS uses these `_label` fields to show human-readable names in table cells and FK typeahead dropdowns instead of raw UUIDs.\n\n```\nroomTypeId: \"rt_abc123\" → displayed as \"Master Bedroom\"\naccountCodeId: \"ac_xyz789\" → displayed as \"4100 - Revenue\"\n```\n\nThe FK target table is resolved from the `fkTarget` property on each column. For simple cases like `roomTypeId` → `roomType`, the compiler auto-detects it. For non-obvious mappings (e.g., `vendorId` → `contact`), use explicit [`references`](/compiler/definitions/schema#references) in your `defineTable()` config.\n\n## Next Steps\n\n- **[Connecting](/cms/connecting)** — Demo mode vs. live mode setup\n- **[Schema Format Reference](/cms/schema-format)** — Full TypeScript types"
102
+ "content": "# Schema Registry\n\nThe schema registry is a static JSON file generated by the Quickback compiler. It contains full metadata about every table in your project — columns, types, guards, masking rules, views, actions, validation, and firewall config. The CMS reads this file to render its entire UI.\n\n## Generation\n\nSchema registry generation is **enabled by default**. Run `quickback compile` and the compiler outputs `schema-registry.json` alongside your compiled API files.\n\nTo disable generation:\n\n```typescript title=\"quickback/quickback.config.ts\"\nexport default defineConfig({\n schemaRegistry: { generate: false },\n // ... rest of your config\n});\n```\n\n## Output Shape\n\nThe top-level structure of `schema-registry.json`:\n\n```json\n{\n \"generatedAt\": \"2026-02-14T06:26:53.554Z\",\n \"generatedBy\": \"quickback-compiler\",\n \"version\": \"1.0.0\",\n \"features\": { ... },\n \"tables\": { ... },\n \"tablesByFeature\": { ... }\n}\n```\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `generatedAt` | `string` | ISO timestamp of generation |\n| `generatedBy` | `string` | Always `\"quickback-compiler\"` |\n| `version` | `string` | Compiler version used |\n| `features` | `Record<string, string[]>` | Feature name to file list mapping |\n| `tables` | `Record<string, TableMeta>` | Table name to full metadata |\n| `tablesByFeature` | `Record<string, string[]>` | Feature name to table name list |\n\n## TableMeta\n\nEach table entry contains everything the CMS needs to render its UI:\n\n```typescript\ninterface TableMeta {\n name: string; // camelCase table name (e.g., \"accountCode\")\n dbName: string; // SQL table name (e.g., \"account_code\")\n feature: string; // Parent feature name\n columns: ColumnMeta[]; // All columns including audit fields\n firewall: Record<string, unknown>; // Tenant isolation config\n crud: Record<string, CrudConfig>; // Per-operation access rules\n guards: {\n createable: string[]; // Fields allowed on create\n updatable: string[]; // Fields allowed on update\n immutable: string[]; // Fields locked after creation\n protected: Record<string, string[]>; // Fields only updatable via actions\n };\n masking: Record<string, MaskingRule>; // Per-field masking rules\n views: Record<string, ViewConfig>; // Named column projections\n validation: Record<string, ValidationRule>; // Per-field validation\n actions: ActionMeta[]; // Available actions for this table\n displayColumn?: string; // Human-readable label column\n internal?: boolean; // Hidden from CMS sidebar when true\n}\n```\n\n## ColumnMeta\n\nEach column in the `columns` array:\n\n```typescript\ninterface ColumnMeta {\n name: string; // Property name (camelCase)\n dbName: string; // SQL column name (snake_case)\n type: \"text\" | \"integer\" | \"real\" | \"blob\"; // SQLite type\n mode?: \"boolean\"; // When an integer represents a boolean\n primaryKey: boolean;\n notNull: boolean;\n defaultValue?: string | number | boolean;\n fkTarget?: string; // Target table name for FK columns\n}\n```\n\nThe compiler automatically includes audit fields (`id`, `organizationId`, `createdAt`, `createdBy`, `modifiedAt`, `modifiedBy`, `deletedAt`) at the beginning of every table's column list.\n\n### fkTarget — FK Resolution\n\nColumns ending in `Id` may have a `fkTarget` property indicating which table they reference. This is resolved in priority order:\n\n1. **Explicit `references`** — from your `defineTable()` config (highest priority)\n2. **Drizzle `.references()`** — parsed from schema source code\n3. **Convention** — strip `Id` suffix, match table name directly\n\n```json\n{\n \"name\": \"vendorId\",\n \"type\": \"text\",\n \"fkTarget\": \"contact\"\n}\n```\n\nThe CMS uses `fkTarget` to render typeahead/lookup inputs that search the correct table instead of showing raw IDs.\n\n## Input Hints\n\nTables with `inputHints` configured in `defineTable()` include an `inputHints` map in their metadata:\n\n```json\n{\n \"name\": \"invoice\",\n \"inputHints\": {\n \"status\": \"select\",\n \"sortOrder\": \"radio\",\n \"isPartialPaymentDisabled\": \"checkbox\",\n \"headerMessage\": \"textarea\"\n }\n}\n```\n\nThe CMS reads these hints to render the appropriate form control for each field. See [Input Hints](/compiler/definitions/schema#input-hints) for the full list of supported values.\n\n## Display Column\n\nThe `displayColumn` field tells the CMS which column to use as a human-readable label for a record. This is used in:\n\n- FK typeahead dropdowns (showing names instead of IDs)\n- Record titles in detail views\n- Breadcrumb labels\n\n### Auto-Detection\n\nIf you don't explicitly set `displayColumn` in your resource config, the compiler auto-detects it by scanning column names in priority order:\n\n1. `name`\n2. `title`\n3. `label`\n4. `headline`\n5. `subject`\n6. `code`\n7. `displayName`\n8. `fullName`\n9. `description`\n\nThe first match wins. If no candidate matches, the table has no display column and the CMS falls back to showing IDs.\n\n### Explicit Config\n\nSet it explicitly in your table definition:\n\n```typescript\nexport default defineTable(contacts, {\n displayColumn: \"companyName\",\n // ...\n});\n```\n\n## FK Label Resolution\n\nWhen a table has foreign key columns (ending in `Id`), the API enriches list responses with `_label` fields. For example, a `roomTypeId` column gets a corresponding `roomType_label` field containing the display column value from the referenced table.\n\nThe CMS uses these `_label` fields to show human-readable names in table cells and FK typeahead dropdowns instead of raw UUIDs.\n\n```\nroomTypeId: \"rt_abc123\" → displayed as \"Master Bedroom\"\naccountCodeId: \"ac_xyz789\" → displayed as \"4100 - Revenue\"\n```\n\nThe FK target table is resolved from the `fkTarget` property on each column. For simple cases like `roomTypeId` → `roomType`, the compiler auto-detects it. For non-obvious mappings (e.g., `vendorId` → `contact`), use explicit [`references`](/compiler/definitions/schema#references) in your `defineTable()` config.\n\n## Next Steps\n\n- **[Connecting](/cms/connecting)** — Demo mode vs. live mode setup\n- **[Schema Format Reference](/cms/schema-format)** — Full TypeScript types"
103
103
  },
104
104
  "cms/security": {
105
105
  "title": "Security",
@@ -135,7 +135,7 @@ export const DOCS = {
135
135
  },
136
136
  "compiler/config": {
137
137
  "title": "Configuration",
138
- "content": "The `quickback.config.ts` file configures your Quickback project. It defines which providers to use, which features to enable, and how the compiler generates code.\n\n```typescript\n\nexport default defineConfig({\n name: \"my-app\",\n template: \"hono\",\n features: [\"organizations\"],\n providers: {\n runtime: defineRuntime(\"cloudflare\"),\n database: defineDatabase(\"cloudflare-d1\"),\n auth: defineAuth(\"better-auth\"),\n },\n});\n```\n\n## Required Fields\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `name` | `string` | Project name |\n| `template` | `\"hono\"` | Application template (`\"nextjs\"` is experimental) |\n| `providers` | `object` | Runtime, database, and auth provider configuration |\n\n## Optional Fields\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `features` | `string[]` | Feature flags, e.g. `[\"organizations\"]` |\n| `compiler` | `object` | Compiler options (including migration rename hints for headless CI) |\n| `openapi` | `object` | OpenAPI spec generation (`generate`, `publish`) — see [OpenAPI](/compiler/using-the-api/openapi) |\n| `schemaRegistry` | `object` | Schema registry generation for the CMS (`{ generate: true }`) — see [Schema Registry](/cms/schema-registry) |\n\n## Headless Migration Rename Hints\n\nWhen `drizzle-kit generate` detects a potential rename, it normally prompts for confirmation. Cloud/CI compiles are non-interactive, so you must provide explicit rename hints.\n\nAdd hints in `compiler.migrations.renames`:\n\n```typescript\nexport default defineConfig({\n // ...\n compiler: {\n migrations: {\n renames: {\n // Keys are NEW names, values are OLD names\n tables: {\n events_v2: \"events\",\n },\n columns: {\n events: {\n summary_text: \"summary\",\n },\n },\n },\n },\n },\n});\n```\n\nRules:\n- `tables`: `new_table_name -> old_table_name`\n- `columns.<table>`: `new_column_name -> old_column_name`\n- Keys must match the names in your current schema (the new names)\n- Validation fails fast for malformed rename config, unsupported keys, or conflicting legacy/new rename paths.\n\n## Security Contract Reports and Signing\n\nAfter generation, Quickback verifies machine-checkable security contracts for Hono routes and RLS SQL.\n\nIt also emits a report artifact and signature artifact by default:\n\n- `reports/security-contracts.report.json`\n- `reports/security-contracts.report.sig.json`\n\nYou can configure output paths and signing behavior:\n\n```typescript\nexport default defineConfig({\n // ...\n compiler: {\n securityContracts: {\n failMode: \"error\", // or \"warn\"\n report: {\n path: \"reports/security-contracts.report.json\",\n signature: {\n enabled: true,\n required: true,\n keyEnv: \"QUICKBACK_SECURITY_REPORT_SIGNING_KEY\",\n keyId: \"prod-k1\",\n path: \"reports/security-contracts.report.sig.json\",\n },\n },\n },\n },\n});\n```\n\nIf `signature.required: true` and no key is available, compilation fails with a clear error message (or warning in `failMode: \"warn\"`).\n\nSee the sub-pages for detailed reference on each section:\n- [Providers](/compiler/config/providers) — Runtime, database, and auth options\n- [Variables](/compiler/config/variables) — Environment variables and flags\n- [Output](/compiler/config/output) — Generated file structure"
138
+ "content": "The `quickback.config.ts` file configures your Quickback project. It defines which providers to use, which features to enable, and how the compiler generates code.\n\n```typescript\n\nexport default defineConfig({\n name: \"my-app\",\n template: \"hono\",\n features: [\"organizations\"],\n providers: {\n runtime: defineRuntime(\"cloudflare\"),\n database: defineDatabase(\"cloudflare-d1\"),\n auth: defineAuth(\"better-auth\"),\n },\n});\n```\n\n## Required Fields\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `name` | `string` | Project name |\n| `template` | `\"hono\"` | Application template (`\"nextjs\"` is experimental) |\n| `providers` | `object` | Runtime, database, and auth provider configuration |\n\n## Optional Fields\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `features` | `string[]` | Feature flags, e.g. `[\"organizations\"]` |\n| `compiler` | `object` | Compiler options (including migration rename hints for headless CI) |\n| `openapi` | `object` | OpenAPI spec generation (`generate`, `publish`) — see [OpenAPI](/compiler/using-the-api/openapi) |\n| `schemaRegistry` | `object` | Schema registry generation for the CMS (enabled by default, `{ generate: false }` to disable) — see [Schema Registry](/cms/schema-registry) |\n\n## Headless Migration Rename Hints\n\nWhen `drizzle-kit generate` detects a potential rename, it normally prompts for confirmation. Cloud/CI compiles are non-interactive, so you must provide explicit rename hints.\n\nAdd hints in `compiler.migrations.renames`:\n\n```typescript\nexport default defineConfig({\n // ...\n compiler: {\n migrations: {\n renames: {\n // Keys are NEW names, values are OLD names\n tables: {\n events_v2: \"events\",\n },\n columns: {\n events: {\n summary_text: \"summary\",\n },\n },\n },\n },\n },\n});\n```\n\nRules:\n- `tables`: `new_table_name -> old_table_name`\n- `columns.<table>`: `new_column_name -> old_column_name`\n- Keys must match the names in your current schema (the new names)\n- Validation fails fast for malformed rename config, unsupported keys, or conflicting legacy/new rename paths.\n\n## Security Contract Reports and Signing\n\nAfter generation, Quickback verifies machine-checkable security contracts for Hono routes and RLS SQL.\n\nIt also emits a report artifact and signature artifact by default:\n\n- `reports/security-contracts.report.json`\n- `reports/security-contracts.report.sig.json`\n\nYou can configure output paths and signing behavior:\n\n```typescript\nexport default defineConfig({\n // ...\n compiler: {\n securityContracts: {\n failMode: \"error\", // or \"warn\"\n report: {\n path: \"reports/security-contracts.report.json\",\n signature: {\n enabled: true,\n required: true,\n keyEnv: \"QUICKBACK_SECURITY_REPORT_SIGNING_KEY\",\n keyId: \"prod-k1\",\n path: \"reports/security-contracts.report.sig.json\",\n },\n },\n },\n },\n});\n```\n\nIf `signature.required: true` and no key is available, compilation fails with a clear error message (or warning in `failMode: \"warn\"`).\n\nSee the sub-pages for detailed reference on each section:\n- [Providers](/compiler/config/providers) — Runtime, database, and auth options\n- [Variables](/compiler/config/variables) — Environment variables and flags\n- [Output](/compiler/config/output) — Generated file structure"
139
139
  },
140
140
  "compiler/config/output": {
141
141
  "title": "Output Structure",
@@ -179,7 +179,7 @@ export const DOCS = {
179
179
  },
180
180
  "compiler/definitions/schema": {
181
181
  "title": "Database Schema",
182
- "content": "Quickback uses [Drizzle ORM](https://orm.drizzle.team/) to define your database schema. With `defineTable`, you combine your schema definition and security configuration in a single file.\n\n## Defining Tables with defineTable\n\nEach table gets its own file with schema and config together. Use the Drizzle dialect that matches your target database:\n\n| Target Database | Import From | Table Function |\n|-----------------|-------------|----------------|\n| Cloudflare D1, Turso, SQLite | `drizzle-orm/sqlite-core` | `sqliteTable` |\n| Supabase, Neon, PostgreSQL | `drizzle-orm/pg-core` | `pgTable` |\n| PlanetScale, MySQL | `drizzle-orm/mysql-core` | `mysqlTable` |\n\n```typescript\n// quickback/features/jobs/jobs.ts\n// For D1/SQLite targets:\n\nexport const jobs = sqliteTable('jobs', {\n id: text('id').primaryKey(),\n organizationId: text('organization_id').notNull(),\n title: text('title').notNull(),\n department: text('department').notNull(),\n status: text('status').notNull(), // \"draft\" | \"open\" | \"closed\"\n salaryMin: integer('salary_min'),\n salaryMax: integer('salary_max'),\n});\n\nexport default defineTable(jobs, {\n firewall: { organization: {} },\n guards: {\n createable: [\"title\", \"department\", \"status\", \"salaryMin\", \"salaryMax\"],\n updatable: [\"title\", \"department\", \"status\"],\n },\n crud: {\n list: { access: { roles: [\"owner\", \"hiring-manager\", \"recruiter\", \"interviewer\"] } },\n get: { access: { roles: [\"owner\", \"hiring-manager\", \"recruiter\", \"interviewer\"] } },\n create: { access: { roles: [\"owner\", \"hiring-manager\"] } },\n update: { access: { roles: [\"owner\", \"hiring-manager\"] } },\n delete: { access: { roles: [\"owner\", \"hiring-manager\"] } },\n },\n});\n\nexport type Job = typeof jobs.$inferSelect;\n```\n\n## Column Types\n\nDrizzle supports all standard SQL column types:\n\n| Type | Drizzle Function | Example |\n|------|------------------|---------|\n| String | `text()`, `varchar()` | `text('name')` |\n| Integer | `integer()`, `bigint()` | `integer('count')` |\n| Boolean | `boolean()` | `boolean('is_active')` |\n| Timestamp | `timestamp()` | `timestamp('created_at')` |\n| JSON | `json()`, `jsonb()` | `jsonb('metadata')` |\n| UUID | `uuid()` | `uuid('id')` |\n| Decimal | `decimal()`, `numeric()` | `decimal('price', { precision: 10, scale: 2 })` |\n\n## Column Modifiers\n\n```typescript\n// Required field\nname: text('name').notNull()\n\n// Default value\nisActive: boolean('is_active').default(true)\n\n// Primary key\nid: text('id').primaryKey()\n\n// Unique constraint\nemail: text('email').unique()\n\n// Default to current timestamp\ncreatedAt: timestamp('created_at').defaultNow()\n```\n\n## File Organization\n\nOrganize your tables by feature. Each feature directory contains table files:\n\n```\nquickback/\n├── quickback.config.ts\n└── features/\n ├── jobs/\n │ ├── jobs.ts # Main table + config\n │ ├── applications.ts # Related table + config\n │ └── actions.ts # Custom actions\n ├── candidates/\n │ ├── candidates.ts # Table + config\n │ └── candidate-notes.ts # Related table\n └── organizations/\n └── organizations.ts\n```\n\n**Key points:**\n- Tables with `export default defineTable(...)` get CRUD routes generated\n- Tables without a default export are internal (no routes, used for joins/relations)\n- Route paths are derived from filenames: `applications.ts` → `/api/v1/applications`\n\n### defineTable vs defineResource\n\nBoth functions are available:\n\n- **`defineTable`** - The standard function for defining tables with CRUD routes\n- **`defineResource`** - Alias for `defineTable`, useful when thinking in terms of REST resources\n\n```typescript\n// These are equivalent:\nexport default defineTable(jobs, { /* config */ });\nexport default defineResource(jobs, { /* config */ });\n```\n\n## 1 Resource = 1 Security Boundary\n\nEach `defineTable()` call defines a complete, self-contained security boundary. The security config you write — firewall, access, guards, and masking — is compiled into a single resource file that wraps all CRUD routes for that table.\n\nThis is a deliberate design choice. Mixing two resources with different security rules in one configuration would create ambiguity about which firewall, access, or masking rules apply to which table. By keeping it 1:1, there's never any question.\n\n| Scenario | What to do |\n|----------|------------|\n| Table needs its own API routes + security | Own file with `defineTable()` |\n| Table is internal/supporting (no direct API) | Extra `.ts` file in the parent feature directory, no `defineTable()` |\n\nA supporting table without `defineTable()` is useful when it's accessed internally — by action handlers, joins, or background jobs — but should never be directly exposed as its own API endpoint.\n\n## Internal Tables (No Routes)\n\nFor junction tables or internal data structures that shouldn't have API routes, simply omit the `defineTable` export:\n\n```typescript\n// quickback/features/jobs/interview-scores.ts\n\n// Internal scoring table - no routes needed\nexport const interviewScores = sqliteTable('interview_scores', {\n applicationId: text('application_id').notNull(),\n interviewerId: text('interviewer_id').notNull(),\n score: integer('score'),\n organizationId: text('organization_id').notNull(), // Always scope junction tables\n});\n\n// No default export = no CRUD routes generated\n```\n\nThese internal tables still participate in the database schema and migrations — they just don't get API routes or security configuration.\n\nThe compiler automatically injects **audit fields** (`createdAt`, `modifiedAt`, `deletedAt`, etc.) and **`organizationId`** into child/junction tables when the parent feature is org-scoped. This ensures cascade soft deletes and scoped queries work correctly without manual column definitions.\n\n## Audit Fields\n\nQuickback automatically adds and manages these audit fields on **all tables** in a feature (including child/junction tables without `defineTable`) - you don't need to define them:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `createdAt` | timestamp | Set when record is created |\n| `createdBy` | text | User ID who created the record |\n| `modifiedAt` | timestamp | Updated on every change |\n| `modifiedBy` | text | User ID who last modified |\n| `deletedAt` | timestamp | Set on soft delete (optional) |\n| `deletedBy` | text | User ID who deleted (optional) |\n\nThe soft delete fields (`deletedAt`, `deletedBy`) are only added if your resource uses soft delete mode.\n\n### Disabling Audit Fields\n\nTo disable automatic audit fields for your project:\n\n```typescript\n// quickback.config.ts\nexport default defineConfig({\n // ...\n compiler: {\n features: {\n auditFields: false, // Disable for entire project\n }\n }\n});\n```\n\n### Protected System Fields\n\nThese fields are always protected and cannot be set by clients, even with `guards: false`:\n\n- `id` (when `generateId` is not `false`)\n- `createdAt`, `createdBy`\n- `modifiedAt`, `modifiedBy`\n- `deletedAt`, `deletedBy`\n\n## Ownership Fields\n\nFor the firewall to work, include the appropriate ownership columns:\n\n```typescript\n// For organization-scoped data (most common)\norganizationId: text('organization_id').notNull()\n\n// For user-owned data (personal data)\nownerId: text('owner_id').notNull()\n\n// For team-scoped data\nteamId: text('team_id').notNull()\n```\n\n## Display Column\n\nWhen a table has foreign key columns (e.g., `departmentId` referencing `departments`), Quickback can automatically resolve the human-readable label in GET and LIST responses. This eliminates the need for frontend lookup calls.\n\n### Auto-Detection\n\nQuickback auto-detects the display column by checking for common column names in this order:\n\n`name` → `title` → `label` → `headline` → `subject` → `code` → `displayName` → `fullName` → `description`\n\nIf your `departments` table has a `name` column, it's automatically used as the display column. No config needed.\n\n### Explicit Override\n\nOverride the auto-detected column with `displayColumn`:\n\n```typescript\nexport default defineTable(interviewStages, {\n displayColumn: 'label', // Use 'label' instead of auto-detected 'name'\n firewall: { organization: {} },\n crud: {\n list: { access: { roles: [\"hiring-manager\", \"recruiter\"] } },\n get: { access: { roles: [\"hiring-manager\", \"recruiter\"] } },\n },\n});\n```\n\n### How Labels Appear in Responses\n\nFor any FK column ending in `Id`, the API adds a `_label` field with the resolved display value:\n\n```json\n{\n \"id\": \"job_abc\",\n \"title\": \"Senior Engineer\",\n \"departmentId\": \"dept_xyz\",\n \"department_label\": \"Engineering\",\n \"locationId\": \"loc_123\",\n \"location_label\": \"San Francisco\"\n}\n```\n\nThe pattern is `{columnWithoutId}_label`. The frontend can find all labels with:\n\n```typescript\nObject.keys(record).filter(k => k.endsWith('_label'))\n```\n\nLabel resolution works within the same feature (tables in the same feature directory). System columns (`organizationId`, `createdBy`, `modifiedBy`) are never resolved.\n\nFor LIST endpoints, labels are batch-resolved efficiently — one query per FK column, not per record.\n\n## References\n\nWhen your FK columns don't match the target table name by convention (e.g., `vendorId` actually points to the `contact` table), declare explicit references so the CMS and schema registry know the correct target:\n\n```typescript\nexport default defineTable(applications, {\n references: {\n candidateId: \"candidate\",\n jobId: \"job\",\n referredById: \"candidate\",\n },\n // ... firewall, guards, crud\n});\n```\n\nEach key is a column name ending in `Id`, and the value is the camelCase table name it references. These mappings flow into the schema registry as `fkTarget` on each column, enabling the CMS to render typeahead/lookup inputs that search the correct table.\n\nConvention-based matching (strip `Id` suffix, look for a matching table) still works for simple cases like `projectId` → `project`. Use `references` only for columns where the convention doesn't match.\n\n## Input Hints\n\nControl how the CMS renders form inputs for specific columns. By default the CMS infers input types from the column's SQL type — `inputHints` lets you override that:\n\n```typescript\nexport default defineTable(jobs, {\n inputHints: {\n status: \"select\",\n department: \"select\",\n salaryMin: \"currency\",\n salaryMax: \"currency\",\n description: \"textarea\",\n },\n // ... firewall, guards, crud\n});\n```\n\n### Available Hint Values\n\n| Hint | Renders As |\n|------|-----------|\n| `select` | Dropdown select (single value) |\n| `multi-select` | Multi-value select |\n| `radio` | Radio button group |\n| `checkbox` | Checkbox toggle |\n| `textarea` | Multi-line text input |\n| `lookup` | FK typeahead search |\n| `hidden` | Hidden from forms |\n| `color` | Color picker |\n| `date` | Date picker |\n| `datetime` | Date + time picker |\n| `time` | Time picker |\n| `currency` | Currency input with formatting |\n\nInput hints are emitted in the schema registry as `inputHints` on the table metadata, and the CMS reads them to render the appropriate form controls.\n\n## Relations (Optional)\n\nDrizzle supports defining relations for type-safe joins:\n\n```typescript\n\nexport const jobsRelations = relations(jobs, ({ one, many }) => ({\n organization: one(organizations, {\n fields: [jobs.organizationId],\n references: [organizations.id],\n }),\n applications: many(applications),\n}));\n```\n\n## Database Configuration\n\nConfigure database options in your Quickback config:\n\n```typescript\n// quickback.config.ts\nexport default defineConfig({\n name: 'my-app',\n providers: {\n database: defineDatabase('cloudflare-d1', {\n generateId: 'prefixed', // 'uuid' | 'cuid' | 'nanoid' | 'prefixed' | 'serial' | false\n namingConvention: 'snake_case', // 'camelCase' | 'snake_case'\n usePlurals: false, // Auth table names: 'users' vs 'user'\n }),\n },\n compiler: {\n features: {\n auditFields: true, // Auto-manage audit timestamps\n }\n }\n});\n```\n\n## Choosing Your Dialect\n\nUse the Drizzle dialect that matches your database provider:\n\n### SQLite (D1, Turso, better-sqlite3)\n\n```typescript\n\nexport const jobs = sqliteTable('jobs', {\n id: text('id').primaryKey(),\n title: text('title').notNull(),\n metadata: text('metadata', { mode: 'json' }), // JSON stored as text\n isOpen: integer('is_open', { mode: 'boolean' }).default(false),\n organizationId: text('organization_id').notNull(),\n});\n```\n\n### PostgreSQL (Supabase, Neon)\n\n```typescript\n\nexport const jobs = pgTable('jobs', {\n id: serial('id').primaryKey(),\n title: text('title').notNull(),\n metadata: jsonb('metadata'), // Native JSONB\n isOpen: boolean('is_open').default(false),\n organizationId: text('organization_id').notNull(),\n});\n```\n\n### Key Differences\n\n| Feature | SQLite | PostgreSQL |\n|---------|--------|------------|\n| Boolean | `integer({ mode: 'boolean' })` | `boolean()` |\n| JSON | `text({ mode: 'json' })` | `jsonb()` or `json()` |\n| Auto-increment | `integer().primaryKey()` | `serial()` |\n| UUID | `text()` | `uuid()` |\n\n## Next Steps\n\n- [Configure the firewall](/compiler/definitions/firewall) for data isolation\n- [Set up access control](/compiler/definitions/access) for CRUD operations\n- [Define guards](/compiler/definitions/guards) for field modification rules\n- [Add custom actions](/compiler/definitions/actions) for business logic"
182
+ "content": "Quickback uses [Drizzle ORM](https://orm.drizzle.team/) to define your database schema. With `defineTable`, you combine your schema definition and security configuration in a single file.\n\n## Defining Tables with defineTable\n\nEach table gets its own file with schema and config together. Use the Drizzle dialect that matches your target database:\n\n| Target Database | Import From | Table Function |\n|-----------------|-------------|----------------|\n| Cloudflare D1, Turso, SQLite | `drizzle-orm/sqlite-core` | `sqliteTable` |\n| Supabase, Neon, PostgreSQL | `drizzle-orm/pg-core` | `pgTable` |\n| PlanetScale, MySQL | `drizzle-orm/mysql-core` | `mysqlTable` |\n\n```typescript\n// quickback/features/jobs/jobs.ts\n// For D1/SQLite targets:\n\nexport const jobs = sqliteTable('jobs', {\n id: text('id').primaryKey(),\n organizationId: text('organization_id').notNull(),\n title: text('title').notNull(),\n department: text('department').notNull(),\n status: text('status').notNull(), // \"draft\" | \"open\" | \"closed\"\n salaryMin: integer('salary_min'),\n salaryMax: integer('salary_max'),\n});\n\nexport default defineTable(jobs, {\n firewall: { organization: {} },\n guards: {\n createable: [\"title\", \"department\", \"status\", \"salaryMin\", \"salaryMax\"],\n updatable: [\"title\", \"department\", \"status\"],\n },\n crud: {\n list: { access: { roles: [\"owner\", \"hiring-manager\", \"recruiter\", \"interviewer\"] } },\n get: { access: { roles: [\"owner\", \"hiring-manager\", \"recruiter\", \"interviewer\"] } },\n create: { access: { roles: [\"owner\", \"hiring-manager\"] } },\n update: { access: { roles: [\"owner\", \"hiring-manager\"] } },\n delete: { access: { roles: [\"owner\", \"hiring-manager\"] } },\n },\n});\n\nexport type Job = typeof jobs.$inferSelect;\n```\n\n## Column Types\n\nDrizzle supports all standard SQL column types:\n\n| Type | Drizzle Function | Example |\n|------|------------------|---------|\n| String | `text()`, `varchar()` | `text('name')` |\n| Integer | `integer()`, `bigint()` | `integer('count')` |\n| Boolean | `boolean()` | `boolean('is_active')` |\n| Timestamp | `timestamp()` | `timestamp('created_at')` |\n| JSON | `json()`, `jsonb()` | `jsonb('metadata')` |\n| UUID | `uuid()` | `uuid('id')` |\n| Decimal | `decimal()`, `numeric()` | `decimal('price', { precision: 10, scale: 2 })` |\n\n## Column Modifiers\n\n```typescript\n// Required field\nname: text('name').notNull()\n\n// Default value\nisActive: boolean('is_active').default(true)\n\n// Primary key\nid: text('id').primaryKey()\n\n// Unique constraint\nemail: text('email').unique()\n\n// Default to current timestamp\ncreatedAt: timestamp('created_at').defaultNow()\n```\n\n## File Organization\n\nOrganize your tables by feature. Each feature directory contains table files:\n\n```\nquickback/\n├── quickback.config.ts\n└── features/\n ├── jobs/\n │ ├── jobs.ts # Main table + config\n │ ├── applications.ts # Related table + config\n │ └── actions.ts # Custom actions\n ├── candidates/\n │ ├── candidates.ts # Table + config\n │ └── candidate-notes.ts # Related table\n └── organizations/\n └── organizations.ts\n```\n\n**Key points:**\n- Tables with `export default defineTable(...)` get CRUD routes generated\n- Tables without a default export are internal (no routes, used for joins/relations)\n- Route paths are derived from filenames: `applications.ts` → `/api/v1/applications`\n\n### defineTable vs defineResource\n\n- **`defineTable`** — The standard function for defining tables with CRUD routes\n- **`defineResource`** — Deprecated. Still works but emits a warning during compilation. Use `defineTable` instead.\n\n```typescript\n// Preferred:\nexport default defineTable(jobs, { /* config */ });\n```\n\n## 1 Resource = 1 Security Boundary\n\nEach `defineTable()` call defines a complete, self-contained security boundary. The security config you write — firewall, access, guards, and masking — is compiled into a single resource file that wraps all CRUD routes for that table.\n\nThis is a deliberate design choice. Mixing two resources with different security rules in one configuration would create ambiguity about which firewall, access, or masking rules apply to which table. By keeping it 1:1, there's never any question.\n\n| Scenario | What to do |\n|----------|------------|\n| Table needs its own API routes + security | Own file with `defineTable()` |\n| Table is internal/supporting (no direct API) | Extra `.ts` file in the parent feature directory, no `defineTable()` |\n\nA supporting table without `defineTable()` is useful when it's accessed internally — by action handlers, joins, or background jobs — but should never be directly exposed as its own API endpoint.\n\n## Internal Tables (No Routes)\n\nFor junction tables or internal data structures that shouldn't have API routes, simply omit the `defineTable` export:\n\n```typescript\n// quickback/features/jobs/interview-scores.ts\n\n// Internal scoring table - no routes needed\nexport const interviewScores = sqliteTable('interview_scores', {\n applicationId: text('application_id').notNull(),\n interviewerId: text('interviewer_id').notNull(),\n score: integer('score'),\n organizationId: text('organization_id').notNull(), // Always scope junction tables\n});\n\n// No default export = no CRUD routes generated\n```\n\nThese internal tables still participate in the database schema and migrations — they just don't get API routes or security configuration.\n\nThe compiler automatically injects **audit fields** (`createdAt`, `modifiedAt`, `deletedAt`, etc.) and **`organizationId`** into child/junction tables when the parent feature is org-scoped. This ensures cascade soft deletes and scoped queries work correctly without manual column definitions.\n\n## Audit Fields\n\nQuickback automatically adds and manages these audit fields on **all tables** in a feature (including child/junction tables without `defineTable`) - you don't need to define them:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `createdAt` | timestamp | Set when record is created |\n| `createdBy` | text | User ID who created the record |\n| `modifiedAt` | timestamp | Updated on every change |\n| `modifiedBy` | text | User ID who last modified |\n| `deletedAt` | timestamp | Set on soft delete (optional) |\n| `deletedBy` | text | User ID who deleted (optional) |\n\nThe soft delete fields (`deletedAt`, `deletedBy`) are only added if your resource uses soft delete mode.\n\n### Disabling Audit Fields\n\nTo disable automatic audit fields for your project:\n\n```typescript\n// quickback.config.ts\nexport default defineConfig({\n // ...\n compiler: {\n features: {\n auditFields: false, // Disable for entire project\n }\n }\n});\n```\n\n### Protected System Fields\n\nThese fields are always protected and cannot be set by clients, even with `guards: false`:\n\n- `id` (when `generateId` is not `false`)\n- `createdAt`, `createdBy`\n- `modifiedAt`, `modifiedBy`\n- `deletedAt`, `deletedBy`\n\n## Ownership Fields\n\nFor the firewall to work, include the appropriate ownership columns:\n\n```typescript\n// For organization-scoped data (most common)\norganizationId: text('organization_id').notNull()\n\n// For user-owned data (personal data)\nownerId: text('owner_id').notNull()\n\n// For team-scoped data\nteamId: text('team_id').notNull()\n```\n\n## Display Column\n\nWhen a table has foreign key columns (e.g., `departmentId` referencing `departments`), Quickback can automatically resolve the human-readable label in GET and LIST responses. This eliminates the need for frontend lookup calls.\n\n### Auto-Detection\n\nQuickback auto-detects the display column by checking for common column names in this order:\n\n`name` → `title` → `label` → `headline` → `subject` → `code` → `displayName` → `fullName` → `description`\n\nIf your `departments` table has a `name` column, it's automatically used as the display column. No config needed.\n\n### Explicit Override\n\nOverride the auto-detected column with `displayColumn`:\n\n```typescript\nexport default defineTable(interviewStages, {\n displayColumn: 'label', // Use 'label' instead of auto-detected 'name'\n firewall: { organization: {} },\n crud: {\n list: { access: { roles: [\"hiring-manager\", \"recruiter\"] } },\n get: { access: { roles: [\"hiring-manager\", \"recruiter\"] } },\n },\n});\n```\n\n### How Labels Appear in Responses\n\nFor any FK column ending in `Id`, the API adds a `_label` field with the resolved display value:\n\n```json\n{\n \"id\": \"job_abc\",\n \"title\": \"Senior Engineer\",\n \"departmentId\": \"dept_xyz\",\n \"department_label\": \"Engineering\",\n \"locationId\": \"loc_123\",\n \"location_label\": \"San Francisco\"\n}\n```\n\nThe pattern is `{columnWithoutId}_label`. The frontend can find all labels with:\n\n```typescript\nObject.keys(record).filter(k => k.endsWith('_label'))\n```\n\nLabel resolution works within the same feature (tables in the same feature directory). System columns (`organizationId`, `createdBy`, `modifiedBy`) are never resolved.\n\nFor LIST endpoints, labels are batch-resolved efficiently — one query per FK column, not per record.\n\n## References\n\nWhen your FK columns don't match the target table name by convention (e.g., `vendorId` actually points to the `contact` table), declare explicit references so the CMS and schema registry know the correct target:\n\n```typescript\nexport default defineTable(applications, {\n references: {\n candidateId: \"candidate\",\n jobId: \"job\",\n referredById: \"candidate\",\n },\n // ... firewall, guards, crud\n});\n```\n\nEach key is a column name ending in `Id`, and the value is the camelCase table name it references. These mappings flow into the schema registry as `fkTarget` on each column, enabling the CMS to render typeahead/lookup inputs that search the correct table.\n\nConvention-based matching (strip `Id` suffix, look for a matching table) still works for simple cases like `projectId` → `project`. Use `references` only for columns where the convention doesn't match.\n\n## Input Hints\n\nControl how the CMS renders form inputs for specific columns. By default the CMS infers input types from the column's SQL type — `inputHints` lets you override that:\n\n```typescript\nexport default defineTable(jobs, {\n inputHints: {\n status: \"select\",\n department: \"select\",\n salaryMin: \"currency\",\n salaryMax: \"currency\",\n description: \"textarea\",\n },\n // ... firewall, guards, crud\n});\n```\n\n### Available Hint Values\n\n| Hint | Renders As |\n|------|-----------|\n| `select` | Dropdown select (single value) |\n| `multi-select` | Multi-value select |\n| `radio` | Radio button group |\n| `checkbox` | Checkbox toggle |\n| `textarea` | Multi-line text input |\n| `lookup` | FK typeahead search |\n| `hidden` | Hidden from forms |\n| `color` | Color picker |\n| `date` | Date picker |\n| `datetime` | Date + time picker |\n| `time` | Time picker |\n| `currency` | Currency input with formatting |\n\nInput hints are emitted in the schema registry as `inputHints` on the table metadata, and the CMS reads them to render the appropriate form controls.\n\n## Relations (Optional)\n\nDrizzle supports defining relations for type-safe joins:\n\n```typescript\n\nexport const jobsRelations = relations(jobs, ({ one, many }) => ({\n organization: one(organizations, {\n fields: [jobs.organizationId],\n references: [organizations.id],\n }),\n applications: many(applications),\n}));\n```\n\n## Database Configuration\n\nConfigure database options in your Quickback config:\n\n```typescript\n// quickback.config.ts\nexport default defineConfig({\n name: 'my-app',\n providers: {\n database: defineDatabase('cloudflare-d1', {\n generateId: 'prefixed', // 'uuid' | 'cuid' | 'nanoid' | 'prefixed' | 'serial' | false\n namingConvention: 'snake_case', // 'camelCase' | 'snake_case'\n usePlurals: false, // Auth table names: 'users' vs 'user'\n }),\n },\n compiler: {\n features: {\n auditFields: true, // Auto-manage audit timestamps\n }\n }\n});\n```\n\n## Choosing Your Dialect\n\nUse the Drizzle dialect that matches your database provider:\n\n### SQLite (D1, Turso, better-sqlite3)\n\n```typescript\n\nexport const jobs = sqliteTable('jobs', {\n id: text('id').primaryKey(),\n title: text('title').notNull(),\n metadata: text('metadata', { mode: 'json' }), // JSON stored as text\n isOpen: integer('is_open', { mode: 'boolean' }).default(false),\n organizationId: text('organization_id').notNull(),\n});\n```\n\n### PostgreSQL (Supabase, Neon)\n\n```typescript\n\nexport const jobs = pgTable('jobs', {\n id: serial('id').primaryKey(),\n title: text('title').notNull(),\n metadata: jsonb('metadata'), // Native JSONB\n isOpen: boolean('is_open').default(false),\n organizationId: text('organization_id').notNull(),\n});\n```\n\n### Key Differences\n\n| Feature | SQLite | PostgreSQL |\n|---------|--------|------------|\n| Boolean | `integer({ mode: 'boolean' })` | `boolean()` |\n| JSON | `text({ mode: 'json' })` | `jsonb()` or `json()` |\n| Auto-increment | `integer().primaryKey()` | `serial()` |\n| UUID | `text()` | `uuid()` |\n\n## Next Steps\n\n- [Configure the firewall](/compiler/definitions/firewall) for data isolation\n- [Set up access control](/compiler/definitions/access) for CRUD operations\n- [Define guards](/compiler/definitions/guards) for field modification rules\n- [Add custom actions](/compiler/definitions/actions) for business logic"
183
183
  },
184
184
  "compiler/definitions/validation": {
185
185
  "title": "Validation",
@@ -190,8 +190,8 @@ export const DOCS = {
190
190
  "content": "Views implement **Column Level Security (CLS)** - controlling which columns users can access. This complements **Row Level Security (RLS)** provided by the [Firewall](/compiler/definitions/firewall), which controls which rows users can access.\n\n| Security Layer | Controls | Quickback Feature |\n|----------------|----------|-------------------|\n| Row Level Security | Which records | Firewall |\n| Column Level Security | Which fields | Views |\n\nViews provide named field projections with role-based access control. Use views to return different sets of columns to different users without duplicating CRUD endpoints.\n\n## When to Use Views\n\n| Concept | Purpose | Example |\n|---------|---------|---------|\n| CRUD list | Full records | Returns all columns |\n| Masking | Hide values | Email `j***@c******.com` |\n| Views | Exclude columns | Only `id`, `name`, `source` |\n\n- **CRUD list** returns all fields to authorized users\n- **Masking** transforms sensitive values but still includes the column\n- **Views** completely exclude columns from the response\n\n## Basic Usage\n\n```typescript\n// features/candidates/candidates.ts\n\nexport const candidates = sqliteTable('candidates', {\n id: text('id').primaryKey(),\n name: text('name').notNull(),\n email: text('email').notNull(),\n phone: text('phone'),\n source: text('source'),\n resumeUrl: text('resume_url'),\n internalNotes: text('internal_notes'),\n organizationId: text('organization_id').notNull(),\n});\n\nexport default defineTable(candidates, {\n masking: {\n email: { type: 'email', show: { roles: ['hiring-manager', 'recruiter'] } },\n phone: { type: 'phone', show: { roles: ['hiring-manager', 'recruiter'] } },\n },\n\n crud: {\n list: { access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] } },\n get: { access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] } },\n },\n\n // Views: Named field projections\n views: {\n pipeline: {\n fields: ['id', 'name', 'source'],\n access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },\n },\n full: {\n fields: ['id', 'name', 'email', 'phone', 'source', 'resumeUrl', 'internalNotes'],\n access: { roles: ['hiring-manager', 'recruiter'] },\n },\n report: {\n fields: ['id', 'name', 'source', 'createdAt'],\n access: { roles: ['owner', 'hiring-manager'] },\n },\n },\n});\n```\n\n## Generated Endpoints\n\nViews generate GET endpoints at `/{resource}/views/{viewName}`:\n\n```\nGET /api/v1/candidates # CRUD list - all fields\nGET /api/v1/candidates/views/pipeline # View - only pipeline fields\nGET /api/v1/candidates/views/full # View - all fields (hiring-manager/recruiter only)\nGET /api/v1/candidates/views/report # View - report fields\n```\n\n## Query Parameters\n\nViews support the same query parameters as the list endpoint:\n\n### Pagination\n\n| Parameter | Description | Default | Max |\n|-----------|-------------|---------|-----|\n| `limit` | Number of records to return | 50 | 100 |\n| `offset` | Number of records to skip | 0 | - |\n\n```bash\n# Get first 10 records\nGET /api/v1/candidates/views/pipeline?limit=10\n\n# Get records 11-20\nGET /api/v1/candidates/views/pipeline?limit=10&offset=10\n```\n\n### Filtering\n\n```bash\n# Filter by exact value\nGET /api/v1/candidates/views/pipeline?source=linkedin\n\n# Filter with operators\nGET /api/v1/candidates/views/pipeline?createdAt.gt=2024-01-01\n\n# Multiple filters (AND logic)\nGET /api/v1/candidates/views/pipeline?source=linkedin&name.like=Smith\n```\n\n### Sorting\n\n| Parameter | Description | Default |\n|-----------|-------------|---------|\n| `sort` | Field to sort by | `createdAt` |\n| `order` | Sort direction (`asc` or `desc`) | `desc` |\n\n```bash\nGET /api/v1/candidates/views/pipeline?sort=name&order=asc\n```\n\n## Security\n\nAll four security pillars apply to views:\n\n| Pillar | Behavior |\n|--------|----------|\n| **Firewall** | WHERE clause applied (same as list) |\n| **Access** | Per-view access control |\n| **Guards** | N/A (read-only) |\n| **Masking** | Applied to returned fields |\n\n### Firewall\n\nViews automatically apply the same firewall conditions as the list endpoint. Users only see records within their organization scope.\n\n### Access Control\n\nEach view has its own access configuration:\n\n```typescript\nviews: {\n // Pipeline view - available to all roles\n pipeline: {\n fields: ['id', 'name', 'source'],\n access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },\n },\n // Full view - recruiter and above\n full: {\n fields: ['id', 'name', 'source', 'email', 'phone', 'internalNotes'],\n access: { roles: ['hiring-manager', 'recruiter'] },\n },\n}\n```\n\n### Masking\n\nMasking rules are applied to the returned fields. If a view includes a masked field like `email`, the masking rules still apply:\n\n```typescript\nmasking: {\n email: { type: 'email', show: { roles: ['hiring-manager', 'recruiter'] } },\n},\n\nviews: {\n // Even if an interviewer accesses the 'full' view, email will be masked\n // because masking rules take precedence\n full: {\n fields: ['id', 'name', 'email'],\n access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },\n },\n}\n```\n\n### How Views and Masking Work Together\n\nViews and masking are orthogonal concerns:\n- **Views** control field selection (which columns appear)\n- **Masking** controls field transformation (how values appear based on role)\n\nExample configuration:\n\n```typescript\nmasking: {\n email: { type: 'email', show: { roles: ['hiring-manager', 'recruiter'] } },\n},\n\nviews: {\n pipeline: {\n fields: ['id', 'name'], // email NOT included\n access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },\n },\n full: {\n fields: ['id', 'name', 'email'], // email included\n access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },\n },\n}\n```\n\n| Endpoint | Role | `email` in response? | `email` value |\n|----------|------|---------------------|---------------|\n| `/views/pipeline` | interviewer | No | N/A |\n| `/views/pipeline` | recruiter | No | N/A |\n| `/views/full` | interviewer | Yes | `j***@c******.com` |\n| `/views/full` | recruiter | Yes | `jane@company.com` |\n\n## Response Format\n\nView responses include metadata about the view:\n\n```json\n{\n \"data\": [\n { \"id\": \"cnd_123\", \"name\": \"Jane Doe\", \"source\": \"linkedin\" },\n { \"id\": \"cnd_456\", \"name\": \"John Smith\", \"source\": \"referral\" }\n ],\n \"view\": \"pipeline\",\n \"fields\": [\"id\", \"name\", \"source\"],\n \"pagination\": {\n \"limit\": 50,\n \"offset\": 0,\n \"count\": 2\n }\n}\n```\n\n## Complete Example\n\n```typescript\n// features/jobs/jobs.ts\nexport default defineTable(jobs, {\n guards: {\n createable: ['title', 'department', 'status', 'salaryMin', 'salaryMax'],\n updatable: ['title', 'department', 'status'],\n },\n\n masking: {\n salaryMin: { type: 'redact', show: { roles: ['owner', 'hiring-manager'] } },\n salaryMax: { type: 'redact', show: { roles: ['owner', 'hiring-manager'] } },\n },\n\n crud: {\n list: { access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] } },\n get: { access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] } },\n create: { access: { roles: ['owner', 'hiring-manager'] } },\n update: { access: { roles: ['owner', 'hiring-manager'] } },\n delete: { access: { roles: ['owner', 'hiring-manager'] } },\n },\n\n views: {\n // Public job board view\n board: {\n fields: ['id', 'title', 'department', 'status'],\n access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },\n },\n // Internal view with salary info\n internal: {\n fields: ['id', 'title', 'department', 'status', 'salaryMin', 'salaryMax'],\n access: { roles: ['owner', 'hiring-manager'] },\n },\n // Compensation report\n compensation: {\n fields: ['id', 'title', 'department', 'salaryMin', 'salaryMax'],\n access: { roles: ['owner', 'hiring-manager'] },\n },\n },\n});\n```"
191
191
  },
192
192
  "compiler/getting-started/claude-code": {
193
- "title": "Claude Code Skill",
194
- "content": "The Quickback CLI includes a skill for [Claude Code](https://claude.com/claude-code) that gives Claude context about your project structure, definitions, and the Quickback compiler. This helps Claude write correct `defineTable()` definitions, actions, and configuration.\n\n## Installation\n\n### Install the Skill\n\n```bash\nquickback claude install\n```\n\nThis installs the Quickback skill files into your project so Claude Code can use them.\n\n**Options:**\n\n| Flag | Description |\n|------|-------------|\n| `--global` | Install to `~/.claude/` (available in all projects) |\n| `--local` | Install to `./quickback/.claude/` (project-specific, default) |\n\nAfter installing, start Claude Code in your project directory. The Quickback skill will be available automatically.\n\n## What the Skill Provides\n\nThe Quickback skill teaches Claude Code about:\n\n- **Project structure** Where definitions, features, and config files live\n- **`defineTable()` API** How to write schema + security definitions correctly\n- **`defineActions()` API** How to write custom actions with input validation\n- **Security pillars** — Firewall, Access, Guards, and Masking configuration\n- **Provider options** — Cloudflare, Bun, Turso runtime and database choices\n- **Compiler workflow** How to compile and deploy\n\n## Usage Examples\n\nWith the skill installed, you can ask Claude Code to:\n\n```\n\"Add a candidates feature with org-scoped firewall and email/phone masking for interviewers\"\n\n\"Create an action on applications called 'advance-stage' that requires hiring-manager role\"\n\n\"Add a pipeline view to the candidates feature showing only id, name, and source\"\n\n\"Switch my config from Bun to Cloudflare for production deployment\"\n```\n\nClaude will generate correct Quickback definitions that you can compile directly.\n\n## Updating\n\nTo update the skill to the latest version:\n\n```bash\nquickback claude install\n```\n\nThis overwrites the existing skill files with the latest version from the CLI.\n\n## See Also\n\n- [Claude Code Plugin](/plugins-tools/claude-code-skill) — Full plugin reference\n- [Getting Started](/compiler/getting-started) — Project setup guide"
193
+ "title": "AI Tools",
194
+ "content": "The Quickback CLI ships with built-in support for AI developer tools. One install gets you a Claude Code skill, Cursor IDE rules, and an MCP server that works with any AI tool.\n\n## MCP Server\n\nThe MCP server exposes Quickback documentation, project config, and feature definitions to any MCP-compatible AI tool — Claude Desktop, Cursor, VS Code Copilot, Windsurf, and more.\n\n### Setup\n\nAdd to your AI tool's MCP configuration:\n\n```json\n{\n \"mcpServers\": {\n \"quickback\": {\n \"command\": \"npx\",\n \"args\": [\"@kardoe/quickback\", \"mcp\"]\n }\n }\n}\n```\n\n**Where to put this:**\n\n| Tool | Config File |\n|------|-------------|\n| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) |\n| Cursor | `.cursor/mcp.json` in your project root |\n| VS Code (Copilot) | `.vscode/mcp.json` in your project root |\n\n### What the MCP Server Provides\n\n**Documentation tools:**\n\n| Tool | Description |\n|------|-------------|\n| `list_topics` | List all 100+ documentation topics |\n| `get_doc` | Get a specific doc by name (supports fuzzy matching) |\n| `search_docs` | Search documentation content by keyword |\n\n**Project-aware tools** (when running inside a Quickback project):\n\n| Tool | Description |\n|------|-------------|\n| `read_config` | Read your `quickback.config.ts` |\n| `list_features` | List all features and their table files |\n| `read_feature` | Read the source code of a specific feature |\n| `read_schema_registry` | Read the compiled `schema-registry.json` |\n\nThe MCP server also registers every documentation topic as an MCP resource at `quickback://docs/{topic}`.\n\n---\n\n## Claude Code Skill\n\nThe Claude Code skill gives Claude deep knowledge of Quickback's security layers, patterns, and APIs. It also activates a specialist agent for generating complete feature definitions.\n\n### Install\n\n```bash\nquickback claude install\n```\n\n**Options:**\n\n| Flag | Description |\n|------|-------------|\n| `--global` | Install to `~/.claude/` (available in all projects) |\n| `--local` | Install to `./quickback/.claude/` (project-specific) |\n\n### Manage\n\n```bash\nquickback claude status # Check installation\nquickback claude update # Update to latest version\nquickback claude remove # Remove the skill\n```\n\n### What You Get\n\n- **Quickback Skill** — Claude understands `defineTable()`, `defineActions()`, security pillars, provider options, and the compiler workflow\n- **Specialist Agent** — Automatically activates when creating resources, configuring security, or defining actions. Generates complete, working code in your `quickback/features/` directory.\n\n---\n\n## Cursor IDE Rules\n\nCursor rules provide Quickback context directly to Cursor's AI, activated automatically when editing `quickback/**/*.ts` files.\n\n### Install\n\n```bash\nquickback cursor install\n```\n\nThis copies `quickback.mdc` to `.cursor/rules/` in your project. Consider committing this file to your repo so your team gets the rules automatically.\n\n### Manage\n\n```bash\nquickback cursor status # Check installation\nquickback cursor update # Update to latest version\nquickback cursor remove # Remove the rules\n```\n\n---\n\n## Usage Examples\n\nWith any of these tools installed, you can ask your AI to:\n\n```\n\"Create a candidates feature with org-scoped firewall and email/phone masking\"\n\n\"Add an 'approve' action to expenses that sets status and records the approver\"\n\n\"Show me the firewall documentation\"\n\n\"What security layers does my current project have configured?\"\n```\n\n## See Also\n\n- [Claude Code Plugin](/plugins-tools/claude-code-skill) — Full plugin reference\n- [Getting Started](/compiler/getting-started) — Project setup guide"
195
195
  },
196
196
  "compiler/getting-started/full-example": {
197
197
  "title": "Complete Example",
@@ -294,8 +294,8 @@ export const DOCS = {
294
294
  "content": "`@kardoe/better-auth-upgrade-anonymous` provides a single endpoint to convert anonymous users into full authenticated users. It flips the `isAnonymous` flag, optionally updates email and name, and refreshes the session — no re-authentication required.\n\n## Installation\n\n```bash\nnpm install @kardoe/better-auth-upgrade-anonymous\n```\n\n## Configuration\n\nEnable both the `anonymous` and `upgradeAnonymous` plugins:\n\n```typescript\nauth: defineAuth(\"better-auth\", {\n plugins: [\"anonymous\", \"upgradeAnonymous\"],\n})\n```\n\nThe plugin accepts optional configuration:\n\n```typescript\n\nupgradeAnonymous({\n emailConfigured: true, // Whether email delivery is available\n requireEmailVerification: true, // Whether to require email verification\n})\n```\n\n| Option | Default | Description |\n|--------|---------|-------------|\n| `emailConfigured` | `false` | When `true` and an email is provided, `emailVerified` is set based on `requireEmailVerification` |\n| `requireEmailVerification` | `true` | When `true`, provided emails are marked unverified (requiring OTP). When `false`, emails are marked verified immediately |\n\n## Endpoint\n\n```\nPOST /auth/v1/upgrade-anonymous\n```\n\n### Flow\n\n1. Validates the user's session (returns `401` if not authenticated)\n2. Checks if the user is already upgraded (returns success immediately if so)\n3. If an email is provided, checks for duplicates (returns `400` if email is taken)\n4. Updates `isAnonymous` to `false`, plus optional `email`, `name`, and `emailVerified` fields\n5. Refreshes the session cookie with the updated user object\n6. Returns the updated user, session, and `verificationRequired` flag\n\n### Request\n\nThe body is optional. When provided, it accepts:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `email` | `string` (email) | Optional. Real email to replace the anonymous placeholder |\n| `name` | `string` | Optional. User's display name |\n\n```bash\n# Minimal — just upgrade, no email/name\ncurl -X POST https://api.example.com/auth/v1/upgrade-anonymous \\\n -H \"Cookie: better-auth.session_token=...\"\n\n# With email and name\ncurl -X POST https://api.example.com/auth/v1/upgrade-anonymous \\\n -H \"Cookie: better-auth.session_token=...\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"email\": \"jane@example.com\", \"name\": \"Jane\"}'\n```\n\n### Response\n\n```json\n{\n \"success\": true,\n \"verificationRequired\": true,\n \"user\": {\n \"id\": \"usr_abc123\",\n \"email\": \"jane@example.com\",\n \"isAnonymous\": false\n },\n \"session\": {\n \"id\": \"sess_xyz\",\n \"userId\": \"usr_abc123\"\n }\n}\n```\n\nThe `verificationRequired` field is `true` when all three conditions are met:\n- An email was provided in the request body\n- `emailConfigured` is `true` in plugin config\n- `requireEmailVerification` is `true` in plugin config\n\nWhen `verificationRequired` is `true`, the frontend should send a verification OTP and redirect the user to verify their email.\n\n## How It Works\n\nThe plugin is designed to be flexible:\n\n- **Minimal by default** — With no body, only changes `isAnonymous` from `true` to `false`\n- **Optional email/name** — Can update email and name in the same request\n- **Duplicate protection** — Rejects emails already associated with another account\n- **Verification-aware** — Returns `verificationRequired` so the frontend knows whether to trigger OTP\n- **Session preserved** — Same session ID, no re-authentication needed\n- **Idempotent** — Calling on an already-upgraded user returns success immediately\n\n## Typical Flows\n\n### Passkey Signup (no email)\n\n1. User creates anonymous session\n2. User registers a passkey\n3. App calls `POST /auth/v1/upgrade-anonymous` with no body\n4. User is now a full user with passkey auth — no email on file\n\n### Passkey Signup (with email)\n\n1. User creates anonymous session\n2. User registers a passkey\n3. User optionally enters email/name on the email collection step\n4. App calls `POST /auth/v1/upgrade-anonymous` with `{ email, name }`\n5. If `verificationRequired`: app sends OTP, user verifies, then redirects to dashboard\n6. Otherwise: user goes straight to dashboard\n\n### Email Signup\n\n1. User starts as anonymous (created via Better Auth's `anonymous` plugin)\n2. User provides email/password through your UI (via combo auth or signup)\n3. Your app calls `POST /auth/v1/upgrade-anonymous`\n4. User is now a full user with the same ID and all their data intact\n\n### Client Plugin\n\n```typescript\n\nconst authClient = createAuthClient({\n plugins: [upgradeAnonymousClient()],\n});\n\n// Upgrade the current anonymous user\nawait authClient.upgradeAnonymous();\n```\n\n## See Also\n\n- [Combo Auth Plugin](/plugins-tools/better-auth-plugins/combo-auth) — Combined magic link + OTP for collecting credentials\n- [Auth Configuration](/compiler/config/providers) — Configuring auth plugins"
295
295
  },
296
296
  "plugins-tools/claude-code-skill": {
297
- "title": "Claude Code Integration",
298
- "content": "Build Quickback apps faster with Claude Code. The Quickback skill gives Claude deep knowledge of security layers, patterns, and best practices—so you can describe what you want and let Claude write the configuration.\n\n## Installation\n\n### Option 1: Install globally (Recommended)\n\nInstall the skill once and use it across all your projects:\n\n```bash\nnpm install -g @kardoe/quickback-skill\n```\n\nThis installs to `~/.claude/skills/quickback/` and is automatically available in Claude Code.\n\n### Option 2: New Quickback project\n\nCreate a new Quickback project—the skill is included automatically:\n\n```bash\nnpx @kardoe/quickback create cloudflare my-app\ncd my-app\n```\n\n### Option 3: Manual installation\n\nDownload the skill directly:\n\n```bash\nmkdir -p ~/.claude/skills/quickback\ncurl -o ~/.claude/skills/quickback/SKILL.md \\\n https://raw.githubusercontent.com/kardoe/quickback/main/.claude/skills/quickback/SKILL.md\n```\n\n## What You Get\n\nWhen you install the Quickback skill, you get:\n\n### Quickback Skill\n\nClaude understands Quickback concepts and can answer questions about:\n\n- **Security layers** - Firewall, Access, Guards, Masking, Actions\n- **Common patterns** - Multi-tenant, owner-scoped, hierarchical access\n- **Best practices** - Field protection, role-based access, PII handling\n- **Database dialects** - SQLite/D1, PostgreSQL, MySQL syntax\n\n### Quickback Specialist Agent\n\nClaude also gets a specialized agent that activates automatically when you're:\n\n- Creating new resources with schemas and security configurations\n- Configuring security layers (Firewall, Access, Guards, Masking)\n- Defining actions for business logic\n- Debugging configuration issues\n\nThe agent generates complete, working code in your `quickback/features/` directory.\n\n## Usage\n\n### Let Claude help automatically\n\nJust describe what you need. Claude will use Quickback knowledge when relevant:\n\n```\n\"Create a tasks resource where users can only see their own tasks,\nbut admins can see all tasks in the organization\"\n```\n\n### Invoke directly\n\nUse `/quickback` to explicitly activate the skill:\n\n```\n/quickback How do I configure soft delete?\n```\n\n## Example Conversations\n\n### Building a New Resource\n\n**You:** \"I need a resource for invoices. Users should only see invoices from their organization. The status field should only be changeable through approve/reject actions. Mask the customer email for non-admins.\"\n\n**Claude:** Creates complete configuration with:\n- Firewall scoped to organization\n- Protected status field with approve/reject actions\n- Email masking with admin bypass\n- Appropriate guards for createable/updatable fields\n\n### Understanding Existing Code\n\n**You:** \"Explain what this firewall configuration does\"\n\n**Claude:** Breaks down the WHERE clauses, explains the security implications, and identifies potential issues.\n\n### Debugging Configuration\n\n**You:** \"My users can see records from other organizations. What's wrong?\"\n\n**Claude:** Analyzes your firewall setup, checks for `exception: true` or missing organization scope, and suggests fixes.\n\n## What Claude Knows\n\n### Security Layers\n\n| Layer | What Claude Helps With |\n|-------|------------------------|\n| **Firewall** | Data isolation patterns, WHERE clause generation, soft delete |\n| **Access** | Role-based permissions, record-level conditions, combining rules |\n| **Guards** | Field protection, createable vs updatable, immutable fields |\n| **Masking** | PII redaction, role-based visibility, mask types |\n| **Actions** | Custom endpoints, protected field updates, input validation |\n\n### Common Patterns\n\nClaude recognizes and can implement these patterns:\n\n- **Multi-tenant SaaS** - Organization-scoped with role hierarchy\n- **Personal data apps** - Owner-scoped resources\n- **Hierarchical access** - Admins see all, users see own\n- **Public resources** - Reference data, system tables\n- **Workflow resources** - Status fields with action-based transitions\n\n### Database Support\n\nClaude generates correct syntax for your database:\n\n```typescript\n// SQLite / Cloudflare D1\ncreatedAt: integer('created_at', { mode: 'timestamp' })\n\n// PostgreSQL / Supabase\ncreatedAt: timestamp('created_at').defaultNow()\n\n// MySQL\ncreatedAt: timestamp('created_at')\n```\n\n## Tips for Best Results\n\n**Be specific about security requirements:**\n\n```\n// Good\n\"Users can only see their own tasks. Admins can see all tasks\nin the organization. The priority field can only be set by admins.\"\n\n// Less helpful\n\"Create a tasks resource\"\n```\n\n**Describe your user types:**\n\n```\n\"We have three roles: admin (full access), manager (can approve),\nand member (can only edit their own records)\"\n```\n\n**Mention sensitive fields:**\n\n```\n\"The ssn field should be masked for everyone except HR admins\"\n```\n\n## Common Tasks\n\n### Create a complete resource\n\n```\n\"Create an employees resource for a multi-tenant HR app. Include\nfields for name, email, department, salary. Mask salary for\nnon-admins. Only HR can create/delete employees.\"\n```\n\n### Add an action to existing resource\n\n```\n\"Add an 'approve' action to the expenses resource that sets\nstatus to 'approved' and records the approver\"\n```\n\n### Configure access control\n\n```\n\"Update the projects resource so managers can edit any project\nin their organization, but members can only edit projects they created\"\n```\n\n### Set up masking\n\n```\n\"Add masking to the customers resource: email partially masked,\nphone last 4 digits only, SSN fully redacted except for finance role\"\n```\n\n## Troubleshooting\n\n### Skill not found\n\nVerify the skill is installed:\n\n```bash\nls ~/.claude/skills/quickback/SKILL.md\n```\n\nIf missing, reinstall:\n\n```bash\nnpm install -g @kardoe/quickback-skill\n```\n\n### Claude doesn't understand Quickback\n\nMake sure the skill file exists and Claude Code is restarted. You can also invoke it directly with `/quickback` to force it to load.\n\n### Generated code has errors\n\nRun `quickback compile` to validate. Share the error messages with Claude for fixes.\n\n## Updating the Skill\n\nTo get the latest version:\n\n```bash\nnpm update -g @kardoe/quickback-skill\n```\n\n## Resources\n\n- [Getting Started](/compiler/getting-started) - Get your first Quickback project running\n- [Definitions Overview](/compiler/definitions) - Understand the security layer model\n- [npm package](https://www.npmjs.com/package/@kardoe/quickback-skill) - Skill package\n\n## Feedback\n\nFound an issue with the Claude Code integration?\n\n- [GitHub Issues](https://github.com/kardoe/quickback/issues)\n- [Quickback Documentation](https://docs.quickback.dev)"
297
+ "title": "AI Developer Tools",
298
+ "content": "Build Quickback apps faster with AI. The Quickback CLI ships with an MCP server, Claude Code skill, and Cursor IDE rules so any AI tool can help you write security configurations, define features, and understand your project.\n\n## Quick Install\n\n```bash\n# Install the CLI (includes all AI tools)\nnpm install -g @kardoe/quickback\n\n# Claude Code skill\nquickback claude install\n\n# Cursor IDE rules\nquickback cursor install\n\n# MCP server (configure in your AI tool, see below)\nquickback mcp\n```\n\n## MCP Server\n\nThe MCP server makes Quickback documentation and project context available to **any** MCP-compatible AI tool.\n\n### Configuration\n\nAdd to your AI tool's MCP config:\n\n```json\n{\n \"mcpServers\": {\n \"quickback\": {\n \"command\": \"npx\",\n \"args\": [\"@kardoe/quickback\", \"mcp\"]\n }\n }\n}\n```\n\n| Tool | Config File |\n|------|-------------|\n| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) |\n| Cursor | `.cursor/mcp.json` in your project |\n| VS Code | `.vscode/mcp.json` in your project |\n\n### Available Tools\n\n| Tool | Description |\n|------|-------------|\n| `list_topics` | List all 100+ documentation topics |\n| `get_doc` | Get a specific doc (supports fuzzy matching e.g., \"firewall\") |\n| `search_docs` | Search documentation by keyword |\n| `read_config` | Read your project's `quickback.config.ts` |\n| `list_features` | List features and their table files |\n| `read_feature` | Read a specific feature's source code |\n| `read_schema_registry` | Read the compiled `schema-registry.json` |\n\nAll documentation topics are also registered as MCP resources at `quickback://docs/{topic}`.\n\n## Claude Code Skill\n\n### Installation\n\nInstall via the CLI (recommended):\n\n```bash\nquickback claude install --global # All projects (~/.claude/)\nquickback claude install --local # This project only\n```\n\nOr install the standalone npm package:\n\n```bash\nnpm install -g @kardoe/quickback-skill\n```\n\nOr when creating a new project, the skill is included automatically:\n\n```bash\nnpx @kardoe/quickback create cloudflare my-app\n```\n\n## What You Get\n\nWhen you install the Quickback skill, you get:\n\n### Quickback Skill\n\nClaude understands Quickback concepts and can answer questions about:\n\n- **Security layers** - Firewall, Access, Guards, Masking, Actions\n- **Common patterns** - Multi-tenant, owner-scoped, hierarchical access\n- **Best practices** - Field protection, role-based access, PII handling\n- **Database dialects** - SQLite/D1, PostgreSQL, MySQL syntax\n\n### Quickback Specialist Agent\n\nClaude also gets a specialized agent that activates automatically when you're:\n\n- Creating new resources with schemas and security configurations\n- Configuring security layers (Firewall, Access, Guards, Masking)\n- Defining actions for business logic\n- Debugging configuration issues\n\nThe agent generates complete, working code in your `quickback/features/` directory.\n\n## Usage\n\n### Let Claude help automatically\n\nJust describe what you need. Claude will use Quickback knowledge when relevant:\n\n```\n\"Create a tasks resource where users can only see their own tasks,\nbut admins can see all tasks in the organization\"\n```\n\n### Invoke directly\n\nUse `/quickback` to explicitly activate the skill:\n\n```\n/quickback How do I configure soft delete?\n```\n\n## Example Conversations\n\n### Building a New Resource\n\n**You:** \"I need a resource for invoices. Users should only see invoices from their organization. The status field should only be changeable through approve/reject actions. Mask the customer email for non-admins.\"\n\n**Claude:** Creates complete configuration with:\n- Firewall scoped to organization\n- Protected status field with approve/reject actions\n- Email masking with admin bypass\n- Appropriate guards for createable/updatable fields\n\n### Understanding Existing Code\n\n**You:** \"Explain what this firewall configuration does\"\n\n**Claude:** Breaks down the WHERE clauses, explains the security implications, and identifies potential issues.\n\n### Debugging Configuration\n\n**You:** \"My users can see records from other organizations. What's wrong?\"\n\n**Claude:** Analyzes your firewall setup, checks for `exception: true` or missing organization scope, and suggests fixes.\n\n## What Claude Knows\n\n### Security Layers\n\n| Layer | What Claude Helps With |\n|-------|------------------------|\n| **Firewall** | Data isolation patterns, WHERE clause generation, soft delete |\n| **Access** | Role-based permissions, record-level conditions, combining rules |\n| **Guards** | Field protection, createable vs updatable, immutable fields |\n| **Masking** | PII redaction, role-based visibility, mask types |\n| **Actions** | Custom endpoints, protected field updates, input validation |\n\n### Common Patterns\n\nClaude recognizes and can implement these patterns:\n\n- **Multi-tenant SaaS** - Organization-scoped with role hierarchy\n- **Personal data apps** - Owner-scoped resources\n- **Hierarchical access** - Admins see all, users see own\n- **Public resources** - Reference data, system tables\n- **Workflow resources** - Status fields with action-based transitions\n\n### Database Support\n\nClaude generates correct syntax for your database:\n\n```typescript\n// SQLite / Cloudflare D1\ncreatedAt: integer('created_at', { mode: 'timestamp' })\n\n// PostgreSQL / Supabase\ncreatedAt: timestamp('created_at').defaultNow()\n\n// MySQL\ncreatedAt: timestamp('created_at')\n```\n\n## Tips for Best Results\n\n**Be specific about security requirements:**\n\n```\n// Good\n\"Users can only see their own tasks. Admins can see all tasks\nin the organization. The priority field can only be set by admins.\"\n\n// Less helpful\n\"Create a tasks resource\"\n```\n\n**Describe your user types:**\n\n```\n\"We have three roles: admin (full access), manager (can approve),\nand member (can only edit their own records)\"\n```\n\n**Mention sensitive fields:**\n\n```\n\"The ssn field should be masked for everyone except HR admins\"\n```\n\n## Common Tasks\n\n### Create a complete resource\n\n```\n\"Create an employees resource for a multi-tenant HR app. Include\nfields for name, email, department, salary. Mask salary for\nnon-admins. Only HR can create/delete employees.\"\n```\n\n### Add an action to existing resource\n\n```\n\"Add an 'approve' action to the expenses resource that sets\nstatus to 'approved' and records the approver\"\n```\n\n### Configure access control\n\n```\n\"Update the projects resource so managers can edit any project\nin their organization, but members can only edit projects they created\"\n```\n\n### Set up masking\n\n```\n\"Add masking to the customers resource: email partially masked,\nphone last 4 digits only, SSN fully redacted except for finance role\"\n```\n\n## Troubleshooting\n\n### Skill not found\n\nVerify the skill is installed:\n\n```bash\nls ~/.claude/skills/quickback/SKILL.md\n```\n\nIf missing, reinstall:\n\n```bash\nnpm install -g @kardoe/quickback-skill\n```\n\n### Claude doesn't understand Quickback\n\nMake sure the skill file exists and Claude Code is restarted. You can also invoke it directly with `/quickback` to force it to load.\n\n### Generated code has errors\n\nRun `quickback compile` to validate. Share the error messages with Claude for fixes.\n\n## Cursor IDE Rules\n\nCursor rules provide Quickback context when editing `quickback/**/*.ts` files.\n\n### Installation\n\n```bash\nquickback cursor install\n```\n\nThis installs `quickback.mdc` to `.cursor/rules/` in your project. Commit this file so your team gets the rules automatically.\n\n### Management\n\n```bash\nquickback cursor status # Check installation\nquickback cursor update # Update to latest version\nquickback cursor remove # Remove the rules\n```\n\n## Updating\n\nTo update all AI tools to the latest version:\n\n```bash\nnpm update -g @kardoe/quickback\nquickback claude update # Update Claude skill\nquickback cursor update # Update Cursor rules\n```\n\nThe MCP server always uses the latest installed version automatically.\n\n## Resources\n\n- [Getting Started with AI Tools](/compiler/getting-started/claude-code) - Setup guide\n- [Getting Started](/compiler/getting-started) - Get your first Quickback project running\n- [Definitions Overview](/compiler/definitions) - Understand the security layer model\n- [npm package](https://www.npmjs.com/package/@kardoe/quickback) - CLI package\n\n## Feedback\n\nFound an issue with the AI tools integration?\n\n- [GitHub Issues](https://github.com/kardoe/quickback/issues)\n- [Quickback Documentation](https://docs.quickback.dev)"
299
299
  },
300
300
  "plugins-tools": {
301
301
  "title": "Plugins & Tools",
@@ -313,6 +313,10 @@ export const DOCS = {
313
313
  "title": "Auth",
314
314
  "content": "Quickback Stack uses [Better Auth](https://www.better-auth.com/) for authentication, running on Cloudflare Workers with D1 as the session store.\n\n## Overview\n\nBetter Auth provides:\n- Email/password authentication\n- Session management with cookies\n- Multi-tenant organizations with roles\n- Plugin ecosystem for passwordless auth, passkeys, and more\n\n## Configuration\n\nAuth is configured in your `quickback.config.ts`:\n\n```typescript\n\nexport default defineConfig({\n name: \"my-app\",\n providers: {\n runtime: defineRuntime(\"cloudflare\"),\n database: defineDatabase(\"cloudflare-d1\"),\n auth: defineAuth(\"better-auth\", {\n emailAndPassword: { enabled: true },\n plugins: [\"emailOtp\", \"passkey\", \"magicLink\"],\n }),\n },\n});\n```\n\n## Auth Base Path\n\nAll Better Auth routes are served under:\n\n```\n/auth/v1/*\n```\n\nCommon endpoints:\n- `POST /auth/v1/sign-in/email` — Email/password sign in\n- `POST /auth/v1/sign-up/email` — Create account\n- `GET /auth/v1/get-session` — Get current session\n- `POST /auth/v1/sign-out` — Sign out\n\n## Organization Roles\n\n| Role | Description |\n|------|-------------|\n| `owner` | Full access — can delete the organization and transfer ownership |\n| `admin` | Full access — can manage members and resources, cannot delete the organization |\n| `member` | Standard access — read and limited write, cannot delete or manage members |\n\nThese are Better Auth's built-in organization roles — no configuration needed. The `creatorRole` defaults to `owner`.\n\n> **Tip:** Account UI's role picker uses these exact three roles. Use `[\"owner\", \"admin\", \"member\"]` in your [Access](/compiler/definitions/access) rules so generated projects plug into Better Auth and Account UI seamlessly.\n\nRoles are used throughout the security layers — in [Access](/compiler/definitions/access) rules, [Firewall](/compiler/definitions/firewall) owner checks, and RLS policies.\n\n## Next Steps\n\n- [Plugins](/stack/auth/plugins) — Email OTP, passkeys, magic links, and more\n- [Security](/stack/auth/security) — Cookies, rate limiting, cross-domain auth"
315
315
  },
316
+ "stack/auth/jwt-optimization": {
317
+ "title": "JWT Optimization",
318
+ "content": "Quickback automatically optimizes authenticated API requests using JWT tokens. After the first request (which uses session cookies), subsequent requests skip the database entirely by sending a signed JWT that encodes the full auth context.\n\n## How It Works\n\n### Request Flow\n\n```\nFirst request (no JWT cached):\n Browser → Cookie auth → 2 DB queries → Response + set-auth-token header\n Browser caches JWT in localStorage\n\nSubsequent requests (JWT cached):\n Browser → Bearer JWT → Signature check only → Response (0 DB queries)\n```\n\n### What's in the JWT\n\nThe JWT encodes everything needed for auth middleware:\n\n| Claim | Description |\n|-------|-------------|\n| `sub` | User ID |\n| `orgId` | Active organization ID |\n| `role` | Organization membership role (`owner`, `admin`, `member`) |\n| `userRole` | Global user role (when admin panel is enabled) |\n| `email` | User email |\n| `name` | User display name |\n| `iat` | Issued-at timestamp |\n| `exp` | Expiry (15 minutes from issue) |\n\n### Signing\n\nJWTs are signed with HMAC-SHA256 using your `BETTER_AUTH_SECRET`. No additional configuration is needed — this uses the same secret you already set for Better Auth.\n\n---\n\n## Token Lifecycle\n\n### Automatic Minting\n\nWhen a request arrives without a JWT (or with an expired one), the auth middleware:\n\n1. Authenticates via session cookie (existing Better Auth flow)\n2. Signs a JWT with the full auth context\n3. Returns it in the `set-auth-token` response header\n\nThe Quickback API client (`quickback-client.ts`) automatically captures this header and stores the JWT in `localStorage`.\n\n### Re-minting on Org Switch\n\nWhen a user switches organizations, the JWT must be re-minted because `orgId` and `role` change. The auth UI handles this automatically:\n\n```typescript\n// This happens automatically in the org switcher\nawait authClient.organization.setActive({ organizationSlug: slug });\n\n// Fetch fresh JWT with new org context\nconst res = await fetch('/api/v1/token', {\n method: 'POST',\n credentials: 'include',\n});\nconst { token } = await res.json();\nlocalStorage.setItem('bearer_token', token);\n```\n\n### Invalidation on Role Changes\n\nWhen an admin changes a user's role or removes them from an organization, the server broadcasts an `auth:token-invalidated` event via WebSocket. The client automatically clears the cached JWT, forcing the next request to fall back to session auth and mint a fresh token.\n\n```\nAdmin changes user role\n → databaseHooks fires on member update\n → Broadcasts auth:token-invalidated via realtime\n → Client clears localStorage JWT\n → Next request uses session cookie → gets fresh JWT\n```\n\n### Expiry\n\nJWTs expire after 15 minutes. This is a safety net — in practice, JWTs are re-minted well before expiry through the `set-auth-token` response header on session-fallback requests.\n\nWhen a JWT expires or is invalid:\n1. The middleware silently falls back to session cookie auth\n2. A fresh JWT is minted and returned in the response header\n3. The client stores the new JWT for subsequent requests\n\n---\n\n## Token Endpoint\n\n`POST /api/v1/token` mints a fresh JWT from the current session. This endpoint goes through normal auth middleware, so the user must have a valid session cookie.\n\n**Request:**\n```bash\ncurl -X POST https://your-api.example.com/api/v1/token \\\n -H \"Content-Type: application/json\" \\\n --cookie \"better-auth.session_token=...\"\n```\n\n**Response:**\n```json\n{\n \"token\": \"eyJhbGciOiJIUzI1NiJ9...\"\n}\n```\n\nUse this endpoint after operations that change auth context (like switching organizations) to get a JWT that reflects the new state.\n\n---\n\n## Client Integration\n\n### Quickback API Client\n\nThe `quickback-client.ts` API client handles JWT auth automatically:\n\n- **Sends** the cached JWT as `Authorization: Bearer <token>` on every request\n- **Captures** refreshed JWTs from `set-auth-token` response headers\n- **Clears** the JWT on `401` responses (falls back to session cookie)\n\nNo additional client configuration is needed.\n\n### Custom API Calls\n\nIf you make direct `fetch()` calls to your API (outside the Quickback client), include the JWT:\n\n```typescript\nconst jwt = localStorage.getItem('bearer_token');\n\nconst res = await fetch('https://your-api.example.com/api/v1/things', {\n headers: {\n 'Authorization': `Bearer ${jwt}`,\n 'Content-Type': 'application/json',\n },\n credentials: 'include', // Fallback to session cookie if no JWT\n});\n\n// Capture refreshed token\nconst newToken = res.headers.get('set-auth-token');\nif (newToken) {\n localStorage.setItem('bearer_token', newToken);\n}\n```\n\n### Sign Out\n\nClear the JWT on sign-out to prevent stale tokens:\n\n```typescript\nlocalStorage.removeItem('bearer_token');\nawait authClient.signOut();\n```\n\n---\n\n## Security\n\n### Threat Model\n\n| Threat | Mitigation |\n|--------|------------|\n| Token theft (XSS) | 15-minute expiry limits exposure window. `httpOnly` cookies protect the session. |\n| Token replay | Short expiry + `set-auth-token` rotation on session fallback |\n| Stale permissions | WebSocket `auth:token-invalidated` broadcast on role changes |\n| Token forgery | HMAC-SHA256 signature verified with `BETTER_AUTH_SECRET` |\n| Timing attacks | `crypto.subtle.verify()` provides constant-time comparison |\n\n### Key Points\n\n- JWTs are a **performance optimization**, not a replacement for session auth\n- Session cookies remain the source of truth — JWTs are derived from them\n- If a JWT is lost, stolen, or cleared, the system gracefully falls back to cookie auth\n- No additional secrets or configuration needed — uses your existing `BETTER_AUTH_SECRET`\n- The JWT contains no sensitive data beyond what's already in the auth context\n\n---\n\n## Files Worker\n\nThe files worker (for Cloudflare R2 file uploads) also supports JWT authentication. When a request includes a JWT `Authorization` header, the worker verifies it directly instead of calling back to the auth API — eliminating a network round-trip for file operations.\n\n---\n\n## Related\n\n- [Auth Overview](/stack/auth) — Setup and configuration\n- [Auth Security](/stack/auth/security) — Cookie security, rate limiting, CORS\n- [API Keys](/stack/auth/api-keys) — Programmatic API access"
319
+ },
316
320
  "stack/auth/plugins": {
317
321
  "title": "Auth Plugins",
318
322
  "content": "Quickback ships with a curated set of Better Auth plugins and helper wiring so your auth stack is production-ready by default. Enable these in `quickback.config.ts` under `providers.auth`.\n\n## Available Plugins\n\n### Email OTP (with AWS SES)\n\nQuickback wires the Better Auth Email OTP flow to AWS SES. When `emailOtp` is enabled, the compiler emits the SES plugin and combo-auth email flow (magic link + OTP).\n\n**Enable in config:**\n\n```ts\n\nexport default defineConfig({\n name: \"quickback-api\",\n providers: {\n runtime: defineRuntime(\"cloudflare\"),\n database: defineDatabase(\"cloudflare-d1\", {\n vars: {\n AWS_SES_REGION: \"us-east-2\",\n EMAIL_FROM: \"noreply@yourdomain.com\",\n EMAIL_FROM_NAME: \"Your App | Account Services\",\n APP_NAME: \"Your App\",\n APP_URL: \"https://account.yourdomain.com\",\n BETTER_AUTH_URL: \"https://api.yourdomain.com\",\n },\n }),\n auth: defineAuth(\"better-auth\", {\n emailAndPassword: { enabled: true },\n plugins: [\"emailOtp\"],\n }),\n },\n});\n```\n\n**Endpoints:**\n- `POST /auth/v1/email-otp/send-verification-otp`\n- `POST /auth/v1/email-otp/verify-otp`\n\n**Email readiness check:**\n\nUse this to show UI warnings when SES isn't configured.\n\n- `GET /api/v1/system/email-status`\n- Response: `{ \"emailConfigured\": true|false }`\n\n### Upgrade Anonymous\n\nAdds a first-class endpoint to convert an anonymous user into a full user. This flips `isAnonymous` to `false` and refreshes the session cache immediately.\n\n**Enable in config:**\n\n```ts\nauth: defineAuth(\"better-auth\", {\n emailAndPassword: { enabled: true },\n plugins: [\"anonymous\", \"upgradeAnonymous\"],\n}),\n```\n\n**Endpoint:**\n- `POST /auth/v1/upgrade-anonymous`\n\n**Note:** If you send `Content-Type: application/json`, include a body (an empty `{}` is fine). This avoids request parsing errors in some clients.\n\n### AWS SES Plugin\n\nThis is a Quickback-provided Better Auth plugin used by Email OTP. It handles SES signing and delivery and is auto-included when `emailOtp` is enabled.\n\nRequired vars (use Wrangler secrets for credentials):\n- `AWS_ACCESS_KEY_ID` (secret)\n- `AWS_SECRET_ACCESS_KEY` (secret)\n- `AWS_SES_REGION`\n- `EMAIL_FROM`\n\nOptional vars:\n- `EMAIL_FROM_NAME`\n- `EMAIL_REPLY_TO` - Reply-to address for emails (defaults to `EMAIL_FROM`)\n- `APP_NAME`\n- `APP_URL`\n- `BETTER_AUTH_URL`\n\n### Magic Links\n\nMagic links provide passwordless authentication by sending a unique login link to the user's email. When clicked, the link authenticates the user without requiring a password.\n\n**Enable in config:**\n\n```ts\nauth: defineAuth(\"better-auth\", {\n plugins: [\"magicLink\"],\n}),\n```\n\n**Endpoints:**\n- `POST /auth/v1/magic-link/send` - Send magic link email\n- `GET /auth/v1/magic-link/verify` - Verify magic link token\n\n**Email customization:**\n\nMagic link emails use the same AWS SES configuration as Email OTP. Customize the email template via environment variables:\n\n- `APP_NAME` - Application name in email subject\n- `APP_URL` - Base URL for magic link redirect\n- `EMAIL_FROM` - Sender email address\n- `EMAIL_FROM_NAME` - Sender display name\n\n**Frontend integration:**\n\n```ts\n// Request magic link\nawait fetch('/auth/v1/magic-link/send', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email: 'user@yourdomain.com' })\n});\n\n// User clicks link in email, which redirects to your app\n// The token is verified automatically via the callback URL\n```\n\n### Passkeys\n\nPasskeys provide passwordless authentication using WebAuthn/FIDO2. Users authenticate with biometrics (fingerprint, face) or device PIN instead of passwords.\n\n**Enable in config:**\n\n```ts\nauth: defineAuth(\"better-auth\", {\n plugins: [\"passkey\"],\n}),\n```\n\n**Required environment variable:**\n- `ACCOUNT_URL` - Your account/frontend URL (used as the relying party origin for WebAuthn)\n\n**Endpoints:**\n- `POST /auth/v1/passkey/register/options` - Get registration challenge\n- `POST /auth/v1/passkey/register/verify` - Complete registration\n- `POST /auth/v1/passkey/authenticate/options` - Get authentication challenge\n- `POST /auth/v1/passkey/authenticate/verify` - Complete authentication\n- `GET /auth/v1/passkey/list-user-passkeys` - List user's registered passkeys\n- `POST /auth/v1/passkey/delete-passkey` - Delete a registered passkey\n\n**Browser support:**\n\nPasskeys are supported in all modern browsers:\n- Chrome 67+\n- Safari 14+\n- Firefox 60+\n- Edge 79+\n\n**Registration flow:**\n\n```ts\n// 1. Get registration options\nconst optionsRes = await fetch('/auth/v1/passkey/register/options', {\n method: 'POST',\n credentials: 'include'\n});\nconst options = await optionsRes.json();\n\n// 2. Create credential using WebAuthn API\nconst credential = await navigator.credentials.create({\n publicKey: options\n});\n\n// 3. Send credential to server\nawait fetch('/auth/v1/passkey/register/verify', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n credentials: 'include',\n body: JSON.stringify(credential)\n});\n```\n\n**Authentication flow:**\n\n```ts\n// 1. Get authentication options\nconst optionsRes = await fetch('/auth/v1/passkey/authenticate/options', {\n method: 'POST'\n});\nconst options = await optionsRes.json();\n\n// 2. Get credential using WebAuthn API\nconst credential = await navigator.credentials.get({\n publicKey: options\n});\n\n// 3. Verify credential\nawait fetch('/auth/v1/passkey/authenticate/verify', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n credentials: 'include',\n body: JSON.stringify(credential)\n});\n```\n\n## Where This Runs\n\nAll Better Auth plugin routes are served under your auth base path:\n\n```\n/auth/v1/*\n```"
@@ -359,15 +363,15 @@ export const DOCS = {
359
363
  },
360
364
  "stack/realtime/durable-objects": {
361
365
  "title": "Realtime",
362
- "content": "Quickback can generate realtime notification helpers for broadcasting changes to connected clients. This enables live updates via WebSocket using Cloudflare Durable Objects.\n\n## Enabling Realtime\n\nAdd `realtime` configuration to individual table definitions:\n\n```typescript\n// features/applications/applications.ts\n\nexport const applications = sqliteTable(\"applications\", {\n id: text(\"id\").primaryKey(),\n candidateId: text(\"candidate_id\").notNull(),\n jobId: text(\"job_id\").notNull(),\n stage: text(\"stage\").notNull(),\n organizationId: text(\"organization_id\").notNull(),\n});\n\nexport default defineTable(applications, {\n firewall: { organization: {} },\n realtime: {\n enabled: true, // Enable realtime for this table\n onInsert: true, // Broadcast on INSERT (default: true)\n onUpdate: true, // Broadcast on UPDATE (default: true)\n onDelete: true, // Broadcast on DELETE (default: true)\n requiredRoles: [\"recruiter\", \"hiring-manager\"], // Who receives broadcasts\n fields: [\"id\", \"candidateId\", \"stage\"], // Fields to include (optional)\n },\n // ... guards, crud\n});\n```\n\nWhen any table has realtime enabled, the compiler generates `src/lib/realtime.ts` with helper functions for sending notifications.\n\n## Generated Helper\n\nThe `createRealtime()` factory provides convenient methods for both Postgres Changes (CRUD events) and custom broadcasts:\n\n```typescript\n\nexport const execute: ActionExecutor = async ({ db, ctx, input }) => {\n const realtime = createRealtime(ctx.env);\n\n // After creating a record\n await realtime.insert('applications', newApplication, ctx.activeOrgId!);\n\n // After updating a record\n await realtime.update('applications', newApplication, oldApplication, ctx.activeOrgId!);\n\n // After deleting a record\n await realtime.delete('applications', { id: applicationId }, ctx.activeOrgId!);\n\n return { success: true };\n};\n```\n\n## Event Format\n\nQuickback broadcasts events in a structured JSON format for easy client-side handling.\n\n### Insert Event\n\n```typescript\nawait realtime.insert('applications', {\n id: 'app_123',\n candidateId: 'cnd_456',\n stage: 'applied'\n}, ctx.activeOrgId!);\n```\n\nBroadcasts:\n```json\n{\n \"type\": \"postgres_changes\",\n \"table\": \"applications\",\n \"eventType\": \"INSERT\",\n \"schema\": \"public\",\n \"new\": {\n \"id\": \"app_123\",\n \"candidateId\": \"cnd_456\",\n \"stage\": \"applied\"\n },\n \"old\": null\n}\n```\n\n### Update Event\n\n```typescript\nawait realtime.update('applications',\n { id: 'app_123', stage: 'interview' }, // new\n { id: 'app_123', stage: 'screening' }, // old\n ctx.activeOrgId!\n);\n```\n\nBroadcasts:\n```json\n{\n \"type\": \"postgres_changes\",\n \"table\": \"applications\",\n \"eventType\": \"UPDATE\",\n \"schema\": \"public\",\n \"new\": { \"id\": \"app_123\", \"stage\": \"interview\" },\n \"old\": { \"id\": \"app_123\", \"stage\": \"screening\" }\n}\n```\n\n### Delete Event\n\n```typescript\nawait realtime.delete('applications',\n { id: 'app_123' },\n ctx.activeOrgId!\n);\n```\n\nBroadcasts:\n```json\n{\n \"type\": \"postgres_changes\",\n \"table\": \"applications\",\n \"eventType\": \"DELETE\",\n \"schema\": \"public\",\n \"new\": null,\n \"old\": { \"id\": \"app_123\" }\n}\n```\n\n## Custom Broadcasts\n\nFor arbitrary events that don't map to CRUD operations:\n\n```typescript\nawait realtime.broadcast('screening-complete', {\n applicationId: 'app_123',\n candidateId: 'cnd_456',\n stage: 'interview'\n}, ctx.activeOrgId!);\n```\n\nBroadcasts:\n```json\n{\n \"type\": \"broadcast\",\n \"event\": \"screening-complete\",\n \"payload\": {\n \"applicationId\": \"app_123\",\n \"candidateId\": \"cnd_456\",\n \"stage\": \"interview\"\n }\n}\n```\n\n## User-Specific Broadcasts\n\nTarget a specific user instead of the entire organization:\n\n```typescript\n// Only this user receives the notification\nawait realtime.insert('notifications', newNotification, ctx.activeOrgId!, {\n userId: ctx.userId\n});\n\n// Notify a hiring manager of a new application\nawait realtime.broadcast('application-received', {\n applicationId: 'app_123',\n jobId: 'job_789'\n}, ctx.activeOrgId!, {\n userId: hiringManagerId\n});\n```\n\n## Role-Based Filtering\n\nLimit which roles receive a broadcast using `targetRoles`:\n\n```typescript\n// Only hiring managers and recruiters receive this broadcast\nawait realtime.insert('applications', application, ctx.activeOrgId!, {\n targetRoles: ['hiring-manager', 'recruiter']\n});\n\n// Interviewers only see applications assigned to them\nawait realtime.update('applications', newRecord, oldRecord, ctx.activeOrgId!, {\n targetRoles: ['hiring-manager']\n});\n```\n\nIf `targetRoles` is not specified, all authenticated users in the organization receive the broadcast.\n\n## Per-Role Field Masking\n\nApply different field masking based on the subscriber's role using `maskingConfig`:\n\n```typescript\nawait realtime.insert('candidates', newCandidate, ctx.activeOrgId!, {\n maskingConfig: {\n phone: { type: 'phone', show: { roles: ['owner', 'hiring-manager', 'recruiter'] } },\n email: { type: 'email', show: { roles: ['owner', 'hiring-manager', 'recruiter'] } },\n resumeUrl: { type: 'redact', show: { roles: ['owner', 'hiring-manager', 'recruiter'] } },\n },\n});\n```\n\n**How masking works:**\n- Each subscriber receives a payload masked according to their role\n- Roles in the `show.roles` array see unmasked values\n- All other roles see the masked version\n- Masking is pre-computed per-role (O(roles) not O(subscribers))\n\n**Available mask types:**\n| Type | Example Output |\n|------|----------------|\n| `email` | `j***@y*********.com` |\n| `phone` | `******4567` |\n| `ssn` | `*****6789` |\n| `creditCard` | `**** **** **** 1111` |\n| `name` | `J***` |\n| `redact` | `[REDACTED]` |\n\n### Owner-Based Masking\n\nShow unmasked data to the record owner:\n\n```typescript\nmaskingConfig: {\n phone: {\n type: 'phone',\n show: { roles: ['hiring-manager'], or: 'owner' } // Hiring manager OR owner sees unmasked\n },\n}\n```\n\n## Client-Side Subscription\n\n### WebSocket Authentication\n\nThe Broadcaster supports two authentication methods:\n\n**Session Token (Browser/App):**\n```typescript\nconst ws = new WebSocket('wss://api.yourdomain.com/realtime/v1/websocket');\n\nws.onopen = () => {\n ws.send(JSON.stringify({\n type: 'auth',\n token: sessionToken, // JWT from Better Auth session\n organizationId: activeOrgId,\n }));\n};\n\nws.onmessage = (e) => {\n const msg = JSON.parse(e.data);\n if (msg.type === 'auth_success') {\n console.log('Authenticated:', {\n role: msg.role,\n roles: msg.roles,\n authMethod: msg.authMethod, // 'session'\n });\n }\n};\n```\n\n**API Key (Server/CLI):**\n```typescript\nconst ws = new WebSocket('wss://api.yourdomain.com/realtime/v1/websocket');\n\nws.onopen = () => {\n ws.send(JSON.stringify({\n type: 'auth',\n token: apiKey, // API key for machine-to-machine auth\n organizationId: orgId,\n }));\n};\n\n// authMethod will be 'api_key' on success\n```\n\n### Handling Messages\n\n```typescript\nws.onmessage = (event) => {\n const msg = JSON.parse(event.data);\n\n // Handle CRUD events\n if (msg.type === 'postgres_changes') {\n const { table, eventType, new: newRecord, old: oldRecord } = msg;\n\n if (eventType === 'INSERT') {\n addRecord(table, newRecord);\n } else if (eventType === 'UPDATE') {\n updateRecord(table, newRecord);\n } else if (eventType === 'DELETE') {\n removeRecord(table, oldRecord.id);\n }\n }\n\n // Handle custom broadcasts\n if (msg.type === 'broadcast') {\n const { event, payload } = msg;\n\n if (event === 'screening-complete') {\n refreshApplication(payload.applicationId);\n } else if (event === 'interview-scheduled') {\n showInterviewNotification(payload.applicationId);\n }\n }\n};\n```\n\n## Required Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `REALTIME_URL` | URL of the broadcast/realtime worker |\n| `ACCESS_TOKEN` | Internal service-to-service auth token |\n\nExample configuration:\n\n```toml\n# wrangler.toml\n[vars]\nREALTIME_URL = \"https://your-realtime-worker.workers.dev\"\nACCESS_TOKEN = \"your-internal-secret-token\"\n```\n\n## Architecture\n\n```\n┌─────────────┐ POST /broadcast ┌──────────────────┐\n│ API Worker │ ───────────────────────► │ Realtime Worker │\n│ (Quickback)│ │ (Durable Object)│\n└─────────────┘ └────────┬─────────┘\n │\n WebSocket│\n │\n ┌────────▼─────────┐\n │ Browser Clients │\n │ (WebSocket) │\n └──────────────────┘\n```\n\n1. **API Worker** - Your Quickback-generated API. Calls `realtime.insert()` etc. after CRUD operations.\n2. **Realtime Worker** - Separate worker with Durable Object for managing WebSocket connections.\n3. **Browser Clients** - Connect via WebSocket, subscribe to channels.\n\n## Best Practices\n\n### Broadcast After Commit\n\nAlways broadcast after the database operation succeeds:\n\n```typescript\n// Good - broadcast after successful insert\nconst [newApp] = await db.insert(applications).values(data).returning();\nawait realtime.insert('applications', newApp, ctx.activeOrgId!);\n\n// Bad - don't broadcast before confirming success\nawait realtime.insert('applications', data, ctx.activeOrgId!);\nawait db.insert(applications).values(data); // Could fail!\n```\n\n### Minimal Payloads\n\nOnly include necessary data in broadcasts:\n\n```typescript\n// Good - minimal payload\nawait realtime.update('applications',\n { id: record.id, stage: 'interview' },\n { id: record.id, stage: 'screening' },\n ctx.activeOrgId!\n);\n\n// Avoid - sending entire record with large content\nawait realtime.update('applications', fullRecord, oldRecord, ctx.activeOrgId!);\n```\n\n### Use Broadcasts for Complex Events\n\nFor events that don't map cleanly to CRUD:\n\n```typescript\n// Hiring pipeline stage completed\nawait realtime.broadcast('pipeline-stage-complete', {\n applicationId: application.id,\n stages: ['applied', 'screening', 'interview'],\n currentStage: 'offer',\n candidateId: application.candidateId\n}, ctx.activeOrgId!);\n```\n\n## Custom Event Namespaces\n\nFor complex applications with many custom events, you can define typed event namespaces using `defineRealtime`. This generates strongly-typed helper methods for your custom events.\n\n### Defining Event Namespaces\n\nCreate a file in `services/realtime/`:\n\n```typescript\n// services/realtime/screening.ts\n\nexport default defineRealtime({\n name: 'screening',\n events: ['started', 'progress', 'completed', 'failed'],\n description: 'Candidate screening pipeline events',\n});\n```\n\n### Generated Helper Methods\n\nAfter compilation, the `createRealtime()` helper includes your custom namespace:\n\n```typescript\n\nexport const execute: ActionExecutor = async ({ ctx, input }) => {\n const realtime = createRealtime(ctx.env);\n\n // Type-safe custom event methods\n await realtime.screening.started({\n applicationId: input.applicationId,\n }, ctx.activeOrgId!);\n\n // Progress updates\n await realtime.screening.progress({\n applicationId: input.applicationId,\n percent: 50,\n step: 'background-check',\n }, ctx.activeOrgId!);\n\n // Completion\n await realtime.screening.completed({\n applicationId: input.applicationId,\n result: 'passed',\n duration: 1234,\n }, ctx.activeOrgId!);\n\n return { success: true };\n};\n```\n\n### Event Format\n\nCustom namespace events use the `broadcast` type with namespaced event names:\n\n```json\n{\n \"type\": \"broadcast\",\n \"event\": \"screening:started\",\n \"payload\": {\n \"applicationId\": \"app_123\"\n }\n}\n```\n\n### Multiple Namespaces\n\nDefine multiple namespaces for different parts of your application:\n\n```typescript\n// services/realtime/notifications.ts\nexport default defineRealtime({\n name: 'notifications',\n events: ['interview-reminder', 'offer-update', 'application-received'],\n description: 'Recruitment notification events',\n});\n\n// services/realtime/presence.ts\nexport default defineRealtime({\n name: 'presence',\n events: ['joined', 'left', 'reviewing', 'idle'],\n description: 'Who is reviewing which candidate',\n});\n```\n\nUsage:\n```typescript\nconst realtime = createRealtime(ctx.env);\n\n// Notification events\nawait realtime.notifications['interview-reminder']({ ... }, ctx.activeOrgId!);\n\n// Presence events — who's reviewing which candidate\nawait realtime.presence.reviewing({ userId: ctx.userId, candidateId: 'cnd_456' }, ctx.activeOrgId!);\n```\n\n### Namespace vs Generic Broadcast\n\n| Use Case | Approach |\n|----------|----------|\n| One-off custom event | `realtime.broadcast('event-name', payload, orgId)` |\n| Repeated event patterns | `defineRealtime` namespace |\n| Type-safe events | `defineRealtime` namespace |\n| Event discovery | `defineRealtime` (appears in generated types) |"
366
+ "content": "Quickback can generate realtime notification helpers for broadcasting changes to connected clients. This enables live updates via WebSocket using Cloudflare Durable Objects.\n\n## Enabling Realtime\n\nAdd `realtime` configuration to individual table definitions:\n\n```typescript\n// features/applications/applications.ts\n\nexport const applications = sqliteTable(\"applications\", {\n id: text(\"id\").primaryKey(),\n candidateId: text(\"candidate_id\").notNull(),\n jobId: text(\"job_id\").notNull(),\n stage: text(\"stage\").notNull(),\n organizationId: text(\"organization_id\").notNull(),\n});\n\nexport default defineTable(applications, {\n firewall: { organization: {} },\n realtime: {\n enabled: true, // Enable realtime for this table\n onInsert: true, // Broadcast on INSERT (default: true)\n onUpdate: true, // Broadcast on UPDATE (default: true)\n onDelete: true, // Broadcast on DELETE (default: true)\n requiredRoles: [\"recruiter\", \"hiring-manager\"], // Who receives broadcasts\n fields: [\"id\", \"candidateId\", \"stage\"], // Fields to include (optional)\n },\n // ... guards, crud\n});\n```\n\nWhen any table has realtime enabled, the compiler generates `src/lib/realtime.ts` with helper functions for sending notifications.\n\n## Generated Helper\n\nThe `createRealtime()` factory provides convenient methods for both Postgres Changes (CRUD events) and custom broadcasts:\n\n```typescript\n\nexport const execute: ActionExecutor = async ({ db, ctx, input }) => {\n const realtime = createRealtime(ctx.env);\n\n // After creating a record\n await realtime.insert('applications', newApplication, ctx.activeOrgId!);\n\n // After updating a record\n await realtime.update('applications', newApplication, oldApplication, ctx.activeOrgId!);\n\n // After deleting a record\n await realtime.delete('applications', { id: applicationId }, ctx.activeOrgId!);\n\n return { success: true };\n};\n```\n\n## Event Format\n\nQuickback broadcasts events in a structured JSON format for easy client-side handling.\n\n### Insert Event\n\n```typescript\nawait realtime.insert('applications', {\n id: 'app_123',\n candidateId: 'cnd_456',\n stage: 'applied'\n}, ctx.activeOrgId!);\n```\n\nBroadcasts:\n```json\n{\n \"type\": \"postgres_changes\",\n \"table\": \"applications\",\n \"eventType\": \"INSERT\",\n \"schema\": \"public\",\n \"new\": {\n \"id\": \"app_123\",\n \"candidateId\": \"cnd_456\",\n \"stage\": \"applied\"\n },\n \"old\": null\n}\n```\n\n### Update Event\n\n```typescript\nawait realtime.update('applications',\n { id: 'app_123', stage: 'interview' }, // new\n { id: 'app_123', stage: 'screening' }, // old\n ctx.activeOrgId!\n);\n```\n\nBroadcasts:\n```json\n{\n \"type\": \"postgres_changes\",\n \"table\": \"applications\",\n \"eventType\": \"UPDATE\",\n \"schema\": \"public\",\n \"new\": { \"id\": \"app_123\", \"stage\": \"interview\" },\n \"old\": { \"id\": \"app_123\", \"stage\": \"screening\" }\n}\n```\n\n### Delete Event\n\n```typescript\nawait realtime.delete('applications',\n { id: 'app_123' },\n ctx.activeOrgId!\n);\n```\n\nBroadcasts:\n```json\n{\n \"type\": \"postgres_changes\",\n \"table\": \"applications\",\n \"eventType\": \"DELETE\",\n \"schema\": \"public\",\n \"new\": null,\n \"old\": { \"id\": \"app_123\" }\n}\n```\n\n## Custom Broadcasts\n\nFor arbitrary events that don't map to CRUD operations:\n\n```typescript\nawait realtime.broadcast('screening-complete', {\n applicationId: 'app_123',\n candidateId: 'cnd_456',\n stage: 'interview'\n}, ctx.activeOrgId!);\n```\n\nBroadcasts:\n```json\n{\n \"type\": \"broadcast\",\n \"event\": \"screening-complete\",\n \"payload\": {\n \"applicationId\": \"app_123\",\n \"candidateId\": \"cnd_456\",\n \"stage\": \"interview\"\n }\n}\n```\n\n## User-Specific Broadcasts\n\nTarget a specific user instead of the entire organization:\n\n```typescript\n// Only this user receives the notification\nawait realtime.insert('notifications', newNotification, ctx.activeOrgId!, {\n userId: ctx.userId\n});\n\n// Notify a hiring manager of a new application\nawait realtime.broadcast('application-received', {\n applicationId: 'app_123',\n jobId: 'job_789'\n}, ctx.activeOrgId!, {\n userId: hiringManagerId\n});\n```\n\n## Role-Based Filtering\n\nLimit which roles receive a broadcast using `targetRoles`:\n\n```typescript\n// Only hiring managers and recruiters receive this broadcast\nawait realtime.insert('applications', application, ctx.activeOrgId!, {\n targetRoles: ['hiring-manager', 'recruiter']\n});\n\n// Interviewers only see applications assigned to them\nawait realtime.update('applications', newRecord, oldRecord, ctx.activeOrgId!, {\n targetRoles: ['hiring-manager']\n});\n```\n\nIf `targetRoles` is not specified, all authenticated users in the organization receive the broadcast.\n\n## Per-Role Field Masking\n\nApply different field masking based on the subscriber's role using `maskingConfig`:\n\n```typescript\nawait realtime.insert('candidates', newCandidate, ctx.activeOrgId!, {\n maskingConfig: {\n phone: { type: 'phone', show: { roles: ['owner', 'hiring-manager', 'recruiter'] } },\n email: { type: 'email', show: { roles: ['owner', 'hiring-manager', 'recruiter'] } },\n resumeUrl: { type: 'redact', show: { roles: ['owner', 'hiring-manager', 'recruiter'] } },\n },\n});\n```\n\n**How masking works:**\n- Each subscriber receives a payload masked according to their role\n- Roles in the `show.roles` array see unmasked values\n- All other roles see the masked version\n- Masking is pre-computed per-role (O(roles) not O(subscribers))\n\n**Available mask types:**\n| Type | Example Output |\n|------|----------------|\n| `email` | `j***@y*********.com` |\n| `phone` | `******4567` |\n| `ssn` | `*****6789` |\n| `creditCard` | `**** **** **** 1111` |\n| `name` | `J***` |\n| `redact` | `[REDACTED]` |\n\n### Owner-Based Masking\n\nShow unmasked data to the record owner:\n\n```typescript\nmaskingConfig: {\n phone: {\n type: 'phone',\n show: { roles: ['hiring-manager'], or: 'owner' } // Hiring manager OR owner sees unmasked\n },\n}\n```\n\n## Client-Side Subscription\n\n### WebSocket Authentication\n\nQuickback uses **ticket-based authentication** for WebSocket connections. The client first obtains a short-lived ticket from the main API, then connects with it as a URL parameter. The Broadcaster verifies the ticket cryptographically at upgrade time — no HTTP round-trip needed.\n\n```typescript\n// 1. Get a ws-ticket from your API (requires session auth)\nconst response = await fetch('/realtime/v1/ws-ticket', {\n method: 'POST',\n headers: { Authorization: `Bearer ${sessionToken}` },\n});\nconst { wsTicket } = await response.json();\n\n// 2. Connect with ticket as URL param\nconst ws = new WebSocket(\n `wss://api.yourdomain.com/realtime/v1/websocket?ws_ticket=${wsTicket}&organizationId=${activeOrgId}`\n);\n\nws.onopen = () => {\n console.log('Connected and authenticated!');\n // No auth message needed — pre-authenticated at upgrade\n};\n```\n\nTickets expire after 60 seconds, so fetch a fresh one before each connection or reconnection.\n\n### Handling Messages\n\n```typescript\nws.onmessage = (event) => {\n const msg = JSON.parse(event.data);\n\n // Handle CRUD events\n if (msg.type === 'postgres_changes') {\n const { table, eventType, new: newRecord, old: oldRecord } = msg;\n\n if (eventType === 'INSERT') {\n addRecord(table, newRecord);\n } else if (eventType === 'UPDATE') {\n updateRecord(table, newRecord);\n } else if (eventType === 'DELETE') {\n removeRecord(table, oldRecord.id);\n }\n }\n\n // Handle custom broadcasts\n if (msg.type === 'broadcast') {\n const { event, payload } = msg;\n\n if (event === 'screening-complete') {\n refreshApplication(payload.applicationId);\n } else if (event === 'interview-scheduled') {\n showInterviewNotification(payload.applicationId);\n }\n }\n};\n```\n\n## Required Environment Variables\n\n| Variable | Where | Description |\n|----------|-------|-------------|\n| `REALTIME_URL` | Main API | URL of the broadcast/realtime worker |\n| `ACCESS_TOKEN` | Both | Shared secret for broadcast API auth and ws-ticket signing |\n\nThe `ACCESS_TOKEN` serves double duty: it authenticates internal broadcast API calls from the main worker, and it signs/verifies WebSocket tickets. Set it as a secret on the broadcast worker:\n\n```bash\ncd cloudflare-workers/broadcast && wrangler secret put ACCESS_TOKEN\n```\n\n## Architecture\n\n```\n 1. POST /ws-ticket\n┌──────────────┐ ◄──────────────────────────────── ┌─────────────────┐\n│ API Worker │ ────────────────────────────────► │ Browser Client │\n│ (Quickback) │ { wsTicket, expiresIn: 60 } │ │\n└──────┬───────┘ └───────┬─────────┘\n │ │\n │ POST /broadcast 2. WS ?ws_ticket=...│\n │ │\n ▼ ▼\n┌──────────────────┐ Verified at upgrade\n│ Realtime Worker │ ◄──────── WebSocket ────── (no HTTP call)\n│ (Durable Object) │\n└──────────────────┘\n```\n\n1. **Client gets a ticket** — `POST /realtime/v1/ws-ticket` on the main API (session-authenticated). Returns a 60-second HMAC-signed ticket.\n2. **Client connects** — Opens WebSocket with `?ws_ticket=<ticket>`. The Durable Object verifies cryptographically at upgrade.\n3. **API broadcasts** — After CRUD ops, the main API calls `POST /broadcast` on the realtime worker, which delivers to matching subscribers.\n\n## Best Practices\n\n### Broadcast After Commit\n\nAlways broadcast after the database operation succeeds:\n\n```typescript\n// Good - broadcast after successful insert\nconst [newApp] = await db.insert(applications).values(data).returning();\nawait realtime.insert('applications', newApp, ctx.activeOrgId!);\n\n// Bad - don't broadcast before confirming success\nawait realtime.insert('applications', data, ctx.activeOrgId!);\nawait db.insert(applications).values(data); // Could fail!\n```\n\n### Minimal Payloads\n\nOnly include necessary data in broadcasts:\n\n```typescript\n// Good - minimal payload\nawait realtime.update('applications',\n { id: record.id, stage: 'interview' },\n { id: record.id, stage: 'screening' },\n ctx.activeOrgId!\n);\n\n// Avoid - sending entire record with large content\nawait realtime.update('applications', fullRecord, oldRecord, ctx.activeOrgId!);\n```\n\n### Use Broadcasts for Complex Events\n\nFor events that don't map cleanly to CRUD:\n\n```typescript\n// Hiring pipeline stage completed\nawait realtime.broadcast('pipeline-stage-complete', {\n applicationId: application.id,\n stages: ['applied', 'screening', 'interview'],\n currentStage: 'offer',\n candidateId: application.candidateId\n}, ctx.activeOrgId!);\n```\n\n## Custom Event Namespaces\n\nFor complex applications with many custom events, you can define typed event namespaces using `defineRealtime`. This generates strongly-typed helper methods for your custom events.\n\n### Defining Event Namespaces\n\nCreate a file in `services/realtime/`:\n\n```typescript\n// services/realtime/screening.ts\n\nexport default defineRealtime({\n name: 'screening',\n events: ['started', 'progress', 'completed', 'failed'],\n description: 'Candidate screening pipeline events',\n});\n```\n\n### Generated Helper Methods\n\nAfter compilation, the `createRealtime()` helper includes your custom namespace:\n\n```typescript\n\nexport const execute: ActionExecutor = async ({ ctx, input }) => {\n const realtime = createRealtime(ctx.env);\n\n // Type-safe custom event methods\n await realtime.screening.started({\n applicationId: input.applicationId,\n }, ctx.activeOrgId!);\n\n // Progress updates\n await realtime.screening.progress({\n applicationId: input.applicationId,\n percent: 50,\n step: 'background-check',\n }, ctx.activeOrgId!);\n\n // Completion\n await realtime.screening.completed({\n applicationId: input.applicationId,\n result: 'passed',\n duration: 1234,\n }, ctx.activeOrgId!);\n\n return { success: true };\n};\n```\n\n### Event Format\n\nCustom namespace events use the `broadcast` type with namespaced event names:\n\n```json\n{\n \"type\": \"broadcast\",\n \"event\": \"screening:started\",\n \"payload\": {\n \"applicationId\": \"app_123\"\n }\n}\n```\n\n### Multiple Namespaces\n\nDefine multiple namespaces for different parts of your application:\n\n```typescript\n// services/realtime/notifications.ts\nexport default defineRealtime({\n name: 'notifications',\n events: ['interview-reminder', 'offer-update', 'application-received'],\n description: 'Recruitment notification events',\n});\n\n// services/realtime/presence.ts\nexport default defineRealtime({\n name: 'presence',\n events: ['joined', 'left', 'reviewing', 'idle'],\n description: 'Who is reviewing which candidate',\n});\n```\n\nUsage:\n```typescript\nconst realtime = createRealtime(ctx.env);\n\n// Notification events\nawait realtime.notifications['interview-reminder']({ ... }, ctx.activeOrgId!);\n\n// Presence events — who's reviewing which candidate\nawait realtime.presence.reviewing({ userId: ctx.userId, candidateId: 'cnd_456' }, ctx.activeOrgId!);\n```\n\n### Namespace vs Generic Broadcast\n\n| Use Case | Approach |\n|----------|----------|\n| One-off custom event | `realtime.broadcast('event-name', payload, orgId)` |\n| Repeated event patterns | `defineRealtime` namespace |\n| Type-safe events | `defineRealtime` namespace |\n| Event discovery | `defineRealtime` (appears in generated types) |"
363
367
  },
364
368
  "stack/realtime": {
365
369
  "title": "Realtime",
366
- "content": "The Quickback Stack uses Cloudflare Durable Objects to broadcast real-time updates over WebSockets. CRUD events and custom broadcasts are delivered to connected clients with the same security layers (firewall isolation, role-based filtering, field masking) applied.\n\n## Architecture\n\n```\n┌─────────────┐ POST /broadcast ┌──────────────────┐\n│ API Worker │ ───────────────────────► │ Realtime Worker │\n│ (Quickback) │ │ (Durable Object) │\n└─────────────┘ └────────┬─────────┘\n │\n WebSocket│\n │\n ┌────────▼─────────┐\n │ Browser Clients │\n │ (WebSocket) │\n └──────────────────┘\n```\n\n1. **API Worker** — Your Quickback-compiled API. Calls `realtime.insert()` etc. after CRUD operations.\n2. **Realtime Worker** — Separate Cloudflare Worker with Durable Object for managing WebSocket connections, one per organization.\n3. **Browser Clients** — Connect via WebSocket, receive filtered and masked broadcasts.\n\n## Key Features\n\n| Feature | Description |\n|---------|-------------|\n| Organization-scoped | Each org gets its own Durable Object instance |\n| Role-based filtering | Only send events to users with matching roles |\n| Per-role masking | Different users see different field values based on their role |\n| User-specific targeting | Send events to a specific user within an org |\n| Custom broadcasts | Arbitrary events beyond CRUD |\n| Custom namespaces | `defineRealtime()` for type-safe event helpers |\n| Two auth methods | Session tokens (browser) and API keys (server/CLI) |\n\n## Enabling Realtime\n\nAdd `realtime` to individual table definitions:\n\n```typescript\nexport default defineTable(applications, {\n firewall: { organization: {} },\n realtime: {\n enabled: true,\n onInsert: true,\n onUpdate: true,\n onDelete: true,\n requiredRoles: [\"recruiter\", \"hiring-manager\"],\n fields: [\"id\", \"candidateId\", \"stage\"],\n },\n});\n```\n\nAnd enable the realtime binding in your database config. The compiler generates the Durable Object worker and helper functions.\n\n## Pages\n\n- **[Durable Objects Setup](/stack/realtime/durable-objects)** — Configuration, wrangler bindings, event formats, masking, and custom namespaces\n- **[Using Realtime](/stack/realtime/using-realtime)** — WebSocket connection, authentication, and client-side handling"
370
+ "content": "The Quickback Stack uses Cloudflare Durable Objects to broadcast real-time updates over WebSockets. CRUD events and custom broadcasts are delivered to connected clients with the same security layers (firewall isolation, role-based filtering, field masking) applied.\n\n## Architecture\n\n```\n┌─────────────┐ POST /broadcast ┌──────────────────┐\n│ API Worker │ ───────────────────────► │ Realtime Worker │\n│ (Quickback) │ │ (Durable Object) │\n└─────────────┘ └────────┬─────────┘\n │\n WebSocket│\n │\n ┌────────▼─────────┐\n │ Browser Clients │\n │ (WebSocket) │\n └──────────────────┘\n```\n\n1. **API Worker** — Your Quickback-compiled API. Calls `realtime.insert()` etc. after CRUD operations.\n2. **Realtime Worker** — Separate Cloudflare Worker with Durable Object for managing WebSocket connections, one per organization.\n3. **Browser Clients** — Connect via WebSocket, receive filtered and masked broadcasts.\n\n## Key Features\n\n| Feature | Description |\n|---------|-------------|\n| Organization-scoped | Each org gets its own Durable Object instance |\n| Role-based filtering | Only send events to users with matching roles |\n| Per-role masking | Different users see different field values based on their role |\n| User-specific targeting | Send events to a specific user within an org |\n| Custom broadcasts | Arbitrary events beyond CRUD |\n| Custom namespaces | `defineRealtime()` for type-safe event helpers |\n| Ticket-based auth | HMAC-signed tickets verified at WebSocket upgrade no HTTP round-trip |\n\n## Enabling Realtime\n\nAdd `realtime` to individual table definitions:\n\n```typescript\nexport default defineTable(applications, {\n firewall: { organization: {} },\n realtime: {\n enabled: true,\n onInsert: true,\n onUpdate: true,\n onDelete: true,\n requiredRoles: [\"recruiter\", \"hiring-manager\"],\n fields: [\"id\", \"candidateId\", \"stage\"],\n },\n});\n```\n\nAnd enable the realtime binding in your database config. The compiler generates the Durable Object worker and helper functions.\n\n## Pages\n\n- **[Durable Objects Setup](/stack/realtime/durable-objects)** — Configuration, wrangler bindings, event formats, masking, and custom namespaces\n- **[Using Realtime](/stack/realtime/using-realtime)** — WebSocket connection, authentication, and client-side handling"
367
371
  },
368
372
  "stack/realtime/using-realtime": {
369
373
  "title": "Using Realtime",
370
- "content": "This page covers connecting to the Quickback realtime system from client applications — authentication, subscribing to events, and handling messages.\n\n## Connecting\n\nOpen a WebSocket connection to the realtime worker:\n\n```typescript\nconst ws = new WebSocket(\"wss://api.yourdomain.com/realtime/v1/websocket\");\n```\n\n## Authentication\n\nAfter connecting, send an auth message to authenticate. Two methods are supported.\n\n### Session Token (Browser/App)\n\n```typescript\nws.onopen = () => {\n ws.send(JSON.stringify({\n type: \"auth\",\n token: sessionToken, // JWT from Better Auth session\n organizationId: activeOrgId,\n }));\n};\n```\n\n### API Key (Server/CLI)\n\n```typescript\nws.onopen = () => {\n ws.send(JSON.stringify({\n type: \"auth\",\n token: apiKey, // API key for machine-to-machine auth\n organizationId: orgId,\n }));\n};\n```\n\n### Auth Response\n\nOn success:\n\n```json\n{\n \"type\": \"auth_success\",\n \"organizationId\": \"org_123\",\n \"userId\": \"user_456\",\n \"role\": \"hiring-manager\",\n \"roles\": [\"hiring-manager\", \"recruiter\"],\n \"authMethod\": \"session\"\n}\n```\n\nOn failure, the connection is closed with an error message.\n\n## Handling Messages\n\n### CRUD Events\n\nCRUD events use the `postgres_changes` type:\n\n```typescript\nws.onmessage = (event) => {\n const msg = JSON.parse(event.data);\n\n if (msg.type === \"postgres_changes\") {\n const { table, eventType, new: newRecord, old: oldRecord } = msg;\n\n switch (eventType) {\n case \"INSERT\":\n addRecord(table, newRecord);\n break;\n case \"UPDATE\":\n updateRecord(table, newRecord);\n break;\n case \"DELETE\":\n removeRecord(table, oldRecord.id);\n break;\n }\n }\n};\n```\n\n**Event payload:**\n\n```json\n{\n \"type\": \"postgres_changes\",\n \"table\": \"applications\",\n \"schema\": \"public\",\n \"eventType\": \"INSERT\",\n \"new\": { \"id\": \"app_123\", \"candidateId\": \"cnd_456\", \"stage\": \"interview\" },\n \"old\": null\n}\n```\n\nFor UPDATE events, both `new` and `old` are populated. For DELETE events, only `old` is populated.\n\n### Custom Broadcasts\n\nCustom events use the `broadcast` type:\n\n```typescript\nif (msg.type === \"broadcast\") {\n const { event, payload } = msg;\n\n if (event === \"screening-complete\") {\n refreshApplication(payload.applicationId);\n } else if (event === \"screening:progress\") {\n updateProgressBar(payload.percent);\n }\n}\n```\n\n**Event payload:**\n\n```json\n{\n \"type\": \"broadcast\",\n \"event\": \"screening-complete\",\n \"payload\": {\n \"applicationId\": \"app_123\",\n \"candidateId\": \"cnd_456\",\n \"stage\": \"interview\"\n }\n}\n```\n\nCustom namespaces (from `defineRealtime()`) use the format `namespace:event` — e.g., `screening:started`, `screening:progress`.\n\n## Security\n\n### Role-Based Filtering\n\nEvents are only delivered to users whose role matches the broadcast's `targetRoles`. If a table's realtime config specifies `requiredRoles: [\"hiring-manager\", \"recruiter\"]`, only users with those roles receive the events.\n\n### Per-Role Masking\n\nField values are masked according to the subscriber's role. For example, with this masking config:\n\n```typescript\nmasking: {\n ssn: { type: \"ssn\", show: { roles: [\"owner\"] } },\n}\n```\n\n- **Owner sees:** `{ ssn: \"123-45-6789\" }`\n- **Recruiter sees:** `{ ssn: \"*****6789\" }`\n\nMasking is pre-computed per-role (O(roles), not O(subscribers)) for efficiency.\n\n### User-Specific Events\n\nEvents can target a specific user within an organization. Only that user receives the broadcast — all other connections in the org are skipped.\n\n### Organization Isolation\n\nEach organization has its own Durable Object instance. Users can only subscribe to events in organizations they belong to, enforced during authentication.\n\n## Reconnection\n\nWebSocket connections can drop due to network issues. Implement reconnection logic in your client:\n\n```typescript\nfunction connect() {\n const ws = new WebSocket(\"wss://api.yourdomain.com/realtime/v1/websocket\");\n\n ws.onopen = () => {\n ws.send(JSON.stringify({\n type: \"auth\",\n token: sessionToken,\n organizationId: activeOrgId,\n }));\n };\n\n ws.onclose = () => {\n // Reconnect after delay\n setTimeout(connect, 2000);\n };\n\n ws.onmessage = (event) => {\n const msg = JSON.parse(event.data);\n handleMessage(msg);\n };\n\n return ws;\n}\n```\n\n## Required Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `REALTIME_URL` | URL of the broadcast/realtime worker |\n| `ACCESS_TOKEN` | Internal service-to-service auth token |\n\n```toml\n# wrangler.toml\n[vars]\nREALTIME_URL = \"https://my-app-broadcast.workers.dev\"\nACCESS_TOKEN = \"your-internal-secret-token\"\n```\n\n## Cloudflare Only\n\nRealtime requires Cloudflare Durable Objects and is only available with the Cloudflare runtime.\n\n## See Also\n\n- [Durable Objects Setup](/stack/realtime/durable-objects) — Configuration, event formats, masking, and custom namespaces\n- [Masking](/compiler/definitions/masking) — Field masking configuration"
374
+ "content": "This page covers connecting to the Quickback realtime system from client applications — authentication, subscribing to events, and handling messages.\n\n## Connecting\n\nOpen a WebSocket connection to the realtime worker:\n\n```typescript\nconst ws = new WebSocket(\"wss://api.yourdomain.com/realtime/v1/websocket\");\n```\n\n## Authentication\n\nQuickback uses **ticket-based authentication** for WebSocket connections. This is a two-step process:\n\n1. **Get a ticket** — Call the ws-ticket endpoint with your session auth\n2. **Connect with ticket** — Pass the ticket as a URL parameter when opening the WebSocket\n\nThis approach is faster and more secure than in-band auth messages — the connection is authenticated at upgrade time with no HTTP round-trip from the Durable Object.\n\n### Step 1: Get a WebSocket Ticket\n\n```typescript\nconst response = await fetch(\"/realtime/v1/ws-ticket\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${sessionToken}`,\n \"Content-Type\": \"application/json\",\n },\n});\nconst { wsTicket, expiresIn } = await response.json();\n// expiresIn = 60 (seconds)\n```\n\nThe ticket is a short-lived (60-second) HMAC-signed token containing your userId, organizationId, and roles.\n\n### Step 2: Connect with Ticket\n\n```typescript\nconst ws = new WebSocket(\n `wss://api.yourdomain.com/realtime/v1/websocket?ws_ticket=${wsTicket}&organizationId=${activeOrgId}`\n);\n\nws.onopen = () => {\n console.log(\"Connected and authenticated!\");\n // No auth message needed — connection is pre-authenticated\n};\n```\n\nIf the ticket is invalid or expired, the WebSocket upgrade is rejected with a 401 status.\n\n## Handling Messages\n\n### CRUD Events\n\nCRUD events use the `postgres_changes` type:\n\n```typescript\nws.onmessage = (event) => {\n const msg = JSON.parse(event.data);\n\n if (msg.type === \"postgres_changes\") {\n const { table, eventType, new: newRecord, old: oldRecord } = msg;\n\n switch (eventType) {\n case \"INSERT\":\n addRecord(table, newRecord);\n break;\n case \"UPDATE\":\n updateRecord(table, newRecord);\n break;\n case \"DELETE\":\n removeRecord(table, oldRecord.id);\n break;\n }\n }\n};\n```\n\n**Event payload:**\n\n```json\n{\n \"type\": \"postgres_changes\",\n \"table\": \"applications\",\n \"schema\": \"public\",\n \"eventType\": \"INSERT\",\n \"new\": { \"id\": \"app_123\", \"candidateId\": \"cnd_456\", \"stage\": \"interview\" },\n \"old\": null\n}\n```\n\nFor UPDATE events, both `new` and `old` are populated. For DELETE events, only `old` is populated.\n\n### Custom Broadcasts\n\nCustom events use the `broadcast` type:\n\n```typescript\nif (msg.type === \"broadcast\") {\n const { event, payload } = msg;\n\n if (event === \"screening-complete\") {\n refreshApplication(payload.applicationId);\n } else if (event === \"screening:progress\") {\n updateProgressBar(payload.percent);\n }\n}\n```\n\n**Event payload:**\n\n```json\n{\n \"type\": \"broadcast\",\n \"event\": \"screening-complete\",\n \"payload\": {\n \"applicationId\": \"app_123\",\n \"candidateId\": \"cnd_456\",\n \"stage\": \"interview\"\n }\n}\n```\n\nCustom namespaces (from `defineRealtime()`) use the format `namespace:event` — e.g., `screening:started`, `screening:progress`.\n\n## Security\n\n### Role-Based Filtering\n\nEvents are only delivered to users whose role matches the broadcast's `targetRoles`. If a table's realtime config specifies `requiredRoles: [\"hiring-manager\", \"recruiter\"]`, only users with those roles receive the events.\n\n### Per-Role Masking\n\nField values are masked according to the subscriber's role. For example, with this masking config:\n\n```typescript\nmasking: {\n ssn: { type: \"ssn\", show: { roles: [\"owner\"] } },\n}\n```\n\n- **Owner sees:** `{ ssn: \"123-45-6789\" }`\n- **Recruiter sees:** `{ ssn: \"*****6789\" }`\n\nMasking is pre-computed per-role (O(roles), not O(subscribers)) for efficiency.\n\n### User-Specific Events\n\nEvents can target a specific user within an organization. Only that user receives the broadcast — all other connections in the org are skipped.\n\n### Organization Isolation\n\nEach organization has its own Durable Object instance. Users can only subscribe to events in organizations they belong to, enforced during authentication.\n\n## Reconnection\n\nWebSocket connections can drop due to network issues. Implement reconnection logic in your client — note that you need to fetch a fresh ticket on each reconnect since tickets expire after 60 seconds:\n\n```typescript\nasync function connect() {\n // Get a fresh ticket each time\n const res = await fetch(\"/realtime/v1/ws-ticket\", {\n method: \"POST\",\n headers: { Authorization: `Bearer ${sessionToken}` },\n });\n const { wsTicket } = await res.json();\n\n const ws = new WebSocket(\n `wss://api.yourdomain.com/realtime/v1/websocket?ws_ticket=${wsTicket}&organizationId=${activeOrgId}`\n );\n\n ws.onclose = () => {\n // Reconnect after delay\n setTimeout(connect, 2000);\n };\n\n ws.onmessage = (event) => {\n const msg = JSON.parse(event.data);\n handleMessage(msg);\n };\n\n return ws;\n}\n```\n\n## Required Environment Variables\n\n| Variable | Where | Description |\n|----------|-------|-------------|\n| `REALTIME_URL` | Main API | URL of the broadcast/realtime worker |\n| `ACCESS_TOKEN` | Both | Shared secret for broadcast API auth and ws-ticket signing |\n\n```toml\n# Main API wrangler.toml\n[vars]\nREALTIME_URL = \"https://my-app-broadcast.workers.dev\"\n\n# Broadcast worker: set secret via CLI\n# wrangler secret put ACCESS_TOKEN\n```\n\n## Cloudflare Only\n\nRealtime requires Cloudflare Durable Objects and is only available with the Cloudflare runtime.\n\n## See Also\n\n- [Durable Objects Setup](/stack/realtime/durable-objects) — Configuration, event formats, masking, and custom namespaces\n- [Masking](/compiler/definitions/masking) — Field masking configuration"
371
375
  },
372
376
  "stack/storage": {
373
377
  "title": "Storage",
@@ -493,6 +497,7 @@ export const TOPIC_LIST = [
493
497
  "stack/auth/api-keys",
494
498
  "stack/auth/device-auth",
495
499
  "stack/auth",
500
+ "stack/auth/jwt-optimization",
496
501
  "stack/auth/plugins",
497
502
  "stack/auth/security",
498
503
  "stack/auth/using-auth",