belt 0.2.9 → 0.2.11
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +119 -0
- data/README.md +217 -8
- data/lib/belt/action_router.rb +33 -5
- data/lib/belt/assets/welcome.css +119 -47
- data/lib/belt/cli/bucket_security.rb +10 -4
- data/lib/belt/cli/deploy_command.rb +48 -0
- data/lib/belt/cli/destroy_command.rb +119 -10
- data/lib/belt/cli/doctor_command.rb +208 -0
- data/lib/belt/cli/environment_command.rb +38 -6
- data/lib/belt/cli/frontend_command.rb +25 -12
- data/lib/belt/cli/frontend_setup_command.rb +41 -0
- data/lib/belt/cli/new_command.rb +106 -38
- data/lib/belt/cli/path_gem_materializer.rb +141 -0
- data/lib/belt/cli/plugin_command.rb +221 -0
- data/lib/belt/cli/setup_command.rb +35 -10
- data/lib/belt/cli.rb +8 -0
- data/lib/belt/configuration.rb +30 -0
- data/lib/belt/controllers/welcome_controller.rb +69 -2
- data/lib/belt/helpers/cors_origin.rb +19 -2
- data/lib/belt/helpers/response.rb +48 -6
- data/lib/belt/http_status.rb +89 -0
- data/lib/belt/lambda_handler.rb +14 -5
- data/lib/belt/rendering.rb +1 -0
- data/lib/belt/version.rb +1 -1
- data/lib/belt/views/welcome/show.html.erb +31 -31
- data/lib/belt.rb +16 -0
- data/lib/belt_controller/base.rb +24 -1
- data/lib/belt_controller/implicit_response.rb +104 -0
- data/lib/templates/environment/backend.tf.erb +1 -1
- data/lib/templates/frontend/react/src/index.css +4 -4
- data/lib/templates/frontend/react/src/lib/apiClient.js.erb +20 -3
- data/lib/templates/frontend/react/src/pages/Home.jsx.erb +283 -4
- data/lib/templates/module/main.tf.erb +13 -3
- data/lib/templates/new_app/config/routes.tf.rb.erb +2 -1
- data/lib/templates/plugin/AGENTS.md.erb +135 -0
- data/lib/templates/plugin/CHANGELOG.md.erb +5 -0
- data/lib/templates/plugin/Gemfile.erb +11 -0
- data/lib/templates/plugin/LICENSE.erb +21 -0
- data/lib/templates/plugin/README.md.erb +63 -0
- data/lib/templates/plugin/Rakefile.erb +7 -0
- data/lib/templates/plugin/gemspec.erb +26 -0
- data/lib/templates/plugin/gitignore.erb +11 -0
- data/lib/templates/plugin/lib/belt/generators/generator.rb.erb +122 -0
- data/lib/templates/plugin/lib/belt/module/configuration.rb.erb +15 -0
- data/lib/templates/plugin/lib/belt/module/version.rb.erb +7 -0
- data/lib/templates/plugin/lib/belt/module.rb.erb +27 -0
- data/lib/templates/plugin/lib/entry.rb.erb +3 -0
- data/lib/templates/plugin/rspec.erb +3 -0
- data/lib/templates/plugin/spec/configuration_spec.rb.erb +17 -0
- data/lib/templates/plugin/spec/spec_helper.rb.erb +19 -0
- metadata +37 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 43ae84461595be08ca6caada3afd148ccfa0db94bfc2e2bd864a9b466d41f087
|
|
4
|
+
data.tar.gz: 1f33826b8c7a7b068458db8ca38aa9910079e16f04c3c5c727550f084ed85075
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 81532edca53a084bdd6aabf5e7027fd1653ec0fc4c592b0bdb60374283c7c59512a2217405d3a19f32a975bcbcbbebdf9f9a97d952a25b1567e05af8e2cb879d
|
|
7
|
+
data.tar.gz: d5ff609d6526f8cc59964df6da091a43150eca9d1c6f625dee27b070db0c56bd90ef58ef11c42f57e27259f169c477a4c1c548f9ca0f4c4f916c8bea540763bb
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,124 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.2.11
|
|
6
|
+
|
|
7
|
+
### Security hardening
|
|
8
|
+
|
|
9
|
+
- **CORS origin validation**: format validation (scheme required, no paths/queries/fragments), length limit (253 chars) to prevent ReDoS, restrict wildcard matching to valid subdomain chars `[a-z0-9-]`.
|
|
10
|
+
- **Security headers** on all responses: `X-Content-Type-Options: nosniff`, `Vary: Origin`; HTML responses also get `X-Frame-Options: DENY` and `Referrer-Policy: strict-origin-when-cross-origin`.
|
|
11
|
+
- **Path traversal protection** in `ActionRouter`: strict regex for controller names, reject `..` sequences / absolute paths / backslashes, `realpath` validation in `resolve_from_paths`.
|
|
12
|
+
- **Request body size limit** (10 MB): returns 413 for oversized bodies; proper 400 for invalid JSON.
|
|
13
|
+
|
|
14
|
+
### Safe `belt destroy environment`
|
|
15
|
+
|
|
16
|
+
Previously `belt destroy environment <name>` immediately deleted the infrastructure directory with no checks — leaving orphaned AWS resources.
|
|
17
|
+
|
|
18
|
+
Now the command:
|
|
19
|
+
1. Checks if Terraform state exists (local `.terraform` dir or remote state)
|
|
20
|
+
2. If state found, prompts user to run `terraform destroy` first
|
|
21
|
+
3. Requires explicit confirmation before deleting files
|
|
22
|
+
4. Supports `--force` (skip prompts) and `--skip-terraform` flags
|
|
23
|
+
|
|
24
|
+
### Environment generator state bucket fix
|
|
25
|
+
|
|
26
|
+
The environment generator was writing `belt-terraform-state` as the backend bucket name, missing the account-ID suffix. Now resolves via sibling `backend.tf`, STS `get-caller-identity`, or placeholder (for later `belt setup state` patch).
|
|
27
|
+
|
|
28
|
+
### Contributing & plugins
|
|
29
|
+
|
|
30
|
+
- README: **Plugins** and **Contributing** sections — how to contribute to belt, how plugins register via `GeneratorRegistry`, layout used by `belt-messaging` / `belt-pay`, generator checklist for humans and agents.
|
|
31
|
+
- **`belt plugin new <name>`** — scaffold a new plugin gem (gemspec, `Belt::<Name>` module, generator stub, RSpec, README, AGENTS.md), similar to `rails plugin new`.
|
|
32
|
+
- **`AGENTS.md`** at the belt gem root — agent-oriented map of core layout, CLI, and the plugin/GeneratorRegistry contract (complements README).
|
|
33
|
+
- Plugin scaffold includes **`AGENTS.md`** so new plugins match the agent guidance pattern already used by `belt new` apps.
|
|
34
|
+
|
|
35
|
+
## 0.2.10
|
|
36
|
+
|
|
37
|
+
### Quiet `belt new` (with optional verbose)
|
|
38
|
+
|
|
39
|
+
`belt new` prints a short phase summary by default (skeleton, environments, frontend,
|
|
40
|
+
bundle, state bucket) instead of every path plus AWS/npm noise.
|
|
41
|
+
|
|
42
|
+
Use `-v` / `--verbose` for Rails-style per-file `create` lines when you want the
|
|
43
|
+
inventory. Nested generators skip their own next-steps banners; the final success
|
|
44
|
+
block still owns next steps. State-bucket setup stays non-interactive either way.
|
|
45
|
+
|
|
46
|
+
### State bucket naming (global uniqueness)
|
|
47
|
+
|
|
48
|
+
S3 bucket names are a **global** namespace across all AWS accounts. The previous default
|
|
49
|
+
`belt-terraform-state` only works for the first account that creates it — everyone else
|
|
50
|
+
hits "owned by a different AWS account".
|
|
51
|
+
|
|
52
|
+
**Fix:** shared bucket is still one-per-account (all apps share it), but the name is now:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
belt-terraform-state-<account_id>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- Still one bucket for all belt apps in an account (state key: `<app>/<env>/terraform.tfstate`)
|
|
59
|
+
- `--bucket` still overrides when you want a custom name
|
|
60
|
+
- `belt setup state` rewrites `backend.tf` with the resolved name
|
|
61
|
+
- Existing installs that already own `belt-terraform-state` can keep using `--bucket belt-terraform-state`
|
|
62
|
+
|
|
63
|
+
### Implicit responses (Rails-style assigns)
|
|
64
|
+
|
|
65
|
+
Controllers can set instance variables and skip the explicit `success_response` call:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
def index
|
|
69
|
+
@posts = Post.all
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def show
|
|
73
|
+
@post = Post.find(params[:id])
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Belt auto-builds `success_response({ posts: [...] })` / `{ post: {...} }` from assigns set
|
|
78
|
+
during the action. Serialization:
|
|
79
|
+
|
|
80
|
+
- Models → `to_h` (ActiveItem already defines this)
|
|
81
|
+
- `ActiveItem::Relation` / Enumerable → map each record via `to_h`
|
|
82
|
+
- Nested hashes/arrays recurse
|
|
83
|
+
|
|
84
|
+
Explicit `success_response` / `error_response` / `html_response` / `render` / `head` still win
|
|
85
|
+
when returned. Rescue handlers still use `error_response`.
|
|
86
|
+
|
|
87
|
+
### Default format (`:json` / `:html`)
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
# App-wide (lambda/config/environment.rb)
|
|
91
|
+
Belt.configure do |c|
|
|
92
|
+
c.default_format = :json # default — API first
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Per-controller override (inherits down the chain)
|
|
96
|
+
class PagesController < ApplicationController
|
|
97
|
+
self.default_format = :html
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
| `default_format` | Implicit response (no explicit helper) |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `:json` (default) | `success_response` from assigns |
|
|
104
|
+
| `:html` | `render` template `views/<controller>/<action>.html.erb` (missing → `TemplateNotFound`) |
|
|
105
|
+
|
|
106
|
+
### Symbol status codes, `head`, `response_status`
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
success_response({ post: post.to_h }, :created) # 201
|
|
110
|
+
error_response("Nope", :unprocessable_entity) # 422
|
|
111
|
+
head :no_content # 204 empty body
|
|
112
|
+
head :created # 201 empty body
|
|
113
|
+
|
|
114
|
+
def create
|
|
115
|
+
@post = Post.create!(...)
|
|
116
|
+
response_status :created # 201 + implicit assigns body
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Bare integers still work. Symbols match the Rack/Rails names (`:created`, `:not_found`, …).
|
|
121
|
+
|
|
3
122
|
## 0.3.0
|
|
4
123
|
|
|
5
124
|
### Pre-Deploy Backups
|
data/README.md
CHANGED
|
@@ -71,24 +71,31 @@ require "belt"
|
|
|
71
71
|
class PostsController < BeltController::Base
|
|
72
72
|
before_action :authenticate!
|
|
73
73
|
|
|
74
|
+
# Implicit response: assigns become the JSON body
|
|
75
|
+
# → { "posts": [ { "id": "...", "title": "..." }, ... ] }
|
|
74
76
|
def index
|
|
75
|
-
posts = Post.where(user_id: current_user_id, index: "UserIndex")
|
|
76
|
-
success_response(posts.map(&:attributes))
|
|
77
|
+
@posts = Post.where(user_id: current_user_id, index: "UserIndex")
|
|
77
78
|
end
|
|
78
79
|
|
|
79
80
|
def show
|
|
80
|
-
post = Post.find(params["id"])
|
|
81
|
-
success_response(post.attributes)
|
|
81
|
+
@post = Post.find(params["id"])
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
+
# Implicit body + non-200 status
|
|
84
85
|
def create
|
|
85
86
|
attrs = params.require(:post).permit(:title, :body).to_h
|
|
86
|
-
post = Post.create!(attrs.merge(user_id: current_user_id))
|
|
87
|
-
|
|
87
|
+
@post = Post.create!(attrs.merge(user_id: current_user_id))
|
|
88
|
+
response_status :created
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def destroy
|
|
92
|
+
Post.find(params["id"]).destroy
|
|
93
|
+
head :no_content
|
|
88
94
|
end
|
|
89
95
|
end
|
|
90
96
|
```
|
|
91
97
|
|
|
98
|
+
|
|
92
99
|
### 4. Lambda entry point
|
|
93
100
|
|
|
94
101
|
Use `Belt::LambdaHandler` to get automatic observability, CORS preflight handling, and error wrapping:
|
|
@@ -199,9 +206,42 @@ end
|
|
|
199
206
|
|
|
200
207
|
```ruby
|
|
201
208
|
success_response({ id: "123", name: "Example" }) # 200 JSON with CORS
|
|
202
|
-
success_response({ id: "123" },
|
|
203
|
-
error_response("Not found",
|
|
209
|
+
success_response({ id: "123" }, :created) # 201 Created (symbol or int)
|
|
210
|
+
error_response("Not found", :not_found) # 404 JSON error
|
|
211
|
+
error_response("Nope", :unprocessable_entity) # 422
|
|
204
212
|
html_response("<h1>Hello</h1>") # 200 HTML with CORS
|
|
213
|
+
head :no_content # 204 empty body
|
|
214
|
+
head :created # 201 empty body
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Default format (API vs HTML)
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
# App-wide — typically in lambda/config/environment.rb
|
|
221
|
+
Belt.configure do |config|
|
|
222
|
+
config.default_format = :json # default
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Per-controller override
|
|
226
|
+
class PagesController < ApplicationController
|
|
227
|
+
self.default_format = :html
|
|
228
|
+
end
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
- **`:json`** (default): action assigns → `success_response({ posts: [...] })`
|
|
232
|
+
- **`:html`**: action assigns stay on the controller; Belt implicitly `render`s
|
|
233
|
+
`views/<controller>/<action>.html.erb`. Missing template raises `Belt::TemplateNotFound`
|
|
234
|
+
(no silent JSON fallback).
|
|
235
|
+
- Explicit helpers always win: `success_response`, `error_response`, `html_response`,
|
|
236
|
+
`render`, `head`.
|
|
237
|
+
|
|
238
|
+
### Non-200 with implicit assigns
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
def create
|
|
242
|
+
@post = Post.create!(...)
|
|
243
|
+
response_status :created # → 201 + { post: {...} }
|
|
244
|
+
end
|
|
205
245
|
```
|
|
206
246
|
|
|
207
247
|
## Controller Discovery
|
|
@@ -535,6 +575,175 @@ These are set via Terraform variables in each environment's `terraform.tfvars`.
|
|
|
535
575
|
|
|
536
576
|
The backup phase reads table names from Terraform outputs. On a brand-new environment that has never been deployed, there are no outputs yet — Belt will warn and skip the backup phase gracefully. After the first successful deploy, backups run normally on subsequent deploys.
|
|
537
577
|
|
|
578
|
+
## Plugins
|
|
579
|
+
|
|
580
|
+
Belt is designed to stay lean. Optional capabilities ship as **separate gems** that plug into the CLI and runtime the same way Rails engines and generators do.
|
|
581
|
+
|
|
582
|
+
### Official / example plugins
|
|
583
|
+
|
|
584
|
+
| Gem | Purpose | Status |
|
|
585
|
+
|-----|---------|--------|
|
|
586
|
+
| [`belt-messaging`](https://github.com/stowzilla/belt-messaging) | Two-way SMS via AWS End User Messaging (Pinpoint) | Early (not production-hardened) |
|
|
587
|
+
| [`belt-pay`](https://github.com/stowzilla/belt-pay) | Stripe payments & subscriptions | Early (not production-hardened) |
|
|
588
|
+
|
|
589
|
+
Install a plugin in a Belt app:
|
|
590
|
+
|
|
591
|
+
```ruby
|
|
592
|
+
# Gemfile
|
|
593
|
+
gem "belt-messaging"
|
|
594
|
+
# gem "belt-pay"
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
```bash
|
|
598
|
+
bundle install
|
|
599
|
+
belt generate messaging # or: belt g pay
|
|
600
|
+
belt generate --help # lists built-ins + gem generators
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
### How plugins register
|
|
604
|
+
|
|
605
|
+
No central registry file, no initializer hook. Belt discovers generators by scanning loaded gems:
|
|
606
|
+
|
|
607
|
+
1. Gem is in the app `Gemfile` and bundled
|
|
608
|
+
2. Gem ships a file at `lib/belt/generators/<name>_generator.rb`
|
|
609
|
+
3. Class lives in `Belt::Generators`, named `<Name>Generator`
|
|
610
|
+
4. Implements `.run(args)` (required), `.destroy(args)` and `.description` (optional)
|
|
611
|
+
|
|
612
|
+
That is the whole contract. After `bundle install`, `belt generate <name>` and `belt destroy <name>` just work.
|
|
613
|
+
|
|
614
|
+
See `Belt::CLI::GeneratorRegistry` and the CHANGELOG entry for **0.1.13 Generator Extension API**.
|
|
615
|
+
|
|
616
|
+
### Plugin layout (canonical)
|
|
617
|
+
|
|
618
|
+
```
|
|
619
|
+
belt-messaging/
|
|
620
|
+
├── belt-messaging.gemspec
|
|
621
|
+
├── lib/
|
|
622
|
+
│ ├── belt-messaging.rb # require entrypoint
|
|
623
|
+
│ └── belt/
|
|
624
|
+
│ ├── messaging.rb # Belt::Messaging API
|
|
625
|
+
│ ├── messaging/
|
|
626
|
+
│ │ ├── configuration.rb
|
|
627
|
+
│ │ ├── version.rb
|
|
628
|
+
│ │ ├── controllers/ # default controllers (optional)
|
|
629
|
+
│ │ └── templates/ # ERB templates for the generator
|
|
630
|
+
│ │ ├── terraform/
|
|
631
|
+
│ │ ├── lambda/
|
|
632
|
+
│ │ ├── config/
|
|
633
|
+
│ │ └── controllers/
|
|
634
|
+
│ └── generators/
|
|
635
|
+
│ └── messaging_generator.rb # ← auto-discovered
|
|
636
|
+
└── spec/
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
**Runtime code stays in the gem.** Generators copy only what the host app must own (Terraform modules, Lambda entrypoints, optional controller overrides). Prefer gem defaults + `belt g <plugin> --controllers` over dumping everything into the app (same idea as `rails g devise:views`).
|
|
640
|
+
|
|
641
|
+
### Creating a new plugin
|
|
642
|
+
|
|
643
|
+
Scaffold a ready-to-fill gem (Rails-style `plugin new`):
|
|
644
|
+
|
|
645
|
+
```bash
|
|
646
|
+
belt plugin new notifications
|
|
647
|
+
# → ./belt-notifications/
|
|
648
|
+
|
|
649
|
+
belt plugin new pay --path ~/Code --summary "Stripe payments for Belt"
|
|
650
|
+
# → ~/Code/belt-pay/
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
Then:
|
|
654
|
+
|
|
655
|
+
```bash
|
|
656
|
+
cd belt-notifications
|
|
657
|
+
bundle install
|
|
658
|
+
# implement lib/belt/notifications/* and the generator
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
Point a Belt app at it while developing:
|
|
662
|
+
|
|
663
|
+
```ruby
|
|
664
|
+
# In the app Gemfile
|
|
665
|
+
gem "belt-notifications", path: "../belt-notifications"
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
```bash
|
|
669
|
+
bundle install
|
|
670
|
+
belt generate notifications
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
When packaging Lambdas, `belt deploy` vendors path gems into `vendor/cache` so conveyor-belt can package them.
|
|
674
|
+
|
|
675
|
+
### Generator checklist (for humans and agents)
|
|
676
|
+
|
|
677
|
+
A solid plugin generator typically:
|
|
678
|
+
|
|
679
|
+
1. **Terraform module** → `infrastructure/modules/<name>/` (`main.tf`, `variables.tf`, `outputs.tf`)
|
|
680
|
+
2. **Lambda config** → `config/lambda/<name>.yml` (timeout, memory, env, triggers)
|
|
681
|
+
3. **Lambda entrypoint** → `lambda/<name>.rb` using `Belt::LambdaHandler`
|
|
682
|
+
4. **Routes / schema** → inject into `config/routes.tf.rb` or `infrastructure/schema.tf.rb` when needed
|
|
683
|
+
5. **Optional overrides** → `--controllers` flag for app-local subclasses
|
|
684
|
+
6. **Destroy path** → `belt destroy <name>` removes what generate created
|
|
685
|
+
7. **Help text** → `.description` + `--help` explaining what was installed and next steps
|
|
686
|
+
|
|
687
|
+
Copy from `belt-messaging` or `belt-pay` rather than inventing a new structure.
|
|
688
|
+
|
|
689
|
+
## Contributing
|
|
690
|
+
|
|
691
|
+
Bug reports and pull requests are welcome on [GitHub](https://github.com/stowzilla/belt).
|
|
692
|
+
|
|
693
|
+
### Development setup
|
|
694
|
+
|
|
695
|
+
```bash
|
|
696
|
+
git clone https://github.com/stowzilla/belt.git
|
|
697
|
+
cd belt
|
|
698
|
+
bundle install
|
|
699
|
+
bundle exec rspec
|
|
700
|
+
bundle exec rubocop
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
### Guidelines
|
|
704
|
+
|
|
705
|
+
1. **Branch from `master`** — open a PR against `master`
|
|
706
|
+
2. **Keep changes focused** — one concern per PR when practical
|
|
707
|
+
3. **Follow existing patterns** — look at neighboring files and `lib/belt/cli/` before inventing new ones
|
|
708
|
+
4. **Tests + lint** — run `bundle exec rspec` and `bundle exec rubocop` before opening (or updating) a PR. CI requires both (plus `bundler-audit`); failing checks block merge on `master`
|
|
709
|
+
5. **Changelog** — note user-facing changes in `CHANGELOG.md` under the next version / Unreleased
|
|
710
|
+
6. **No secrets** — never commit AWS keys, tokens, or real account IDs
|
|
711
|
+
|
|
712
|
+
### Project layout (for contributors)
|
|
713
|
+
|
|
714
|
+
| Path | What lives there |
|
|
715
|
+
|------|------------------|
|
|
716
|
+
| `lib/belt.rb` | Public require entry |
|
|
717
|
+
| `lib/belt/` | Framework core (controller, router, handler, observability) |
|
|
718
|
+
| `lib/belt/cli/` | CLI commands (`new`, `generate`, `deploy`, `plugin`, …) |
|
|
719
|
+
| `lib/belt_controller/` | `BeltController::Base` |
|
|
720
|
+
| `lib/templates/` | ERB templates for `belt new`, generators, plugin scaffold |
|
|
721
|
+
| `exe/belt` | CLI executable |
|
|
722
|
+
| `spec/` | RSpec suite |
|
|
723
|
+
| `AGENTS.md` | Agent-oriented map of this gem (layout, CLI, plugin contract) |
|
|
724
|
+
|
|
725
|
+
AI / coding agents: start with **[AGENTS.md](AGENTS.md)**. Belt apps and plugins scaffolded by the CLI get their own `AGENTS.md` as well (`belt new`, `belt plugin new`).
|
|
726
|
+
|
|
727
|
+
### Local gem development against an app
|
|
728
|
+
|
|
729
|
+
```ruby
|
|
730
|
+
# In a Belt app's Gemfile
|
|
731
|
+
gem "belt", path: "../belt"
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
`belt deploy` detects `path:` gems and materializes them into `vendor/cache` for Lambda packaging so you can iterate without publishing a gem for every try.
|
|
735
|
+
|
|
736
|
+
### Contributing a plugin
|
|
737
|
+
|
|
738
|
+
Plugins are separate repositories (not vendored into this repo). To add a new first-class capability:
|
|
739
|
+
|
|
740
|
+
1. `belt plugin new <name>` (or copy `belt-messaging` / `belt-pay`)
|
|
741
|
+
2. Implement the runtime API + generator contract above
|
|
742
|
+
3. Document install steps in the plugin README (`gem …` → `belt generate …`)
|
|
743
|
+
4. Open a PR on the plugin repo; optionally link it from this README's plugin table
|
|
744
|
+
|
|
745
|
+
Questions about plugin design or core changes: open a GitHub issue or discuss in the project Discord.
|
|
746
|
+
|
|
538
747
|
## License
|
|
539
748
|
|
|
540
749
|
MIT
|
data/lib/belt/action_router.rb
CHANGED
|
@@ -102,15 +102,35 @@ module Belt
|
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
def dispatch_to_controller(route_info, event, body)
|
|
105
|
-
|
|
105
|
+
controller_name = route_info[:controller]
|
|
106
|
+
|
|
107
|
+
# Prevent path traversal — controller names must be simple identifiers or single-level nested
|
|
108
|
+
unless valid_controller_name?(controller_name)
|
|
109
|
+
Belt::Observability::Logger.instance&.warn('Invalid controller name', controller: controller_name)
|
|
110
|
+
return error_response('Not found', 404, event)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
controller_class = resolve_controller(controller_name)
|
|
106
114
|
controller = controller_class.new(event: event, body: body)
|
|
107
115
|
|
|
108
|
-
|
|
109
|
-
Belt::Observability::Logger.instance&.info("Processing by #{
|
|
116
|
+
class_label = controller_class.name.split('::').last.gsub('Controller', '')
|
|
117
|
+
Belt::Observability::Logger.instance&.info("Processing by #{class_label}##{route_info[:action]}")
|
|
110
118
|
|
|
111
119
|
controller.dispatch(route_info[:action].to_sym)
|
|
112
120
|
end
|
|
113
121
|
|
|
122
|
+
# Validate controller names to prevent path traversal.
|
|
123
|
+
# Allows: "posts", "admin/posts" (single nesting level, no dots or special chars)
|
|
124
|
+
def valid_controller_name?(name)
|
|
125
|
+
return false if name.nil? || name.empty?
|
|
126
|
+
return false if name.include?('..')
|
|
127
|
+
return false if name.start_with?('/') || name.end_with?('/')
|
|
128
|
+
return false if name.include?('\\')
|
|
129
|
+
|
|
130
|
+
# Allow only lowercase letters, digits, underscores, and a single forward slash for nesting
|
|
131
|
+
name.match?(%r{\A[a-z][a-z0-9_]*(/[a-z][a-z0-9_]*)?\z})
|
|
132
|
+
end
|
|
133
|
+
|
|
114
134
|
def resolve_controller(controller_name)
|
|
115
135
|
# Try namespace module first (app's own controllers)
|
|
116
136
|
begin
|
|
@@ -137,10 +157,18 @@ module Belt
|
|
|
137
157
|
def resolve_from_paths(controller_name)
|
|
138
158
|
if controller_name.include?('/')
|
|
139
159
|
end
|
|
140
|
-
file_name = "#{controller_name}_controller.rb"
|
|
160
|
+
file_name = "#{controller_name.gsub('/', '_')}_controller.rb"
|
|
141
161
|
|
|
142
162
|
Belt.all_controller_paths.each do |path|
|
|
143
163
|
full_path = File.join(path, file_name)
|
|
164
|
+
|
|
165
|
+
# Ensure resolved path stays within the allowed controller directory
|
|
166
|
+
begin
|
|
167
|
+
resolved = File.realpath(full_path)
|
|
168
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
169
|
+
next
|
|
170
|
+
end
|
|
171
|
+
next unless resolved.start_with?(File.realpath(path))
|
|
144
172
|
next unless File.exist?(full_path)
|
|
145
173
|
|
|
146
174
|
require full_path
|
|
@@ -165,7 +193,7 @@ module Belt
|
|
|
165
193
|
def error_response(message, status_code, event = nil)
|
|
166
194
|
origin = Belt::Helpers::CorsOrigin.resolve_origin(Belt::Helpers::CorsOrigin.origin_from_event(event))
|
|
167
195
|
headers = {
|
|
168
|
-
'Access-Control-Allow-Headers' => 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token',
|
|
196
|
+
'Access-Control-Allow-Headers' => 'Content-Type,Accept,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token',
|
|
169
197
|
'Access-Control-Allow-Methods' => 'GET,POST,PUT,DELETE,PATCH,OPTIONS',
|
|
170
198
|
'Content-Type' => 'application/json'
|
|
171
199
|
}
|
data/lib/belt/assets/welcome.css
CHANGED
|
@@ -1,82 +1,136 @@
|
|
|
1
1
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
2
|
+
html, body {
|
|
3
|
+
height: 100%;
|
|
4
|
+
}
|
|
2
5
|
body {
|
|
3
6
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
4
|
-
background: #
|
|
7
|
+
background: #0b1220;
|
|
5
8
|
color: #2d3748;
|
|
6
9
|
min-height: 100vh;
|
|
10
|
+
overflow: hidden;
|
|
7
11
|
}
|
|
8
12
|
|
|
9
|
-
/* Hero
|
|
13
|
+
/* Hero fills the viewport — all content lives in the overlay (no scroll) */
|
|
10
14
|
.hero {
|
|
11
15
|
position: relative;
|
|
12
16
|
width: 100%;
|
|
17
|
+
height: 100vh;
|
|
13
18
|
overflow: hidden;
|
|
14
19
|
}
|
|
15
20
|
.hero-bg {
|
|
16
21
|
width: 100%;
|
|
17
|
-
height:
|
|
22
|
+
height: 100%;
|
|
23
|
+
object-fit: cover;
|
|
24
|
+
/* Pin the logo / sky — crop hills at the bottom if the viewport is short */
|
|
25
|
+
object-position: top center;
|
|
18
26
|
display: block;
|
|
19
27
|
}
|
|
20
28
|
.overlay {
|
|
21
29
|
position: absolute;
|
|
22
|
-
top:
|
|
30
|
+
top: 50%;
|
|
23
31
|
left: 50%;
|
|
24
32
|
transform: translate(-50%, -50%);
|
|
25
|
-
padding:
|
|
26
|
-
background: rgba(255, 255, 255, 0.
|
|
27
|
-
backdrop-filter: blur(
|
|
28
|
-
-webkit-backdrop-filter: blur(
|
|
29
|
-
border-radius:
|
|
33
|
+
padding: 1.35rem 1.6rem 1.25rem;
|
|
34
|
+
background: rgba(255, 255, 255, 0.55);
|
|
35
|
+
backdrop-filter: blur(8px);
|
|
36
|
+
-webkit-backdrop-filter: blur(8px);
|
|
37
|
+
border-radius: 14px;
|
|
30
38
|
text-align: center;
|
|
31
|
-
max-width:
|
|
32
|
-
width:
|
|
33
|
-
|
|
39
|
+
max-width: 560px;
|
|
40
|
+
width: min(92vw, 560px);
|
|
41
|
+
max-height: min(92vh, 620px);
|
|
42
|
+
overflow: auto;
|
|
43
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
|
34
44
|
}
|
|
35
45
|
.overlay h1 {
|
|
36
|
-
font-size:
|
|
46
|
+
font-size: 1.85rem;
|
|
37
47
|
font-weight: 700;
|
|
38
48
|
color: #2d3748;
|
|
39
|
-
margin-bottom: 0.
|
|
49
|
+
margin-bottom: 0.3rem;
|
|
50
|
+
line-height: 1.2;
|
|
40
51
|
}
|
|
41
52
|
.overlay .subtitle {
|
|
42
|
-
font-size:
|
|
53
|
+
font-size: 0.95rem;
|
|
43
54
|
color: #4a5568;
|
|
55
|
+
line-height: 1.4;
|
|
44
56
|
}
|
|
45
57
|
|
|
46
|
-
/*
|
|
47
|
-
.container {
|
|
48
|
-
max-width: 700px;
|
|
49
|
-
margin: 0 auto;
|
|
50
|
-
padding: 2rem;
|
|
51
|
-
}
|
|
58
|
+
/* Stack + next steps sit inside the white card */
|
|
52
59
|
.stack-check {
|
|
53
60
|
display: flex;
|
|
54
61
|
align-items: center;
|
|
55
62
|
justify-content: center;
|
|
56
|
-
gap: 0.
|
|
57
|
-
margin
|
|
63
|
+
gap: 0.4rem;
|
|
64
|
+
margin: 1rem 0 0.85rem;
|
|
58
65
|
flex-wrap: wrap;
|
|
59
66
|
}
|
|
60
67
|
.check-item {
|
|
61
68
|
display: flex;
|
|
62
69
|
align-items: center;
|
|
63
|
-
gap: 0.
|
|
64
|
-
padding: 0.
|
|
70
|
+
gap: 0.35rem;
|
|
71
|
+
padding: 0.35rem 0.7rem;
|
|
65
72
|
border-radius: 8px;
|
|
66
73
|
font-weight: 500;
|
|
74
|
+
font-size: 0.88rem;
|
|
67
75
|
}
|
|
68
76
|
.check-pass {
|
|
69
|
-
background: rgba(39, 174, 96, 0.
|
|
70
|
-
border: 1px solid rgba(39, 174, 96, 0.
|
|
77
|
+
background: rgba(39, 174, 96, 0.12);
|
|
78
|
+
border: 1px solid rgba(39, 174, 96, 0.35);
|
|
71
79
|
color: #1e8449;
|
|
72
80
|
}
|
|
81
|
+
/* Optional / empty DynamoDB — not an error, just not in use yet */
|
|
82
|
+
.check-neutral {
|
|
83
|
+
background: rgba(113, 128, 150, 0.1);
|
|
84
|
+
border: 1px solid rgba(113, 128, 150, 0.28);
|
|
85
|
+
color: #4a5568;
|
|
86
|
+
}
|
|
73
87
|
.check-warn {
|
|
74
88
|
background: rgba(243, 156, 18, 0.1);
|
|
75
89
|
border: 1px solid rgba(243, 156, 18, 0.3);
|
|
76
90
|
color: #d68910;
|
|
77
91
|
}
|
|
78
|
-
.icon { font-size:
|
|
79
|
-
.arrow { color: #718096; font-size: 1.
|
|
92
|
+
.icon { font-size: 1rem; }
|
|
93
|
+
.arrow { color: #718096; font-size: 1.05rem; }
|
|
94
|
+
|
|
95
|
+
.next-steps {
|
|
96
|
+
text-align: left;
|
|
97
|
+
border-top: 1px solid rgba(45, 55, 72, 0.12);
|
|
98
|
+
padding-top: 0.75rem;
|
|
99
|
+
margin-top: 0.15rem;
|
|
100
|
+
}
|
|
101
|
+
.next-steps h2 {
|
|
102
|
+
font-size: 0.92rem;
|
|
103
|
+
margin-bottom: 0.45rem;
|
|
104
|
+
color: #2d3748;
|
|
105
|
+
text-align: left;
|
|
106
|
+
}
|
|
107
|
+
.next-steps ol {
|
|
108
|
+
padding-left: 1.2rem;
|
|
109
|
+
margin: 0;
|
|
110
|
+
}
|
|
111
|
+
.next-steps li {
|
|
112
|
+
margin-bottom: 0.4rem;
|
|
113
|
+
color: #4a5568;
|
|
114
|
+
line-height: 1.4;
|
|
115
|
+
font-size: 0.88rem;
|
|
116
|
+
}
|
|
117
|
+
.next-steps li:last-child { margin-bottom: 0; }
|
|
118
|
+
.next-steps code,
|
|
119
|
+
.info code {
|
|
120
|
+
background: rgba(99, 45, 145, 0.1);
|
|
121
|
+
padding: 0.12rem 0.35rem;
|
|
122
|
+
border-radius: 4px;
|
|
123
|
+
color: #632d91;
|
|
124
|
+
font-size: 0.78rem;
|
|
125
|
+
word-break: break-word;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* Legacy blocks (local server welcome, older layouts) */
|
|
129
|
+
.container {
|
|
130
|
+
max-width: 700px;
|
|
131
|
+
margin: 0 auto;
|
|
132
|
+
padding: 2rem;
|
|
133
|
+
}
|
|
80
134
|
.info {
|
|
81
135
|
background: #ffffff;
|
|
82
136
|
border: 1px solid #e2e8f0;
|
|
@@ -87,26 +141,44 @@ body {
|
|
|
87
141
|
.info p { margin-bottom: 0.5rem; color: #4a5568; }
|
|
88
142
|
.info p:last-child { margin-bottom: 0; }
|
|
89
143
|
.info strong { color: #2d3748; }
|
|
90
|
-
.
|
|
91
|
-
|
|
92
|
-
|
|
144
|
+
.dynamodb-hint {
|
|
145
|
+
text-align: center;
|
|
146
|
+
margin: 0.5rem 0;
|
|
147
|
+
color: #4a5568;
|
|
148
|
+
font-size: 0.9rem;
|
|
149
|
+
line-height: 1.45;
|
|
150
|
+
}
|
|
151
|
+
.dynamodb-hint code {
|
|
152
|
+
background: rgba(99, 45, 145, 0.1);
|
|
153
|
+
padding: 0.12rem 0.35rem;
|
|
93
154
|
border-radius: 4px;
|
|
94
155
|
color: #632d91;
|
|
95
|
-
font-size: 0.
|
|
156
|
+
font-size: 0.8rem;
|
|
157
|
+
word-break: break-word;
|
|
96
158
|
}
|
|
97
|
-
.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
border-
|
|
101
|
-
|
|
159
|
+
.footer-note {
|
|
160
|
+
margin-top: 0.75rem;
|
|
161
|
+
padding-top: 0.65rem;
|
|
162
|
+
border-top: 1px solid rgba(45, 55, 72, 0.1);
|
|
163
|
+
font-size: 0.78rem;
|
|
164
|
+
color: #718096;
|
|
165
|
+
text-align: center;
|
|
102
166
|
}
|
|
103
|
-
.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
167
|
+
.footer-note code {
|
|
168
|
+
background: rgba(45, 55, 72, 0.06);
|
|
169
|
+
padding: 0.08rem 0.3rem;
|
|
170
|
+
border-radius: 3px;
|
|
171
|
+
color: #4a5568;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* Tiny viewports: shrink type a bit so everything still fits */
|
|
175
|
+
@media (max-height: 640px) {
|
|
176
|
+
.overlay {
|
|
177
|
+
padding: 1rem 1.15rem;
|
|
178
|
+
}
|
|
179
|
+
.overlay h1 { font-size: 1.5rem; }
|
|
180
|
+
.overlay .subtitle { font-size: 0.88rem; }
|
|
181
|
+
.stack-check { margin: 0.7rem 0 0.55rem; }
|
|
182
|
+
.check-item { padding: 0.28rem 0.55rem; font-size: 0.8rem; }
|
|
183
|
+
.next-steps li { font-size: 0.82rem; }
|
|
112
184
|
}
|