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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +119 -0
  3. data/README.md +217 -8
  4. data/lib/belt/action_router.rb +33 -5
  5. data/lib/belt/assets/welcome.css +119 -47
  6. data/lib/belt/cli/bucket_security.rb +10 -4
  7. data/lib/belt/cli/deploy_command.rb +48 -0
  8. data/lib/belt/cli/destroy_command.rb +119 -10
  9. data/lib/belt/cli/doctor_command.rb +208 -0
  10. data/lib/belt/cli/environment_command.rb +38 -6
  11. data/lib/belt/cli/frontend_command.rb +25 -12
  12. data/lib/belt/cli/frontend_setup_command.rb +41 -0
  13. data/lib/belt/cli/new_command.rb +106 -38
  14. data/lib/belt/cli/path_gem_materializer.rb +141 -0
  15. data/lib/belt/cli/plugin_command.rb +221 -0
  16. data/lib/belt/cli/setup_command.rb +35 -10
  17. data/lib/belt/cli.rb +8 -0
  18. data/lib/belt/configuration.rb +30 -0
  19. data/lib/belt/controllers/welcome_controller.rb +69 -2
  20. data/lib/belt/helpers/cors_origin.rb +19 -2
  21. data/lib/belt/helpers/response.rb +48 -6
  22. data/lib/belt/http_status.rb +89 -0
  23. data/lib/belt/lambda_handler.rb +14 -5
  24. data/lib/belt/rendering.rb +1 -0
  25. data/lib/belt/version.rb +1 -1
  26. data/lib/belt/views/welcome/show.html.erb +31 -31
  27. data/lib/belt.rb +16 -0
  28. data/lib/belt_controller/base.rb +24 -1
  29. data/lib/belt_controller/implicit_response.rb +104 -0
  30. data/lib/templates/environment/backend.tf.erb +1 -1
  31. data/lib/templates/frontend/react/src/index.css +4 -4
  32. data/lib/templates/frontend/react/src/lib/apiClient.js.erb +20 -3
  33. data/lib/templates/frontend/react/src/pages/Home.jsx.erb +283 -4
  34. data/lib/templates/module/main.tf.erb +13 -3
  35. data/lib/templates/new_app/config/routes.tf.rb.erb +2 -1
  36. data/lib/templates/plugin/AGENTS.md.erb +135 -0
  37. data/lib/templates/plugin/CHANGELOG.md.erb +5 -0
  38. data/lib/templates/plugin/Gemfile.erb +11 -0
  39. data/lib/templates/plugin/LICENSE.erb +21 -0
  40. data/lib/templates/plugin/README.md.erb +63 -0
  41. data/lib/templates/plugin/Rakefile.erb +7 -0
  42. data/lib/templates/plugin/gemspec.erb +26 -0
  43. data/lib/templates/plugin/gitignore.erb +11 -0
  44. data/lib/templates/plugin/lib/belt/generators/generator.rb.erb +122 -0
  45. data/lib/templates/plugin/lib/belt/module/configuration.rb.erb +15 -0
  46. data/lib/templates/plugin/lib/belt/module/version.rb.erb +7 -0
  47. data/lib/templates/plugin/lib/belt/module.rb.erb +27 -0
  48. data/lib/templates/plugin/lib/entry.rb.erb +3 -0
  49. data/lib/templates/plugin/rspec.erb +3 -0
  50. data/lib/templates/plugin/spec/configuration_spec.rb.erb +17 -0
  51. data/lib/templates/plugin/spec/spec_helper.rb.erb +19 -0
  52. metadata +37 -1
@@ -38,11 +38,12 @@ module Belt
38
38
  end
39
39
  end
40
40
 
41
- def initialize(args = [])
41
+ def initialize(args = [], quiet: false)
42
42
  @app_name = detect_app_name
43
43
  @env_name = nil
44
44
  @custom_bucket = nil
45
45
  @select_mode = false
46
+ @quiet = quiet
46
47
 
