# Belt > Belt is a Rails-inspired framework for serverless Ruby on AWS Lambda. A Ruby DSL (routes.rb) plus the belt CLI and the conveyor-belt Terraform provider turn a Rails-like app into complete AWS serverless infrastructure (API Gateway, Lambda, DynamoDB, Cognito, CloudFront). Source: https://belt.dev/docs --- # Authentication Cognito owns authentication — passwords, MFA, hosted signup, groups. Belt owns the **record** of the human Cognito authenticated, so your app can answer its own questions about that person without re-reading JWT claims in every controller. Declare it on a model and you're done: ```ruby class User < ApplicationRecord cognito_authenticatable end ``` That one line supplies: | | | |---|---| | Primary key | the Cognito `sub` — resolving the caller is one `GetItem` | | Attributes | `email`, `name`, `role`, `email_verified`, `last_seen_on` | | GSI | `EmailIndex`, for finding a person by address | | Class methods | `.sync_from_claims!`, `.for_sub`, `.for_email` | | Instance methods | `#admin?`, `#email_verified?` | Controllers get `current_user`, `authenticate_user!`, `user_signed_in?`, and `cognito_admin?` with no `include` and no configuration. ## The table ```hcl resource "aws_dynamodb_table" "users" { name = "${var.app_name}-${var.environment}-users" billing_mode = "PAY_PER_REQUEST" hash_key = "id" # the Cognito sub attribute { name = "id" type = "S" } attribute { name = "email" type = "S" } global_secondary_index { name = "EmailIndex" hash_key = "email" projection_type = "ALL" } } ``` Identity attributes are stored snake_case (`email_verified`, not `emailVerified`) so they read the same in the console, in `dynamodb.tf`, and in a GSI key definition. Grant the table to every Lambda, not per route. Authenticated requests read it *before* the action runs, so per-route `tables:` means listing it on every route and 500ing on whichever one you missed. ## In a controller ```ruby class ProfilesController < ApplicationController before_action :authenticate_user! def show @profile = current_user end end ``` `authenticate_user!` raises `Belt::Authentication::NotAuthenticated`, which Belt already maps to **401**. It has to raise: `before_action` cannot halt the chain by returning a response — return values are discarded and the action runs anyway. | Method | | |---|---| | `current_user` | the user record, or nil. Memoized per request | | `user_signed_in?` | is there a Cognito identity on this request? | | `authenticate_user!` | `before_action` guard → 401 | | `cognito_admin?` | does the token carry a staff Cognito group? | | `cognito_claims` | raw claims, if you really need them | | `bearer_token` | the raw `Authorization: Bearer` credential | If your app has a *second* credential scheme — an API key for machine callers, say — override `skip_cognito_identity?` to declare that such a request is not a human: ```ruby def skip_cognito_identity? agent_request? # `Authorization: Bearer fp_...` end ``` Usually unnecessary: a credential that isn't a Cognito ID token yields no claims and therefore no `current_user`. It matters when a request could be read as both. ## Just-in-time provisioning There is no signup endpoint to keep in step with Cognito's hosted UI. The first authenticated request from a user writes the row; later requests only write when something actually drifted — a name changed in Cognito, a staff group was granted or revoked, or it's the first sighting today. Steady state is one `GetItem` and no write. `last_seen_on` is a **date**, not a timestamp, precisely so an active session doesn't generate a write per request. To hang your own behaviour off that moment, override the hook: ```ruby class User < ApplicationRecord cognito_authenticatable # Runs whenever an identity is resolved from a token. def after_cognito_sync claim_pending_invitations! end end ``` ## Platform staff `role` is platform-wide: `member` or `admin`. It mirrors a Cognito group on **every** request, so removing someone from the group locks them out on their very next call. ```ruby current_user.admin? # FeatureParity staff — can see across tenants cognito_admin? # the grant: does this token carry the group? ``` Per-tenant roles (owner of a project, member of an org) are your domain, not this concern's. Model them yourself. Grant staff by hand — Terraform should create the group but not manage its members, so that a `terraform apply` can't hand out platform-wide read access: ```bash aws cognito-idp admin-add-user-to-group \ --user-pool-id --username --group-name admins ``` ## Configuration Every setting has a working default. An app whose model is `User` and whose staff group is `admins` configures nothing. ```ruby # lambda/config/environment.rb Belt.configure do |config| config.authentication.user_class = 'Account' # default: 'User' config.authentication.admin_groups = %w[staff] # default: ['admins'], or ADMIN_COGNITO_GROUPS config.authentication.issuer = '...' # default: derived from COGNITO_USER_POOL_ID end ``` Macro options: ```ruby cognito_authenticatable roles: %w[member admin support], default_role: 'member', email_index: 'PeopleEmailIndex' # or false to skip it ``` ## Two token shapes A route can be authenticated either way, and both are handled: 1. **API Gateway Cognito authorizer** — claims arrive pre-verified under `requestContext.authorizer.claims`, with every value flattened to a string (`"true"`, `"[admins, members]"`). 2. **A raw `Authorization: Bearer ` header** — the Lambda decodes it. Case 2 is signature-unverified by design: where an authorizer is attached, the gateway already checked the signature and an unsigned token never reaches your code. What can still be checked cheaply is checked — structure, expiry, issuer, and `token_use` (an access token is rejected; it carries no email or name). Anything that isn't a Cognito ID token — including your own API key scheme sharing the same header — reads as "no Cognito identity" rather than an error, so `current_user` is simply nil. ## See also - `belt explain models` — ActiveItem - `belt explain controllers` — `before_action`, response helpers - `belt generate auth` — create the Cognito user pool --- # Backups Belt integrates automated pre-deploy backups into the deploy lifecycle. When configured, `belt deploy` creates recovery points before applying changes. ## Quick Setup Create `infrastructure//belt.rb`: ```ruby Belt.configure do |config| config.backups do dynamodb :all retention snapshots: 90 end end ``` Then deploy normally — backups run automatically: ```bash belt deploy prod ``` ## Simple Mode For DynamoDB-only backups with defaults (all tables, 90-day retention): ```ruby Belt.configure do |config| config.backups = true end ``` ## Full Configuration ```ruby Belt.configure do |config| config.backups do dynamodb :all # All tables: PITR check + on-demand snapshot dynamodb :posts, :users # Or specific tables only cognito :users, :pool_config # Export user list + pool settings to S3 s3 :legal_documents # Sync bucket to backup bucket retention snapshots: 90, cognito: 10, s3: 10 end end ``` ## Backup Types | Type | What It Does | Default Retention | |------|-------------|-------------------| | `dynamodb :all` | PITR verification + on-demand snapshot per table | 90 days | | `dynamodb :table1, :table2` | Same, specific tables only | 90 days | | `cognito :users` | Paginated user export → JSON in backup bucket | 10 copies | | `cognito :pool_config` | Pool configuration export → JSON | 10 copies | | `s3 :bucket_name` | Full sync to backup bucket | 10 copies | ## CLI Flags ```bash belt deploy prod # normal deploy (runs backups first) belt deploy prod --skip-backup # skip backup phase belt deploy prod --backup-only # just create recovery point, don't deploy ``` ## How It Works 1. Creates backup bucket `-backups-` on first run (versioned, public access blocked) 2. Reads table names from `terraform output` 3. Verifies PITR is enabled on each DynamoDB table 4. Creates on-demand backup named `-` 5. For Cognito/S3: exports to backup bucket under timestamped prefixes 6. Cleans up expired snapshots/copies beyond retention ## First Deploy On a brand-new environment with no prior deploys, there are no Terraform outputs to read table names from. Belt warns and skips the backup phase gracefully. After the first successful deploy, backups run normally. ## DynamoDB Protection Defaults All Belt-generated DynamoDB tables include: - **PITR** (Point-in-Time Recovery) — enabled by default (35 days continuous) - **Deletion protection** — enabled in prod, disabled in dev ## Skipping Backups in Dev Don't create `infrastructure/dev01/belt.rb`, or omit the backups block: ```ruby Belt.configure do |config| # No backups block = no backups during deploy end ``` ## See Also - `belt explain deployment` — the full deploy lifecycle - `belt deploy --help` — all deploy options --- # Console `belt console` (alias: `belt c`) starts an interactive Ruby session with your application fully loaded — models, configuration, AWS clients, everything. ## Basic Usage ```bash belt console # uses BELT_ENV or defaults to 'dev' belt c prod # specify environment belt c dev01 # any environment name ``` ## What Gets Loaded 1. `lambda/config/environment.rb` — your app's boot file (AWS setup, models, libs) 2. IRB starts with `reload!` available 3. `.irbrc` from project root (optional console customization) ## Runner Mode Execute a command and exit (useful for scripts/CI): ```bash belt c dev01 --run "Customer.first" belt c prod --run "Post.count" belt c dev01 --run "User.where(status: 'active', index: 'StatusIndex').count" ``` ## Production Safety When the environment is `prod`, Belt shows a confirmation prompt before starting the console. This prevents accidentally running destructive commands against production data. ## Common Tasks ```ruby # Find a record post = Post.find("post-id-123") # Query with index users = User.where(status: "active", index: "StatusIndex") # Create a record Post.create!(title: "Test", body: "Hello", user_id: "u-123") # Count records Order.count # Reload code changes reload! ``` ## Environment Resolution Priority order: 1. Explicit argument: `belt c prod` 2. `BELT_ENV` environment variable 3. Default: `dev` ## See Also - `belt explain models` — ActiveItem query methods - `belt explain structure` — where environment.rb lives --- # Controllers Belt controllers inherit from `BeltController::Base` and handle HTTP requests dispatched by `Belt::ActionRouter`. They provide callbacks, strong parameters, response helpers, and error handling — similar to Rails ActionController. ## Basic Controller ```ruby module MyApp class PostsController < ApplicationController def index @posts = Post.all end def show @post = Post.find(params["id"]) end def create attrs = params.require(:post).permit(:title, :body).to_h @post = Post.create!(attrs.merge(user_id: current_user_id)) response_status :created end def destroy Post.find(params["id"]).destroy head :no_content end end end ``` ## Response Behavior ### Implicit Responses (default: JSON) When an action sets instance variables and returns without calling a response helper, Belt auto-serializes assigns into a JSON response: ```ruby def index @posts = Post.all # → { "posts": [...] } end def show @post = Post.find(params["id"]) # → { "post": {...} } end ``` ### Explicit Response Helpers ```ruby success_response({ id: "123", name: "Example" }) # 200 JSON success_response({ id: "123" }, :created) # 201 JSON error_response("Not found", :not_found) # 404 JSON error_response("Nope", :unprocessable_entity) # 422 JSON html_response("