47
48
  parse_args(args)
48
49
 
@@ -67,7 +68,7 @@ module Belt
67
68
  @bucket_name = interactive_bucket_selection if @select_mode
68
69
  setup_or_verify_bucket
69
70
  apply_lifecycle(@bucket_name)
70
- puts ' ensure lifecycle rules (90-day noncurrent expiration)'
71
+ say ' ensure lifecycle rules (90-day noncurrent expiration)'
71
72
  update_backend_config
72
73
  print_success_message
73
74
  end
@@ -81,12 +82,12 @@ module Belt
81
82
  end
82
83
 
83
84
  def verify_existing_bucket
84
- puts "Found existing bucket: #{@bucket_name}"
85
+ say "Found existing bucket: #{@bucket_name}"
85
86
  audit = audit_bucket_security(@bucket_name)
86
- print_security_audit(audit)
87
+ print_security_audit(audit) unless @quiet
87
88
 
88
89
  if audit.values.all?
89
- puts "\n✓ Bucket '#{@bucket_name}' passes all security checks"
90
+ say "\n✓ Bucket '#{@bucket_name}' passes all security checks"
90
91
  else
91
92
  prompt_and_harden(audit)
92
93
  end
@@ -94,6 +95,12 @@ module Belt
94
95
 
95
96
  def prompt_and_harden(audit)
96
97
  puts "\n⚠ Bucket '#{@bucket_name}' has security issues."
98
+ # Auto-harden during belt new (quiet); interactive otherwise
99
+ if @quiet
100
+ harden_bucket(@bucket_name, audit)
101
+ return
102
+ end
103
+
97
104
  print "\nApply security hardening? [Y/n] "
98
105
  response = $stdin.gets&.strip&.downcase
99
106
  if response.nil? || response.empty? || response == 'y'
@@ -105,13 +112,15 @@ module Belt
105
112
  end
106
113
 
107
114
  def create_new_bucket
108
- puts "Creating state bucket: #{@bucket_name} (#{@region})"
115
+ say "Creating state bucket: #{@bucket_name} (#{@region})"
109
116
  create_bucket(@bucket_name)
110
- puts " create s3://#{@bucket_name}"
117
+ say " create s3://#{@bucket_name}"
111
118
  harden_bucket(@bucket_name, {})
112
119
  end
113
120
 
114
121
  def print_success_message
122
+ return if @quiet
123
+
115
124
  puts "\n✓ State bucket '#{@bucket_name}' is ready!"
116
125
  if @env_name
117
126
  puts "\n cd infrastructure/#{@env_name} && terraform init"
@@ -120,6 +129,9 @@ module Belt
120
129
  end
121
130
  end
122
131
 
132
+ # Expose for quiet callers (e.g. belt new) that print their own summary
133
+ attr_reader :bucket_name
134
+
123
135
  private
124
136
 
125
137
  def parse_args(args)
@@ -138,8 +150,15 @@ module Belt
138
150
  end
139
151
  end
140
152
 
153
+ # One shared state bucket per AWS account (all belt apps use it).
154
+ # Account ID keeps the name globally unique — S3 names are a global namespace.
155
+ # Pattern: belt-terraform-state-<account_id>
141
156
  def resolve_bucket_name
142
- @custom_bucket || 'belt-terraform-state'
157
+ return @custom_bucket if @custom_bucket
158
+ return "belt-terraform-state-#{@aws_account_id}" if @aws_account_id
159
+
160
+ # Placeholder until credentials are available (belt setup state re-resolves)
161
+ 'belt-terraform-state'
143
162
  end
144
163
 
145
164
  # --- Interactive selection ---
@@ -238,9 +257,11 @@ module Belt
238
257
  end
239
258
 
240
259
  def run!(*args)
241
- return if system(*args)
260
+ output, status = Open3.capture2e(*args)
261
+ return if status.success?
242
262
 
243
263
  puts "\n✗ Command failed: #{args.shelljoin}"
264
+ puts output.strip unless output.strip.empty?
244
265
  exit 1
245
266
  end
246
267
 
@@ -249,6 +270,10 @@ module Belt
249
270
  status.success? ? output : nil
250
271
  end
251
272
 
273
+ def say(message)
274
+ puts message unless @quiet
275
+ end
276
+
252
277
  # --- Backend config ---
253
278
 
254
279
  def update_backend_config
@@ -268,7 +293,7 @@ module Belt
268
293
  updated = content.gsub(/bucket\s*=\s*"[^"]+"/, "bucket = \"#{@bucket_name}\"")
269
294
  if updated != content
270
295
  File.write(backend_file, updated)
271
- puts " update #{backend_file} → bucket = \"#{@bucket_name}\""
296
+ say " update #{backend_file} → bucket = \"#{@bucket_name}\""
272
297
  end
273
298
  end
274
299
  end
data/lib/belt/cli.rb CHANGED
@@ -21,6 +21,8 @@ require_relative 'cli/routes_command'
21
21
  require_relative 'cli/lambda_config_command'
22
22
  require_relative 'cli/tasks_command'
23
23
  require_relative 'cli/console_command'
24
+ require_relative 'cli/doctor_command'
25
+ require_relative 'cli/plugin_command'
24
26
 
25
27
  module Belt
26
28
  module CLI
@@ -33,6 +35,8 @@ module Belt
33
35
  %w[console c] => Belt::CLI::ConsoleCommand,
34
36
  %w[tasks --tasks -T] => Belt::CLI::TasksCommand,
35
37
  'setup' => Belt::CLI::SetupCommand,
38
+ 'doctor' => Belt::CLI::DoctorCommand,
39
+ 'plugin' => Belt::CLI::PluginCommand,
36
40
  'deploy' => Belt::CLI::DeployCommand,
37
41
  'frontend' => Belt::CLI::FrontendEnvCommand,
38
42
  %w[server s] => Belt::CLI::ServerCommand,
@@ -114,6 +118,8 @@ module Belt
114
118
  setup state Create/select S3 state bucket
115
119
  setup tables <env> Generate DynamoDB tables from schema
116
120
  setup frontend <env> Generate S3 + CloudFront infrastructure
121
+ doctor Check system dependencies and AWS config
122
+ plugin new <name> Scaffold a new Belt plugin gem
117
123
  init [environment] <env> terraform init for environment
118
124
  plan [environment] <env> terraform plan for environment
119
125
  apply [environment] <env> terraform apply for environment
@@ -133,6 +139,7 @@ module Belt
133
139
 
134
140
  Examples:
135
141
  belt new blog --frontend react
142
+ belt new blog --frontend react -v # list every created file
136
143
  belt generate scaffold post title:string content:text status:string
137
144
  belt destroy scaffold post
138
145
  belt generate frontend react
@@ -145,6 +152,7 @@ module Belt
145
152
  belt apply wups
146
153
  belt tasks # list all rake tasks
147
154
  belt lambda:build_layer # run a rake task directly
155
+ belt plugin new messaging # scaffold a belt-messaging style plugin gem
148
156
  USAGE
149
157
  end
150
158
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ # Runtime configuration for Belt apps (set in lambda/config/environment.rb).
5
+ #
6
+ # Belt.configure do |config|
7
+ # config.default_format = :json # or :html
8
+ # end
9
+ #
10
+ # Note: infrastructure/<env>/belt.rb uses a separate sandboxed DSL for CLI
11
+ # deploy/backup settings — it does not share this object.
12
+ class Configuration
13
+ VALID_FORMATS = %i[json html].freeze
14
+
15
+ def initialize
16
+ @default_format = :json
17
+ end
18
+
19
+ attr_reader :default_format
20
+
21
+ def default_format=(value)
22
+ format = value.to_sym
23
+ unless VALID_FORMATS.include?(format)
24
+ raise ArgumentError, "default_format must be :json or :html (got #{value.inspect})"
25
+ end
26
+
27
+ @default_format = format
28
+ end
29
+ end
30
+ end
@@ -6,23 +6,90 @@ module Belt
6
6
  # Gem-embedded welcome controller that serves the default "it works!" page.