Hello

") # 200 HTML head :no_content # 204 empty head :created # 201 empty ``` ### Non-200 with Implicit Assigns ```ruby def create @post = Post.create!(...) response_status :created # → 201 + { "post": {...} } end ``` ### Default Format ```ruby # Global (in config/environment.rb) Belt.configure do |config| config.default_format = :json # or :html end # Per-controller class PagesController < ApplicationController self.default_format = :html end ``` - `:json` — assigns become JSON body - `:html` — renders `views//.html.erb` ## Callbacks ```ruby class ApplicationController < BeltController::Base before_action :authenticate! before_action :require_admin!, except: [:health] skip_before_action :authenticate!, only: [:health] end ``` Callbacks run in definition order. `before_action` can halt the request by calling a response helper (e.g., `error_response`). ## Strong Parameters ```ruby params.require(:user).permit(:name, :email, address: [:street, :city]) ``` - `params` — merged hash of path parameters + parsed JSON body - `require(:key)` — raises if key missing - `permit(:field1, :field2)` — whitelists allowed fields - Nested: `permit(:name, address: [:street, :city])` ## Error Handling ```ruby class ApplicationController < BeltController::Base rescue_from ActiveItem::RecordNotFound, with: :not_found rescue_from MyCustomError, with: :handle_custom private def not_found(exception, _context = {}) error_response(exception.message, :not_found) end def handle_custom(exception, _context = {}) error_response(exception.message, :unprocessable_entity) end end ``` ## Controller Discovery Belt resolves controllers by: 1. Checking the app's namespace module (e.g., `MyApp::PostsController`) 2. Searching `Belt.all_controller_paths` No manual registration required. Controllers are auto-discovered from the `lambda/controllers/` directory. ## CORS CORS headers are handled automatically by `Belt::LambdaHandler`. Controllers don't need to set them manually. Configure allowed origins via environment variables: - `CORS_ALLOWED_ORIGINS` — comma-separated origins - `CUSTOMER_APP_DOMAIN` — primary app domain - `OPS_APP_DOMAIN` — internal tools domain ## See Also - `belt explain routing` — how requests reach controllers - `belt explain models` — ActiveItem ORM - `belt explain parameters` — strong parameters in detail --- # Data Seeding Belt provides two ways to get data into an environment without hand-crafting rows: copying data from another environment, and Rails-style seed files. ## `belt db:copy` — copy data between environments Copies DynamoDB table contents from one environment into another, matching tables by name after stripping each environment's `--` prefix. ```bash belt db:copy prod dev # copy prod data into dev belt db:copy prod dev --force # overwrite dev tables even if non-empty ``` By default, destination tables that already contain data are skipped — safe to re-run against a live environment. `--force` overwrites them instead. ### Cognito identity re-anchoring Cognito identities are **per-environment**: each environment has its own user pool, so the same person has a *different* `sub` in every environment. Any row that references a user by their `sub` (e.g. a membership's `cognito_sub`) has a reference that's meaningless in another environment — copy it verbatim and the row points at a user who doesn't exist in the destination pool, so it silently disappears (a copied project you can't see, a member who isn't there). `belt db:copy` handles this automatically: - The destination's own `users` table is **left untouched** — the destination pool is authoritative for who its users are and what `sub` each one has. - For every other table, any row carrying both an `email` and a `cognito_sub` has its `cognito_sub` **re-anchored** to the destination user with the same email. - A row whose email has no destination user yet has its stale `cognito_sub` **cleared**, so it reads as unclaimed (e.g. a pending invitation Belt binds on that person's first login) rather than dangling. ```bash belt db:copy prod dev # re-anchors identities (default) belt db:copy prod dev --no-remap-identity # copy cognito_sub refs verbatim ``` This relies on Belt's `cognito_authenticatable` convention: the users table is `--users`, its primary key is `id` (the Cognito `sub`), it carries an `email`, and `cognito_sub` is the foreign-key attribute referencing it. The same re-anchoring runs in the nested (PR-preview) environment deploy hook. ### Cross-account copies Source and destination environments often live in different AWS accounts (e.g. prod vs. dev). `belt db:copy` resolves the AWS profile for each side independently from `infrastructure//belt.rb` (`config.aws_profile`): ```ruby # infrastructure/prod/belt.rb Belt.configure do |config| config.aws_profile = "prod-readonly" end ``` Override either side explicitly if you don't want to rely on `belt.rb`: ```bash belt db:copy prod dev --from-profile prod-readonly --to-profile dev ``` ### How it works 1. Lists tables under each environment's prefix (`--`) using the AWS CLI (`aws dynamodb list-tables`) 2. Pairs up tables by matching suffix (e.g. `myapp-prod-posts` ↔ `myapp-dev-posts`) 3. Scans the source table and `batch-write-item`s into the destination, re-anchoring Cognito-sub foreign keys to the destination's users by email (unless `--no-remap-identity`; see above) 4. Skips (or overwrites, with `--force`) destination tables that already have items This is the same mechanism used by nested (PR-preview) environment deploys to seed a preview environment's tables from its parent — `belt db:copy` just exposes it as a standalone command for any two environments. ## `belt db:seed` — Rails-style seed file Mirrors `rails db:seed`. Loads `config/seeds.rb` in the same booted context `belt console` uses — your models (ActiveItem) are available, targeting the resolved environment's tables. ```bash belt db:seed # seeds dev, or $BELT_ENV if set belt db:seed dev01 # explicit environment belt db:seed prod # prompts for confirmation, like belt console prod ``` `config/seeds.rb` is a plain Ruby file: ```ruby # frozen_string_literal: true post = Post.create!(title: "Hello, world", body: "Seeded post") puts "Created post: #{post.id}" ``` ### Safety `belt db:seed` refuses to run if the target environment's tables already have data, to avoid clobbering a live environment (or accidentally reseeding one that's already loaded). Pass `--force` to seed anyway: ```bash belt db:seed dev01 --force ``` Because of this guard, seeds are typically run once against a fresh environment. If you want `seeds.rb` to be safely re-runnable regardless, write it idempotently (`find_or_create_by`-style) — `belt db:seed --force` does not enforce idempotency for you. ### Scaffolding `belt new` generates a starter `config/seeds.rb` with usage notes and an example. Existing apps can add the file manually — it's just a plain Ruby file, no generator required. ## See Also - `belt explain backups` — recovery-point snapshots, not data seeding - `belt explain console` — the app-booting mechanism `db:seed` reuses - `belt explain deployment` — nested environments and the parent → child copy hook --- # Deployment Belt deploys serverless applications to AWS using Terraform. The CLI wraps `terraform init/plan/apply` with conventions for environment management, Lambda packaging, and pre-deploy backups. ## Quick Deploy ```bash belt deploy # init → plan → apply (interactive) belt deploy prod --auto # skip confirmation prompt belt deploy prod --skip-backup # skip pre-deploy backup belt deploy --backup-only # create recovery point without deploying ``` ## First-Time Setup ```bash # 1. Create S3 bucket for Terraform state belt setup state # 2. Scaffold an environment belt generate environment dev01 # 3. Generate DynamoDB table definitions from schema belt setup tables dev01 # 4. Initialize and deploy belt deploy dev01 ``` ## Environment Structure Each environment lives in `infrastructure//`: ``` infrastructure/ ├── modules/ │ └── main/ # Shared Terraform module ├── dev01/ │ ├── main.tf # Module reference + provider │ ├── variables.tf # Variable declarations │ ├── terraform.tfvars # Environment-specific values │ ├── backend.tf # S3 state backend │ └── outputs.tf # Exported values └── prod/ └── ... ``` ## What Gets Deployed The Conveyor Belt Terraform provider reads your Ruby DSL and creates: 1. **API Gateway** — HTTP API with routes matching your DSL 2. **Lambda functions** — packaged Ruby code (one per `gateway`/`function` block) 3. **IAM roles** — least-privilege policies for DynamoDB table access 4. **CloudWatch logs** — log groups for each Lambda 5. **DynamoDB tables** — from your schema definition 6. **Custom domain** — if configured (Route53 + ACM certificate) ## Lambda Packaging Belt packages the `lambda/` directory plus vendored gems. For `path:` gems (local development), Belt materializes them into `vendor/cache` automatically. The Lambda entry point is specified in `config/lambda/.yml`: ```yaml handler: lambda/.lambda_handler runtime: ruby3.3 timeout: 30 memory: 256 environment: ENVIRONMENT: ${var.environment} ``` ## Route Manifests Before deploying, generate the route manifest used at runtime: ```bash belt routes --namespace api # → writes lambda/lib/routes/api_routes.rb ``` This is typically done automatically by `belt deploy`. ## Sidecar Lambda Zips Conveyor Belt packages Ruby lambdas. Some apps also have standalone `aws_lambda_function` resources (Node image processors, Cognito triggers) whose Terraform uses `filename` + `filebase64sha256` pointing at a zip on disk. `belt deploy`, `belt plan`, and `belt apply` scan `infrastructure/**/*.tf` for those zip paths and build any that are missing (or whose source hash changed): - Directory with `package.json` — `npm ci` in Docker (`linux/amd64`, Lambda Node image) so native addons like `sharp` match Lambda, then zip - Plain JS directory — zip the `.js` / `.mjs` files The zip lives next to the source (`image-processor/image-processor.zip`). This is the same pre-terraform step Stowzilla's `scripts/deploy.sh` does for the image processor. Existing zips with no hash file are left alone. Docker must be running for Node packages. ## Terraform Commands Belt wraps Terraform with environment awareness: ```bash belt init # terraform init with correct backend belt plan # terraform plan belt apply # terraform apply belt destroy # terraform destroy (careful!) belt output # terraform output ``` Or use `belt deploy ` which runs init → plan → apply in sequence. ## Pre-Deploy Backups Configure in `infrastructure//belt.rb`: ```ruby Belt.configure do |config| config.backups do dynamodb :all retention snapshots: 90 end end ``` Backups run automatically before each deploy (DynamoDB snapshots, Cognito exports, S3 syncs). See `belt explain backups` for full documentation. ## Environment Variables Set `BELT_ENV` to avoid typing the environment every time: ```bash export BELT_ENV=dev01 belt deploy # uses BELT_ENV belt deploy prod # explicit arg wins ``` ## Frontend Deployment For apps with a frontend: ```bash belt deploy frontend # build + deploy all frontends belt deploy frontend --frontend ops # deploy one named frontend belt frontend env # generate .env from Terraform outputs belt frontend list # show configured frontends ``` The directory is `frontend/` by default. Multiple SPAs are declared in `config/frontends.yml` — see `belt explain frontend`. Full `belt deploy ` deploys every configured frontend after terraform apply. ## See Also - `belt explain routing` — how routes map to infrastructure - `belt explain backups` — pre-deploy backup configuration - `belt doctor` — check system dependencies before deploying --- # Frontends Belt can host one or more JavaScript SPAs (React, Vue, or Svelte) next to the Lambda API. A typical app has a single `frontend/` directory. Some apps — Stowzilla is the example that drove this — have several independent SPAs (customer, ops, partners) that share the same models and API. ## Single frontend (default) ```bash belt new blog --frontend react # or, in an existing app: belt generate frontend react ``` That creates `frontend/`, S3 + CloudFront infrastructure, and wires CORS so the SPA can call the API. No config file is required. ```bash belt server # local Vite dev server belt frontend env dev01 # write frontend/.env from terraform outputs belt deploy frontend dev01 # npm ci → build → S3 sync → CloudFront invalidation ``` ## Multiple frontends Declare them in `config/frontends.yml` (or `.belt/frontends.yml`): ```yaml frontends: customer: path: app dist: build default: true bucket_output: web_app_bucket_name url_output: web_app_url cloudfront_domain_output: web_app_cloudfront_domain ops: path: ops-app dist: build bucket_output: ops_app_bucket_name url_output: ops_app_url cloudfront_domain_output: ops_app_cloudfront_domain ``` `path` is the directory. `dist` is the build output (`dist` by default; belt also auto-detects `build/` after `npm run build`). Terraform output names default to `frontend_*` for the `frontend` app and `{name}_frontend_*` for others — override them when existing infrastructure uses different names. If terraform exports a CloudFront **domain** instead of a distribution ID, set `cloudfront_domain_output` and skip `distribution_output`. Belt looks the ID up via AWS and will not probe `{name}_frontend_distribution_id`. ```bash belt frontend list belt generate frontend react --name ops --path ops-app belt generate views bag --frontend ops belt generate scaffold order --frontend customer belt server --frontend ops belt frontend env dev01 --frontend customer belt deploy frontend dev01 # all configured frontends belt deploy frontend dev01 --frontend ops # just ops ``` If several frontends exist and you omit `--frontend`, generators and `belt server` use the one marked `default: true`. If none is default, they ask you to pick. `belt deploy frontend ` with no `--frontend` deploys every frontend that has a `package.json`. Full `belt deploy ` does the same after terraform apply. ## Env maps Each frontend can have its own `env.yml` mapping process env names to terraform outputs: ```yaml # app/env.yml VITE_API_URL: api_url VITE_COGNITO_USER_POOL_ID: cognito_user_pool_id VITE_COGNITO_CLIENT_ID: cognito_client_id ``` The default `frontend/` directory also accepts `.belt/frontend_env.yml` as a fallback. See the existing env-map behavior: only mapped keys are written into `.env`; missing terraform outputs warn and do not clobber local values. ## Infrastructure `belt setup frontend` (and `belt generate frontend`) writes S3 + CloudFront into `infrastructure/modules/app/frontend.tf`. Additional named frontends get `{name}_frontend.tf` with unique resource names and outputs (`ops_frontend_bucket_name`, …). Extra frontends get a CloudFront URL only — custom DNS stays on the default frontend unless you add records yourself. CORS: each CloudFront domain is added to `frontend_urls` on the conveyor-belt resource so SPA → API calls work. ## See Also - `belt explain deployment` — how frontend deploy fits into `belt deploy` - `belt explain generators` — `belt generate frontend` / views - `belt explain structure` — where frontend directories live --- # Generators Belt generators scaffold code and infrastructure for common patterns. Run `belt generate --help` to see all available generators (built-in + plugins). ## Built-in Generators ### Resource (scaffold) Creates model + controller + routes + schema entry: ```bash belt generate resource post title:string body:text user_id:string status:string belt g resource comment body:text author:string post_id:string ``` This is the most common generator — it sets up everything for a new REST endpoint. ### Model Creates just the model file + schema entry: ```bash belt generate model post title:string body:text user_id:string belt g model payment amount:number currency:string ``` ### Controller Creates just the controller: ```bash belt generate controller posts belt g controller admin/users ``` ### Environment Scaffolds a new Terraform environment directory: ```bash belt generate environment staging belt g environment prod ``` ### Frontend Adds a frontend framework to the project: ```bash belt generate frontend react belt generate frontend vue belt generate frontend svelte belt generate frontend react --name ops --path ops-app ``` `--name` and `--path` scaffold an additional SPA and register it in `config/frontends.yml`. See `belt explain frontend`. ### Views Generates React pages for a resource's REST actions: ```bash belt generate views post title:string body:text status:string belt g views comment body:text author:string belt g views bag --frontend ops ``` When the app has multiple frontends, pass `--frontend ` (or mark one `default: true` in `config/frontends.yml`). ### Auth Sets up Cognito authentication: ```bash belt generate auth belt g auth --provider cognito ``` ## Field Types When specifying fields, use these types: | Type | DynamoDB Type | Notes | |------|---------------|-------| | `string` | S | Default if no type given | | `text` | S | Same as string (semantic hint) | | `number` | N | Numeric values | | `boolean` | BOOL | True/false | | `list` | L | Array values | | `map` | M | Nested objects | ## Destroying Generated Code Every generator has a matching destroy command: ```bash belt destroy resource post belt destroy model comment belt destroy controller admin/users belt destroy environment staging belt destroy frontend belt destroy frontend --frontend ops belt destroy views post ``` ### Non-interactive environment teardown `belt destroy environment` is interactive by default: it prompts before running `terraform destroy` and again before deleting local files. For CI pipelines and agents that spin up ephemeral per-PR environments and tear them down on merge, use `--full`: ```bash belt destroy environment pr-1234 --full ``` `--full` (alias `-y`) runs the whole teardown with zero prompts: it **always runs `terraform destroy`** (when terraform state is present), then deletes the local `infrastructure//` files. Flag comparison: | Flag | terraform destroy | Deletes local files | Prompts | |------|-------------------|---------------------|---------| | _(none)_ | Prompted | Prompted | Yes | | `--full` / `-y` | Always (if state exists) | Yes | No | | `--force` / `-f` | **Skipped** (leaves cloud resources) | Yes | No | | `--skip-terraform` | Never | Yes | Final delete only | > `--force` is for the "just clean up my local files" case — it intentionally > leaves cloud infrastructure running. Reach for `--full` when you want the > environment fully gone. ## Plugin Generators Gems that follow the Belt plugin contract are auto-discovered: ```bash belt generate messaging # from belt-messaging gem belt generate pay # from belt-pay gem belt generate --help # lists all available generators ``` ## Generator Workflow (Common Pattern) ```bash # 1. Generate the resource belt generate resource order item_id:string quantity:number total:number status:string # 2. Generate DynamoDB table from schema belt setup tables dev01 # 3. Deploy belt deploy dev01 ``` ## See Also - `belt explain models` — how generated models work - `belt explain controllers` — how generated controllers work - `belt explain routing` — how generated routes work - `belt explain deployment` — deploying generated code --- # Lambda Handler `Belt::LambdaHandler` is the module you include in your Lambda entry point. It provides the `lambda_handler` method that AWS Lambda invokes, wrapping your application logic with observability, CORS, and error handling. ## Basic Usage ```ruby require "belt" include Belt::LambdaHandler ROUTER = Belt::ActionRouter.new(routes: Routes::API, gateway: "api") def execute(path:, body:, event:) ROUTER.route(event: event, body: body) end ``` ## What It Does When a request arrives, `lambda_handler` automatically: 1. **Initializes observability** — structured logging + CloudWatch metrics 2. **Handles OPTIONS preflight** — returns CORS headers immediately 3. **Parses the request body** — JSON string → Ruby hash 4. **Calls your `execute` method** — with `path:`, `body:`, and `event:` 5. **Catches unhandled errors** — returns a CORS-enabled error response 6. **Emits metrics** — request count, latency, error count via EMF ## The `execute` Method You must define `execute` in your Lambda file. It receives: | Param | Description | |-------|-------------| | `path:` | The request path (e.g., `/posts/123`) | | `body:` | Parsed request body (Hash or nil) | | `event:` | Full API Gateway event (for headers, query params, auth context) | Return value should be a response hash: `{ statusCode:, headers:, body: }`. Typically you just call `ROUTER.route(...)` which returns the correct format. ## Multiple Lambdas If your app has multiple Lambda functions (via `function` in routes): ```ruby # lambda/worker.rb require "belt" include Belt::LambdaHandler ROUTER = Belt::ActionRouter.new(routes: Routes::WORKER, gateway: "worker") def execute(path:, body:, event:) ROUTER.route(event: event, body: body) end ``` Each Lambda entry point gets its own route manifest and gateway name. ## Configuration Configure via `config/lambda/.yml`: ```yaml handler: lambda/api.lambda_handler runtime: ruby3.3 timeout: 30 memory: 256 layers: - ${var.ruby_layer_arn} environment: ENVIRONMENT: ${var.environment} APP_NAME: my-app ERROR_NOTIFICATION_TOPIC_ARN: ${var.sns_topic_arn} ``` ## Observability The handler sets up `Belt::Observability::Logger` and `Belt::Observability::Metrics` automatically. Use them anywhere in your app: ```ruby Belt::Observability::Logger.info("Order created", order_id: order.id) Belt::Observability::Metrics.track_event("OrderCreated", model: "Order") ``` ## Environment Variables | Variable | Purpose | |----------|---------| | `ENVIRONMENT` | Controls error verbosity (`dev*`, `local`, `test` = verbose) | | `BELT_METRICS_NAMESPACE` | CloudWatch namespace (default: `Belt`) | | `ACTION` | Service name for logging | | `ERROR_NOTIFICATION_TOPIC_ARN` | SNS topic for error alerts | | `CORS_ALLOWED_ORIGINS` | Comma-separated allowed origins | ## See Also - `belt explain routing` — how ActionRouter dispatches requests - `belt explain controllers` — how dispatched requests are handled - `belt explain deployment` — Lambda packaging and configuration --- # Models Belt uses **ActiveItem** as its ORM for DynamoDB. Models inherit from `ActiveItem::Base` (typically via an `ApplicationRecord` base class). ## Basic Model ```ruby class Post < ApplicationRecord attr_accessor :id, :user_id, :title, :body, :status, :created_at, :updated_at validates :title, presence: true validates :status, inclusion: { in: %w[draft published] }, allow_nil: true before_create { self.id ||= SecureRandom.uuid } before_save { self.updated_at = Time.now.iso8601 } end ``` ## Table Naming Table names are derived from the environment: `{APP_NAME}-{ENVIRONMENT}-{pluralized_model}` Example: app "blog", environment "prod", model "Post" → `blog-prod-posts` ## Schema Definition Define table structure in `infrastructure/schema.tf.rb`: ```ruby Belt.application.schema.define do model :post do partition_key :id, :string global_secondary_index :UserIndex, partition_key: :user_id global_secondary_index :StatusIndex, partition_key: :status, sort_key: :created_at end end ``` ## CRUD Operations ### Create ```ruby post = Post.create!(title: "Hello", body: "World", user_id: "u-123") # or post = Post.new(title: "Hello") post.save! ``` ### Read ```ruby post = Post.find("post-id-123") # by primary key posts = Post.all # scan (use sparingly) posts = Post.where(user_id: "u-123", index: "UserIndex") post = Post.find_by(user_id: "u-123", index: "UserIndex") # first match ``` ### Update ```ruby post = Post.find("post-id-123") post.update(title: "New Title") # or post.title = "New Title" post.save! ``` ### Delete ```ruby post = Post.find("post-id-123") post.destroy ``` ## Query Patterns ### Using Indexes ```ruby # Query a GSI Post.where(user_id: "u-123", index: "UserIndex") # With sort key conditions Post.where( status: "published", created_at: { gte: "2024-01-01" }, index: "StatusIndex" ) ``` ### Count and Existence ```ruby Post.count # total items (scan) Post.exists?("post-id-123") # check by primary key ``` ## Validations ActiveItem supports ActiveModel-style validations: ```ruby validates :title, presence: true validates :email, format: { with: URI::MailTo::EMAIL_REGEXP } validates :status, inclusion: { in: %w[active inactive] } validates :age, numericality: { greater_than: 0 } ``` ## Callbacks ```ruby before_create { self.id ||= SecureRandom.uuid } before_save { self.updated_at = Time.now.iso8601 } after_create { notify_subscribers } before_destroy { cleanup_associations } ``` ## Associations (Manual) DynamoDB doesn't have joins. Model relationships manually: ```ruby class Post < ApplicationRecord def comments Comment.where(post_id: id, index: "PostIndex") end def author User.find(user_id) end end ``` ## Transactions ```ruby ActiveItem::Transaction.write do |tx| tx.put(post) tx.put(comment) tx.delete(draft) end ``` ## Generating Models ```bash belt generate model post title:string body:text user_id:string belt generate resource comment body:text author:string post_id:string ``` The resource generator creates model + controller + routes + schema entry. ## See Also - `belt explain controllers` — using models in controllers - `belt explain deployment` — how schema becomes infrastructure - `belt explain queries` — advanced DynamoDB query patterns --- # Observability Belt provides structured logging and CloudWatch metrics out of the box. These are initialized automatically by `Belt::LambdaHandler` — no setup required. ## Logging Access the logger from anywhere: ```ruby Belt::Observability::Logger.info("Order placed", order_id: "o-123", total: 49.99) Belt::Observability::Logger.warn("Retry attempt", attempt: 3, service: "payments") Belt::Observability::Logger.error("Payment failed", error: e.message, order_id: "o-123") ``` Logs are structured JSON, compatible with CloudWatch Logs Insights: ```json { "level": "INFO", "message": "Order placed", "order_id": "o-123", "total": 49.99, "timestamp": "2024-01-15T10:30:00Z", "service": "api" } ``` ## Metrics Track custom events via CloudWatch Embedded Metric Format (EMF): ```ruby Belt::Observability::Metrics.track_event("OrderCreated", model: "Order") Belt::Observability::Metrics.track_event("PaymentProcessed", amount: 49.99) ``` ### Built-in Metrics The Lambda handler automatically emits: - `RequestCount` — per invocation - `ErrorCount` — on unhandled exceptions - `Latency` — request duration in milliseconds ### Namespace Metrics are published under the namespace set by `BELT_METRICS_NAMESPACE` (defaults to `Belt`). View them in CloudWatch → Metrics → Custom namespaces. ## Error Alerting Set `ERROR_NOTIFICATION_TOPIC_ARN` to an SNS topic ARN. Unhandled errors will publish a notification with error details (message, backtrace, request context). ```yaml # config/lambda/api.yml environment: ERROR_NOTIFICATION_TOPIC_ARN: ${var.sns_topic_arn} ``` ## CloudWatch Logs Insights Query structured logs: ``` fields @timestamp, message, order_id | filter level = "ERROR" | sort @timestamp desc | limit 20 ``` ## Viewing Logs Locally ```bash belt logs api # tail logs for the "api" Lambda belt logs api -f # follow (live tail) belt logs api -s 30m # last 30 minutes belt logs worker -e prod # specific environment ``` ## Environment Variables | Variable | Purpose | |----------|---------| | `BELT_METRICS_NAMESPACE` | CloudWatch metrics namespace (default: `Belt`) | | `ACTION` | Service name in log entries (falls back to function name) | | `ERROR_NOTIFICATION_TOPIC_ARN` | SNS topic for error alerts | | `ENVIRONMENT` | Controls verbose errors (`dev*`/`local`/`test` = verbose) | ## See Also - `belt explain lambda_handler` — how observability is initialized - `belt explain deployment` — configuring environment variables - `belt logs --help` — full CLI options for log viewing --- # Plugins Belt plugins are separate gems that extend the CLI and runtime. They're discovered automatically — no registration file or initializer hook needed. ## Using a Plugin ```ruby # Gemfile gem "belt-messaging" ``` ```bash bundle install belt generate messaging # run the plugin's generator belt destroy messaging # remove what the generator created ``` ## Available Plugins | Gem | Purpose | |-----|---------| | `belt-messaging` | Two-way SMS via AWS End User Messaging | | `belt-pay` | Stripe payments & subscriptions | ## Creating a Plugin Scaffold a new plugin gem: ```bash belt plugin new notifications belt plugin new pay --path ~/Code --summary "Stripe payments for Belt" ``` This creates a gem with the correct structure and Belt integration points. ## Plugin Discovery Contract Belt discovers plugins automatically when: 1. Gem is in the app's `Gemfile` and bundled 2. Gem has a file at `lib/belt/generators/_generator.rb` 3. Class is `Belt::Generators::Generator` 4. Class implements `.run(args)` (required) Optional: `.destroy(args)` and `.description` for destroy path and help text. ## Generator Checklist A good plugin generator typically installs: 1. **Terraform module** → `infrastructure/modules//` 2. **Lambda config** → `config/lambda/.yml` 3. **Lambda entrypoint** → `lambda/.rb` using `Belt::LambdaHandler` 4. **Routes / schema** → inject into routes or schema when needed 5. **Optional overrides** → `--controllers` flag for app-local subclasses 6. **Destroy path** → removes everything the generator created 7. **Help text** → `.description` + `--help` ## Plugin Layout ``` belt-messaging/ ├── belt-messaging.gemspec ├── lib/ │ ├── belt-messaging.rb # require entrypoint │ └── belt/ │ ├── messaging.rb # Runtime API │ ├── messaging/ │ │ ├── configuration.rb │ │ ├── version.rb │ │ ├── controllers/ # Default controllers │ │ └── templates/ # ERB templates for generator │ └── generators/ │ └── messaging_generator.rb # ← auto-discovered └── spec/ ``` ## Development Workflow Point a Belt app at your plugin during development: ```ruby # In the app Gemfile gem "belt-notifications", path: "../belt-notifications" ``` `belt deploy` detects `path:` gems and vendors them into `vendor/cache` for Lambda packaging. ## See Also - `belt explain generators` — built-in generators - `belt plugin new --help` — scaffold options --- # Routing Belt routes map HTTP requests to controller actions. Routes are defined in `config/routes.rb` using a Ruby DSL that mirrors infrastructure (API Gateway + Lambda). ## Defining Routes ```ruby Belt.application.routes.draw do gateway :api do resources :posts resources :comments, only: [:index, :create] resource :profile, only: [:show, :update] get "health", action: :health end end ``` ## Route DSL Keywords | Keyword | Purpose | Creates Lambda? | |---------|---------|-----------------| | `gateway` | Creates an API Gateway + default Lambda | Yes | | `function` | Routes to a different Lambda (overrides gateway default) | Yes | | `namespace` | Adds path prefix + controller module nesting | No | | `scope` | Flexible path/module/auth grouping | No | ## How `resources` Maps to Verbs `resources :posts` generates: | Verb | Path | Action | |------|------|--------| | GET | /posts | index | | POST | /posts | create | | GET | /posts/{post_id} | show | | PUT | /posts/{post_id} | update | | DELETE | /posts/{post_id} | destroy | **Note:** Belt uses PUT, not PATCH, for updates. Use `only:` or `except:` to limit generated routes: ```ruby resources :posts, only: [:index, :show, :create] resources :comments, except: [:destroy] ``` ## Singular Resources `resource :profile` (no `:id` in the path): | Verb | Path | Action | |------|------|--------| | GET | /profile | show | | PUT | /profile | update | | POST | /profile | create | | DELETE | /profile | destroy | ## Nested Resource DSL Inside a `resources` block, you can use Rails-like `member`, `collection`, `scope`, `namespace`, and singular `resource` for DRYer route definitions: ```ruby Belt.application.routes.draw do gateway :api do resources :projects do # Singular nested resource (no :id in path) resource :billing, only: [:show], tables: [:memberships] resource :token_usage, only: [:show] # Member routes (include /:id/) resources :webhooks do member do post :test # → POST /projects/:project_id/webhooks/:webhook_id/test end end # Collection routes (no /:id/) resources :surfaces do collection do get :teams # → GET /projects/:project_id/surfaces/teams end member do put :assign # → PUT /projects/:project_id/surfaces/:surface_id/assign end end # Scope: groups routes with shared options scope path: 'billing', controller: :billing, tables: [:memberships] do get '/', action: :show # → GET /projects/:project_id/billing post :checkout # → POST /projects/:project_id/billing/checkout post :subscribe post :cancel end # Namespace: adds path prefix AND controller module namespace :admin do resources :users # → /projects/:project_id/admin/users → admin/users controller end end end end ``` ### Action Inference Symbol paths automatically become the action name: ```ruby member do post :test # path: /test, action: :test end post 'mark-complete' # path: /mark-complete, action: :mark_complete ``` Hyphens in path segments convert to underscores in action names. ### Controller Inheritance Routes inside `member` and `collection` blocks inherit the parent resource's controller: ```ruby resources :surfaces do member do put :assign # controller: surfaces, action: assign end end ``` Override with the `controller:` option: ```ruby member do get :billing, controller: :project_billing end ``` ### Namespace vs Scope | Feature | `namespace` | `scope` | |---------|-------------|---------| | Path prefix | ✓ | Optional (`path:`) | | Controller module | ✓ | Optional (`module:`) | | Use case | Rails-like module nesting | Flexible grouping | ```ruby # Namespace: path + controller module namespace :admin do resources :users # → /admin/users → admin/users controller end # Scope with path only (no controller change) scope path: 'v2' do resources :users # → /v2/users → users controller end # Scope with module only (no path change) scope module: 'legacy' do resources :users # → /users → legacy/users controller end ``` ## Namespace and Scope ```ruby gateway :api do # Namespace: adds path prefix AND controller module namespace :admin do resources :users # → /admin/users → Admin::UsersController end # Scope: flexible grouping without full nesting scope path: 'v2', module: 'legacy' do resources :widgets # → /v2/widgets → Legacy::WidgetsController end end ``` ## Multiple Lambdas Use `function` when routes should be handled by a separate Lambda: ```ruby gateway :api do resources :posts # → handled by "api" Lambda function :worker do resources :jobs # → handled by "worker" Lambda end end ``` ## Authentication ```ruby gateway :api, auth: :cognito do resources :posts # requires cognito auth get "health", action: :health, auth: :none # public end ``` ## Table Access Declare which DynamoDB tables a route accesses (used by Terraform for IAM): ```ruby resources :posts, tables: [:posts, :comments] ``` ## Inspecting Routes ```bash belt routes # display all routes belt routes -g posts # filter by pattern belt routes -f json # machine-readable output belt routes --namespace api # generate Ruby route manifest ``` ## Runtime Routing The Lambda entry point uses `Belt::ActionRouter` with the generated route manifest: ```ruby require "belt" include Belt::LambdaHandler ROUTER = Belt::ActionRouter.new(routes: Routes::API, gateway: "api") def execute(path:, body:, event:) ROUTER.route(event: event, body: body) end ``` The router matches the incoming HTTP method + path against the manifest and dispatches to the appropriate controller and action. ## See Also - `belt explain controllers` — how controllers handle requests - `belt explain deployment` — how routes become infrastructure - `belt routes --help` — full CLI options --- # Project Structure A Belt application follows a conventional directory layout. Understanding this structure helps you navigate and extend the app effectively. ## Standard Layout ``` my-app/ ├── lambda/ # Application code (deployed to Lambda) │ ├── api.rb # Lambda entry point │ ├── config/ │ │ └── environment.rb # App boot file (models, libs, AWS setup) │ ├── controllers/ │ │ ├── application_controller.rb │ │ └── my_app/ # Namespaced controllers │ │ ├── posts_controller.rb │ │ └── admin/ │ │ └── users_controller.rb │ ├── models/ │ │ ├── application_record.rb # Base model class │ │ ├── post.rb │ │ └── concerns/ # Shared model behavior │ ├── lib/ │ │ └── routes/ # Auto-generated route manifests │ │ └── api_routes.rb │ └── Gemfile # Lambda-specific dependencies ├── config/ │ ├── routes.rb # Route definitions (DSL) │ ├── contracts.rb # API request/response contracts │ └── lambda/ # Per-lambda configuration │ └── api.yml # Timeout, memory, env vars ├── infrastructure/ │ ├── modules/ # Shared Terraform modules │ │ └── main/ │ ├── schema.tf.rb # DynamoDB table schema (Ruby DSL) │ ├── dev01/ # Per-environment Terraform │ │ ├── main.tf │ │ ├── variables.tf │ │ ├── terraform.tfvars │ │ ├── backend.tf │ │ └── outputs.tf │ └── prod/ │ └── ... ├── frontend/ # Optional frontend (React/Vue/Svelte) ├── config/frontends.yml # Optional: multiple named frontends ├── Gemfile # Project-level dependencies (CLI, dev tools) ├── Rakefile # Rake tasks ├── AGENTS.md # AI agent guide └── .gitignore ``` ## Key Files ### `lambda/api.rb` — Lambda Entry Point The file AWS Lambda invokes. Includes `Belt::LambdaHandler` and defines `execute`. ### `lambda/config/environment.rb` — Boot File Loaded by both Lambda and `belt console`. Sets up AWS clients, requires models, configures the app. Everything your app needs to run. ### `config/routes.rb` — Route Definitions The Ruby DSL that defines your API. Read by both the Conveyor Belt Terraform provider (for infrastructure) and `belt routes` (for manifests). ### `config/lambda/api.yml` — Lambda Config Per-function settings: handler path, runtime, timeout, memory, environment variables, layers, triggers. ### `infrastructure/schema.tf.rb` — Table Schema DynamoDB table definitions in Ruby DSL. Used by `belt setup tables` to generate Terraform resources. ## Conventions - **Controller namespacing**: Controllers live under `lambda/controllers//` to avoid conflicts across modules. - **Route manifests**: Generated files in `lambda/lib/routes/` — don't edit manually. Regenerate with `belt routes --namespace `. - **Two Gemfiles**: Project root Gemfile is for dev tools (belt CLI, rspec). `lambda/Gemfile` is what gets packaged into the Lambda. - **Config over code**: Lambda configuration (timeout, memory, env vars) goes in YAML files, not hardcoded in Terraform. - **Frontends**: Default directory is `frontend/`. Multiple SPAs (customer + ops, etc.) are declared in `config/frontends.yml`. See `belt explain frontend`. ## See Also - `belt explain routing` — how routes.rb maps to infrastructure - `belt explain controllers` — controller conventions - `belt explain deployment` — how this structure gets deployed