7
7
  # Resolved via the ActionRouter's Belt:: namespace fallback when no app-defined
8
8
  # WelcomeController exists. Gets replaced once the user defines their own root route.
9
+ #
10
+ # Dual format:
11
+ # - Browser / no Accept preference → HTML hero + stack check
12
+ # - Accept: application/json (SPA / apiClient) → JSON payload for the React shell
13
+ #
14
+ # The hero image (lib/belt/assets/belt-default.jpg) is the single source of truth —
15
+ # HTML embeds it, and JSON returns the same bytes as a data URI so the SPA matches.
9
16
  class WelcomeController < BeltController::Base
10
17
  skip_before_action :authenticate!
11
18
 
12
19
  ASSETS_DIR = File.expand_path('../assets', __dir__)
20
+ # Scaffold gives model + controller + routes + schema (not model-only).
21
+ SCAFFOLD_HINT_CMD = 'belt generate scaffold Post title content:text'
13
22
 
14
23
  def show
15
24
  @title = ENV.fetch('WELCOME_TITLE', 'Welcome to Belt')
16
25
  @subtitle = ENV.fetch('WELCOME_SUBTITLE', 'API Gateway → Lambda → DynamoDB — all connected.')
17
- @css = welcome_css
18
- @background_image = background_image_base64
19
26
  @dynamodb_connected = dynamodb_connected?
27
+ @scaffold_hint_cmd = SCAFFOLD_HINT_CMD
28
+
29
+ return success_response(welcome_payload) if wants_json?
20
30
 
31
+ @css = welcome_css
32
+ @background_image = background_image_base64
21
33
  render
22
34
  end
23
35
 
24
36
  private
25
37
 
38
+ def welcome_payload
39
+ {
40
+ title: @title,
41
+ subtitle: @subtitle,
42
+ # Same gem asset as the HTML welcome page — edit belt-default.jpg once.
43
+ background_image: "data:image/jpeg;base64,#{background_image_base64}",
44
+ stack: {
45
+ api_gateway: true,
46
+ lambda: true,
47
+ dynamodb: @dynamodb_connected
48
+ },
49
+ # One tip only (was duplicated as dynamodb_hint + first next_step).
50
+ next_steps: [
51
+ "Scaffold a resource: #{SCAFFOLD_HINT_CMD}",
52
+ 'Deploy: belt deploy',
53
+ 'This page will be replaced once you define your own root route.'
54
+ ]
55
+ }
56
+ end
57
+
58
+ # SPA apiClient and fetch(..., { headers: { Accept: "application/json" } })
59
+ def wants_json?
60
+ return true if params['format'].to_s == 'json'
61
+
62
+ accept = header_value('Accept') || header_value('accept') || ''
63
+ return false if accept.empty?
64
+
65
+ # Prefer JSON when it's listed and not beaten by an explicit HTML preference
66
+ json_q = accept_quality(accept, 'application/json')
67
+ html_q = accept_quality(accept, 'text/html')
68
+ json_q.positive? && json_q >= html_q
69
+ end
70
+
71
+ def header_value(name)
72
+ headers = event.is_a?(Hash) ? event['headers'] : nil
73
+ return nil unless headers.is_a?(Hash)
74
+
75
+ headers[name] || headers[name.downcase] || headers[name.split('-').map(&:capitalize).join('-')]
76
+ end
77
+
78
+ def accept_quality(accept, media_type)
79
+ accept.split(',').each do |part|
80
+ type, *params = part.strip.split(';').map(&:strip)
81
+ # Exact type only — do not treat */* as JSON (curl/browsers send that by default)
82
+ next unless type == media_type
83
+
84
+ q = 1.0
85
+ params.each do |p|
86
+ q = p.split('=', 2).last.to_f if p.start_with?('q=')
87
+ end
88
+ return q
89
+ end
90
+ 0.0
91
+ end
92
+
26
93
  def dynamodb_connected?
27
94
  return false unless defined?(Aws::DynamoDB::Client)
28
95
 
@@ -3,10 +3,16 @@
3
3
  module Belt
4
4
  module Helpers
5
5
  module CorsOrigin
6
+ # Maximum length for an Origin header to prevent ReDoS or memory abuse.
7
+ MAX_ORIGIN_LENGTH = 253
8
+
6
9
  def self.resolve_origin(request_origin)
7
10
  allowed = allowed_origins
8
11
  return nil if allowed.empty?
9
- return request_origin if request_origin && matches_allowed?(request_origin, allowed)
12
+
13
+ if request_origin && valid_origin?(request_origin) && matches_allowed?(request_origin, allowed)
14
+ return request_origin
15
+ end
10
16
 
11
17
  # Don't return a wildcard pattern as a literal origin — use '*' instead
12
18
  first = allowed.first
@@ -18,7 +24,7 @@ module Belt
18
24
 
19
25
  allowed.any? do |pattern|
20
26
  if pattern.include?('*')
21
- regex = Regexp.new("\\A#{Regexp.escape(pattern).gsub('\*', '[^.]+')}\\z")
27
+ regex = Regexp.new("\\A#{Regexp.escape(pattern).gsub('\*', '[a-z0-9\\-]+')}\\z")
22
28
  regex.match?(origin)
23
29
  else
24
30
  pattern == origin
@@ -26,6 +32,17 @@ module Belt
26
32
  end
27
33
  end
28
34
 
35
+ # Validate that the origin looks like a legitimate URL scheme+host.
36
+ # Rejects origins with path components, whitespace, or unexpected characters.
37
+ def self.valid_origin?(origin)
38
+ return false if origin.nil? || origin.empty?
39
+ return false if origin.length > MAX_ORIGIN_LENGTH
40
+ return false unless origin.match?(%r{\A https?://[a-z0-9\-.:]+\z}ix)
41
+
42
+ # Reject origins with user-info, paths, queries, or fragments
43
+ !origin.include?('@') && !origin.include?('?') && !origin.include?('#')
44
+ end
45
+
29
46
  def self.origin_from_event(event)
30
47
  return nil unless event.is_a?(Hash)
31
48
 
@@ -2,41 +2,79 @@
2
2
 
3
3
  require_relative 'error_logging'
4
4
  require_relative 'cors_origin'
5
+ require_relative '../http_status'
5
6
 
6
7
  module Belt
7
8
  module Helpers
8
9
  module Response
10
+ # Maximum request body size (10 MB). Bodies larger than this are rejected
11
+ # to prevent memory exhaustion attacks on Lambda functions.
12
+ MAX_REQUEST_BODY_SIZE = 10 * 1024 * 1024
13
+
9
14
  def cors_headers(event = nil)
10
15
  event = @event if event.nil? && instance_variable_defined?(:@event)
11
16
  origin = CorsOrigin.resolve_origin(CorsOrigin.origin_from_event(event))
17
+ allow_headers = 'Content-Type,Accept,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'
12
18
  headers = {
13
- 'Access-Control-Allow-Headers' => 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token',
19
+ 'Access-Control-Allow-Headers' => allow_headers,
14
20
  'Access-Control-Allow-Methods' => 'GET,POST,PUT,DELETE,PATCH,OPTIONS',
15
21
  'Access-Control-Max-Age' => '300',
16
- 'Content-Type' => 'application/json'
22
+ 'Content-Type' => 'application/json',
23
+ 'X-Content-Type-Options' => 'nosniff',
24
+ 'Vary' => 'Origin'
17
25
  }
18
26
  headers['Access-Control-Allow-Origin'] = origin if origin
19
27
  headers
20
28
  end
21
29
 
30
+ # JSON success. Status accepts Integer or Symbol (:created, :ok, …).
22
31
  def success_response(body, status_code = 200)
23
- { statusCode: status_code, headers: cors_headers, body: JSON.generate(body) }
32
+ { statusCode: resolve_status(status_code), headers: cors_headers, body: JSON.generate(body) }
24
33
  end
25
34
 
35
+ # JSON error. Status accepts Integer or Symbol (:unprocessable_entity, :not_found, …).
26
36
  def error_response(message, status_code = 400, error_details = nil)
27
37
  body = { error: message }
28
38
  if error_details
29
39
  body[:details] = error_details.is_a?(Hash) ? error_details : { message: error_details.to_s }
30
40
  end
31
- { statusCode: status_code, headers: cors_headers, body: JSON.generate(body) }
41
+ { statusCode: resolve_status(status_code), headers: cors_headers, body: JSON.generate(body) }
32
42
  end
33
43
 
34
44
  def html_response(html, status_code = 200)
35
45
  event = @event if instance_variable_defined?(:@event)
36
46
  origin = CorsOrigin.resolve_origin(CorsOrigin.origin_from_event(event))
37
- headers = { 'Content-Type' => 'text/html; charset=utf-8' }
47
+ headers = {
48
+ 'Content-Type' => 'text/html; charset=utf-8',
49
+ 'X-Content-Type-Options' => 'nosniff',
50
+ 'X-Frame-Options' => 'DENY',
51
+ 'Referrer-Policy' => 'strict-origin-when-cross-origin'
52
+ }
38
53
  headers['Access-Control-Allow-Origin'] = origin if origin
39
- { statusCode: status_code, headers: headers, body: html }
54
+ { statusCode: resolve_status(status_code), headers: headers, body: html }
55
+ end
56
+
57
+ # Empty-body response (Rails-style). Useful for destroy / create-without-body.
58
+ #
59
+ # head :created # 201, empty body
60
+ # head :no_content # 204
61
+ # head 201
62
+ #
63
+ def head(status = :no_content)
64
+ { statusCode: resolve_status(status), headers: cors_headers, body: '' }
65
+ end
66
+
67
+ # Set the status used by implicit assigns / HTML render (when you don't
68
+ # call success_response / render yourself).
69
+ #
70
+ # def create
71
+ # @post = Post.create!(...)
72
+ # response_status :created
73
+ # end
74
+ # # → 201 + { post: {...} }
75
+ #
76
+ def response_status(status)
77
+ @__response_status = resolve_status(status)
40
78
  end
41
79
 
42
80
  def handle_error_and_respond(error, message, context = {}, status_code = 500)
@@ -56,6 +94,10 @@ module Belt
56
94
 
57
95
  private
58
96
 
97
+ def resolve_status(status)
98
+ Belt::HttpStatus.resolve(status)
99
+ end
100
+
59
101
  def verbose_errors_enabled?
60
102
  env = ENV['ENVIRONMENT']&.downcase || ''
61
103
  env.start_with?('dev') || env == 'local' || env == 'test'
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ # Rails/Rack-style symbolic HTTP status codes.
5
+ # Mirrors the common subset of Rack::Utils::SYMBOL_TO_STATUS_CODE so apps
6
+ # can write `head :created` / `success_response(body, :created)` without
7
+ # taking a rack dependency.
8
+ module HttpStatus
9
+ SYMBOL_TO_STATUS_CODE = {
10
+ continue: 100,
11
+ switching_protocols: 101,
12
+ processing: 102,
13
+ ok: 200,
14
+ created: 201,
15
+ accepted: 202,
16
+ non_authoritative_information: 203,
17
+ no_content: 204,
18
+ reset_content: 205,
19
+ partial_content: 206,
20
+ multi_status: 207,
21
+ already_reported: 208,
22
+ im_used: 226,
23
+ multiple_choices: 300,
24
+ moved_permanently: 301,
25
+ found: 302,
26
+ see_other: 303,
27
+ not_modified: 304,
28
+ use_proxy: 305,
29
+ temporary_redirect: 307,
30
+ permanent_redirect: 308,
31
+ bad_request: 400,
32
+ unauthorized: 401,
33
+ payment_required: 402,
34
+ forbidden: 403,
35
+ not_found: 404,
36
+ method_not_allowed: 405,
37
+ not_acceptable: 406,
38
+ proxy_authentication_required: 407,
39
+ request_timeout: 408,
40
+ conflict: 409,
41
+ gone: 410,
42
+ length_required: 411,
43
+ precondition_failed: 412,
44
+ payload_too_large: 413,
45
+ uri_too_long: 414,
46
+ unsupported_media_type: 415,
47
+ range_not_satisfiable: 416,
48
+ expectation_failed: 417,
49
+ unprocessable_entity: 422,
50
+ locked: 423,
51
+ failed_dependency: 424,
52
+ upgrade_required: 426,
53
+ precondition_required: 428,
54
+ too_many_requests: 429,
55
+ request_header_fields_too_large: 431,
56
+ unavailable_for_legal_reasons: 451,
57
+ internal_server_error: 500,
58
+ not_implemented: 501,
59
+ bad_gateway: 502,
60
+ service_unavailable: 503,
61
+ gateway_timeout: 504,
62
+ http_version_not_supported: 505,
63
+ variant_also_negotiates: 506,
64
+ insufficient_storage: 507,
65
+ loop_detected: 508,
66
+ not_extended: 510,
67
+ network_authentication_required: 511
68
+ }.freeze
69
+
70
+ module_function
71
+
72
+ # Resolve a status to an integer code.
73
+ # Accepts Integer (200), String ("201"), or Symbol (:created).
74
+ def resolve(status)
75
+ case status
76
+ when Integer
77
+ status
78
+ when String
79
+ Integer(status)
80
+ when Symbol
81
+ SYMBOL_TO_STATUS_CODE.fetch(status) do
82
+ raise ArgumentError, "Unknown HTTP status symbol: #{status.inspect}"
83
+ end
84
+ else
85
+ raise ArgumentError, "status must be Integer, String, or Symbol (got #{status.class})"
86
+ end
87
+ end
88
+ end
89
+ end
@@ -76,11 +76,8 @@ module Belt
76
76
 
77
77
  return { statusCode: 200, headers: cors_headers(event), body: '{}' } if event['httpMethod'] == 'OPTIONS'
78
78
 
79
- begin
80
- body = JSON.parse(event['body'] || '{}')
81
- rescue JSON::ParserError
82
- return error_response('Invalid JSON in request body')
83
- end
79
+ body = parse_request_body(event)
80
+ return body if body.is_a?(Hash) && body[:statusCode]
84
81
 
85
82
  begin
86
83
  result = execute(path: event['path'], body: body, event: event)
@@ -107,6 +104,18 @@ module Belt
107
104
 
108
105
  private
109
106
 
107
+ def parse_request_body(event)
108
+ raw_body = event['body'] || '{}'
109
+
110
+ if raw_body.bytesize > Belt::Helpers::Response::MAX_REQUEST_BODY_SIZE
111
+ return error_response('Request body too large', 413)
112
+ end
113
+
114
+ JSON.parse(raw_body)
115
+ rescue JSON::ParserError
116
+ error_response('Invalid JSON in request body', 400)
117
+ end
118
+
110
119
  def init_observability(context:)
111
120
  service_name = ENV['ACTION'] || context.function_name.split('-').last
112
121
 
@@ -54,6 +54,7 @@ module Belt
54
54
  # render layout: false — skip layout (default: no layout)
55
55
  #
56
56
  def render(options = {})
57
+ # Status may be Integer or Symbol (:created, :ok, …)
57
58
  status = options.fetch(:status, 200)
58
59
 
59
60
  html = if options.key?(:inline)
data/lib/belt/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Belt
4
- VERSION = '0.2.9'
4
+ VERSION = '0.2.11'
5
5
  end
@@ -12,39 +12,39 @@
12
12
  <div class="overlay">
13
13
  <h1><%= h(@title) %></h1>
14
14
  <p class="subtitle"><%= h(@subtitle) %></p>
15
- </div>
16
- </div>
17
- <div class="container">
18
- <div class="stack-check">
19
- <div class="check-item check-pass">
20
- <span class="icon">✓</span>
21
- <span>API Gateway</span>
22
- </div>
23
- <div class="arrow">→</div>
24
- <div class="check-item check-pass">
25
- <span class="icon">✓</span>
26
- <span>Lambda</span>
15
+
16
+ <div class="stack-check">
17
+ <div class="check-item check-pass">
18
+ <span class="icon">✓</span>
19
+ <span>API Gateway</span>
20
+ </div>
21
+ <div class="arrow">→</div>
22
+ <div class="check-item check-pass">
23
+ <span class="icon">✓</span>
24
+ <span>Lambda</span>
25
+ </div>
26
+ <div class="arrow">→</div>
27
+ <%- if @dynamodb_connected -%>
28
+ <div class="check-item check-pass">
29
+ <span class="icon">✓</span>
30
+ <span>DynamoDB</span>
31
+ </div>
32
+ <%- else -%>
33
+ <div class="check-item check-neutral">
34
+ <span class="icon">○</span>
35
+ <span>DynamoDB</span>
36
+ </div>
37
+ <%- end -%>
27
38
  </div>
28
- <div class="arrow">→</div>
29
- <%- if @dynamodb_connected -%>
30
- <div class="check-item check-pass">
31
- <span class="icon">✓</span>
32
- <span>DynamoDB</span>
39
+
40
+ <div class="next-steps">
41
+ <h2>Next Steps</h2>
42
+ <ol>
43
+ <li>Scaffold a resource: <code><%= h(@scaffold_hint_cmd) %></code></li>
44
+ <li>Deploy: <code>belt deploy</code></li>
45
+ <li>This page will be replaced once you define your own root route.</li>
46
+ </ol>
33
47
  </div>
34
- <%- else -%>
35
- <div class="check-item check-warn">
36
- <span class="icon">⚠</span>
37
- <span>DynamoDB</span>
38
- </div>
39
- <%- end -%>
40
- </div>
41
- <div class="next-steps">
42
- <h2>Next Steps</h2>
43
- <ol>
44
- <li>Generate a resource: <code>belt g resource post title:string body:text</code></li>
45
- <li>Deploy: <code>belt deploy</code></li>
46
- <li>This page will be replaced once you define your own root route.</li>
47
- </ol>
48
48
  </div>
49
49
  </div>
50
50
  </body>
data/lib/belt.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require_relative 'belt/version'
4
4
  require_relative 'belt/root'
5
+ require_relative 'belt/configuration'
6
+ require_relative 'belt/http_status'
5
7
  require_relative 'belt/parameters'
6
8
  require_relative 'belt/observability'
7
9
  require_relative 'belt/lambda_handler'
@@ -18,6 +20,20 @@ module Belt
18
20
  class << self
19
21
  attr_reader :controller_paths
20
22
 
23
+ # Runtime configuration (lambda/config/environment.rb). Separate from the
24
+ # CLI sandboxed DSL in infrastructure/<env>/belt.rb.
25
+ def configuration
26
+ @configuration ||= Configuration.new
27
+ end
28
+
29
+ def configure
30
+ yield configuration
31
+ end
32
+
33
+ def reset_configuration!
34
+ @configuration = Configuration.new
35
+ end
36
+
21
37
  # Auto-discover lambda/controllers dirs in all loaded gems
22
38
  def gem_controller_paths
23
39
  @gem_controller_paths ||= discover_gem_paths('lambda/controllers')