belt 0.2.18 → 0.2.20

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.

Potentially problematic release.


This version of belt might be problematic. Click here for more details.

@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'tables_command'
4
+ require_relative '../inflector'
5
+
6
+ module Belt
7
+ module CLI
8
+ class IndexCommand
9
+ MODULE_DIR = 'infrastructure/modules/app'
10
+ DYNAMODB_TF = File.join(MODULE_DIR, 'dynamodb.tf')
11
+
12
+ def self.run(args)
13
+ action = args.shift
14
+
15
+ case action
16
+ when 'add', nil
17
+ add(args)
18
+ when 'remove', 'rm'
19
+ remove(args)
20
+ when '--help', '-h'
21
+ puts usage
22
+ else
23
+ # Treat first arg as table name if no subcommand
24
+ add([action] + args)
25
+ end
26
+ end
27
+
28
+ def self.usage
29
+ <<~USAGE
30
+ Usage: belt generate index <table> <IndexName> --partition-key <key> [--sort-key <key>]
31
+ belt destroy index <table> <IndexName>
32
+
33
+ Add or remove a Global Secondary Index (GSI) from dynamodb.tf.
34
+
35
+ Examples:
36
+ belt generate index messages ConversationIndex --partition-key conversation_id
37
+ belt generate index messages RecentByUserIndex --partition-key user_id --sort-key created_at
38
+ belt destroy index messages ConversationIndex
39
+
40
+ Note: After modifying indexes, run `belt deploy` to apply changes to AWS.
41
+ Adding a GSI to an existing table takes ~5-10 minutes (AWS limitation).
42
+ USAGE
43
+ end
44
+
45
+ def self.add(args)
46
+ table, index_name, partition_key, sort_key = parse_add_args(args)
47
+ new(table, index_name, partition_key: partition_key, sort_key: sort_key).add
48
+ end
49
+
50
+ def self.remove(args)
51
+ table = args.shift
52
+ index_name = args.shift
53
+
54
+ if table.nil? || index_name.nil?
55
+ abort "Usage: belt destroy index <table> <IndexName>\n\n" \
56
+ 'Example: belt destroy index messages ConversationIndex'
57
+ end
58
+
59
+ new(table, index_name).remove
60
+ end
61
+
62
+ def self.parse_add_args(args)
63
+ table = args.shift
64
+ index_name = args.shift
65
+ partition_key = nil
66
+ sort_key = nil
67
+
68
+ i = 0
69
+ while i < args.length
70
+ case args[i]
71
+ when '--partition-key', '-p'
72
+ i += 1
73
+ partition_key = args[i]
74
+ when '--sort-key', '-s'
75
+ i += 1
76
+ sort_key = args[i]
77
+ end
78
+ i += 1
79
+ end
80
+
81
+ if table.nil? || index_name.nil? || partition_key.nil?
82
+ abort "Usage: belt generate index <table> <IndexName> --partition-key <key> [--sort-key <key>]\n\n" \
83
+ 'Example: belt generate index messages ConversationIndex --partition-key conversation_id'
84
+ end
85
+
86
+ [table, index_name, partition_key, sort_key]
87
+ end
88
+
89
+ def initialize(table, index_name, partition_key: nil, sort_key: nil)
90
+ @table = table
91
+ @index_name = index_name
92
+ @partition_key = partition_key
93
+ @sort_key = sort_key
94
+ end
95
+
96
+ def add
97
+ validate_tf_exists!
98
+
99
+ content = File.read(DYNAMODB_TF)
100
+ table_resource = "aws_dynamodb_table\" \"#{@table}\""
101
+
102
+ unless content.include?(table_resource)
103
+ abort "Error: Table '#{@table}' not found in #{DYNAMODB_TF}.\n" \
104
+ 'Run `belt setup tables` first to generate the table.'
105
+ end
106
+
107
+ if content.include?("name = \"#{@index_name}\"")
108
+ puts " skip #{@index_name} (already exists on #{@table})"
109
+ return
110
+ end
111
+
112
+ dynamo_pk = Belt::Inflector.camelize_lower(@partition_key)
113
+ dynamo_sk = @sort_key ? Belt::Inflector.camelize_lower(@sort_key) : nil
114
+
115
+ # Build the GSI block
116
+ gsi_block = build_gsi_block(dynamo_pk, dynamo_sk)
117
+
118
+ # Build attribute blocks for new keys
119
+ attr_blocks = build_attribute_blocks(content, dynamo_pk, dynamo_sk)
120
+
121
+ # Insert into the table resource
122
+ insert_gsi(content, gsi_block, attr_blocks)
123
+
124
+ puts " create #{@index_name} on #{@table} (partition: #{dynamo_pk}#{", sort: #{dynamo_sk}" if dynamo_sk})"
125
+ puts "\n Run `belt deploy` to apply. Adding a GSI to an existing table takes ~5-10 min."
126
+ end
127
+
128
+ def remove
129
+ validate_tf_exists!
130
+
131
+ content = File.read(DYNAMODB_TF)
132
+
133
+ unless content.include?("name = \"#{@index_name}\"")
134
+ abort "Error: Index '#{@index_name}' not found in #{DYNAMODB_TF}."
135
+ end
136
+
137
+ # Remove the GSI block
138
+ content.sub!(/\n\s*global_secondary_index \{\n\s*name\s*=\s*"#{Regexp.escape(@index_name)}".*?\n\s*\}/m, '')
139
+
140
+ File.write(DYNAMODB_TF, content)
141
+ puts " remove #{@index_name} from #{@table}"
142
+ puts "\n Run `belt deploy` to apply."
143
+ end
144
+
145
+ private
146
+
147
+ def validate_tf_exists!
148
+ return if File.exist?(DYNAMODB_TF)
149
+
150
+ abort "Error: #{DYNAMODB_TF} not found.\nRun `belt setup tables` first."
151
+ end
152
+
153
+ def build_gsi_block(dynamo_pk, dynamo_sk)
154
+ lines = []
155
+ lines << ' global_secondary_index {'
156
+ lines << " name = \"#{@index_name}\""
157
+ lines << " hash_key = \"#{dynamo_pk}\""
158
+ lines << " range_key = \"#{dynamo_sk}\"" if dynamo_sk
159
+ lines << ' projection_type = "ALL"'
160
+ lines << ' }'
161
+ lines.join("\n")
162
+ end
163
+
164
+ def build_attribute_blocks(content, dynamo_pk, dynamo_sk)
165
+ blocks = []
166
+ [dynamo_pk, dynamo_sk].compact.each do |key|
167
+ next if content.include?("name = \"#{key}\"")
168
+
169
+ blocks << "\n attribute {\n name = \"#{key}\"\n type = \"S\"\n }"
170
+ end
171
+ blocks.join
172
+ end
173
+
174
+ def insert_gsi(content, gsi_block, attr_blocks)
175
+ # Find the table's resource block and insert before point_in_time_recovery
176
+ table_pattern = /resource "aws_dynamodb_table" "#{Regexp.escape(@table)}" \{.*?point_in_time_recovery/m
177
+
178
+ content.sub!(table_pattern) do |match|
179
+ # Insert attributes after last existing attribute block
180
+ unless attr_blocks.empty?
181
+ match.sub!(/( attribute \{.*?\n \})(?!.*attribute)/m) { |attr_match| "#{attr_match}#{attr_blocks}" }
182
+ end
183
+
184
+ # Insert GSI before point_in_time_recovery
185
+ match.sub('point_in_time_recovery', "#{gsi_block}\n\n point_in_time_recovery")
186
+ end
187
+
188
+ File.write(DYNAMODB_TF, content)
189
+ end
190
+ end
191
+ end
192
+ end
@@ -34,7 +34,7 @@ module Belt
34
34
  SUPPORTED_KEYS = %w[
35
35
  timeout memory_size env_vars env_keys
36
36
  s3_buckets dynamodb_tables sns_triggers sqs_triggers
37
- reserved_concurrency ephemeral_storage
37
+ reserved_concurrency ephemeral_storage iam_policy_arns
38
38
  ].freeze
39
39
 
40
40
  def self.run(args)
@@ -112,6 +112,7 @@ module Belt
112
112
  sqs_triggers SQS queue triggers
113
113
  reserved_concurrency Reserved concurrency limit
114
114
  ephemeral_storage Ephemeral storage in MB (512-10240)
115
+ iam_policy_arns Additional IAM policy ARNs (supports ref())
115
116
  HELP
116
117
  end
117
118
 
@@ -14,9 +14,10 @@ module Belt
14
14
  non_param = segments.reject { |s| s.start_with?(':', '{') }
15
15
  return gateway.name if non_param.empty?
16
16
 
17
- # Nested resources (/posts/{id}/comments) and scoped resources (/admin/users):
18
- # join non-param segments posts/comments, admin/users
19
- return non_param.map { |s| s.gsub('-', '_') }.join('/') if route.resource? && non_param.length > 1
17
+ # Nested resources (/posts/{id}/comments): use the last non-param segment
18
+ # as the controller name (matches Rails — nesting affects URL, not controller lookup).
19
+ # Scoped resources (/admin/users) still use the full path when controller is explicitly set.
20
+ return non_param.last.gsub('-', '_') if route.resource? && non_param.length > 1
20
21
 
21
22
  # For non-resource routes with a single segment (e.g., post '/signup' in :onboarding),
22
23
  # the segment is the action name, not the controller. Use the gateway name as controller.
@@ -250,18 +250,8 @@ module Belt
250
250
  def output_concise(routes)
251
251
  return puts('No routes defined.') if routes.empty?
252
252
 
253
- multi_gateway = routes.map { |r| r[:gateway] }.uniq.length > 1
254
253
  verb_w = [routes.map { |r| r[:verb].length }.max, 6].max
255
254
  path_w = [routes.map { |r| r[:path].length }.max, 4].max
256
-
257
- if multi_gateway
258
- output_concise_multi_gateway(routes, verb_w, path_w)
259
- else
260
- output_concise_single_gateway(routes, verb_w, path_w)
261
- end
262
- end
263
-
264
- def output_concise_multi_gateway(routes, verb_w, path_w)
265
255
  gw_w = [routes.map { |r| r[:gateway].to_s.length }.max, 7].max
266
256
  lam_w = [routes.map { |r| r[:lambda].length }.max, 6].max
267
257
 
@@ -278,15 +268,6 @@ module Belt
278
268
  end
279
269
  end
280
270
 
281
- def output_concise_single_gateway(routes, verb_w, path_w)
282
- puts "#{'VERB'.ljust(verb_w)} #{'PATH'.ljust(path_w)} CONTROLLER#ACTION"
283
- puts '-' * (verb_w + path_w + 30)
284
-
285
- routes.each do |r|
286
- puts "#{r[:verb].ljust(verb_w)} #{r[:path].ljust(path_w)} #{r[:controller]}##{r[:action]}"
287
- end
288
- end
289
-
290
271
  def route_specificity(path, verb)
291
272
  segments = path.split('/').reject(&:empty?)
292
273
  param_count = segments.count { |s| s.start_with?('{') }
@@ -75,10 +75,11 @@ module Belt
75
75
  end
76
76
  end
77
77
 
78
- def initialize(name, fields, force: false)
78
+ def initialize(name, fields, force: false, quiet: false)
79
79
  @name = name.downcase.gsub(/[^a-z0-9_]/, '_')
80
80
  @fields = fields
81
81
  @force = force
82
+ @quiet = quiet
82
83
  @overwrite_all = false
83
84
  @singular_name = Belt::Inflector.singularize(@name)
84
85
  @resource_name = Belt::Inflector.pluralize(@singular_name)
@@ -103,14 +104,7 @@ module Belt
103
104
 
104
105
  inject_routes
105
106
 
106
- puts "\n✓ Views for '#{@singular_name}' generated!"
107
- puts "\nFiles created:"
108
- puts " #{pages_dir}/#{@plural_class_name}Index.jsx"
109
- puts " #{pages_dir}/#{@class_name}Show.jsx"
110
- puts " #{pages_dir}/#{@class_name}New.jsx"
111
- puts " #{pages_dir}/#{@class_name}Edit.jsx"
112
- puts " #{pages_dir}/#{@class_name}Form.jsx"
113
- puts ' frontend/src/App.jsx (updated)'
107
+ puts "\n✓ Views for '#{@singular_name}' generated!" unless @quiet
114
108
  end
115
109
 
116
110
  private
data/lib/belt/cli.rb CHANGED
@@ -6,6 +6,7 @@ require_relative 'cli/env_resolver'
6
6
  require_relative 'cli/new_command'
7
7
  require_relative 'cli/generate_command'
8
8
  require_relative 'cli/destroy_command'
9
+ require_relative 'cli/index_command'
9
10
  require_relative 'cli/frontend_command'
10
11
  require_relative 'cli/frontend_setup_command'
11
12
  require_relative 'cli/frontend_deploy_command'
@@ -42,6 +42,14 @@ module Belt
42
42
  word.to_s.split('_').map(&:capitalize).join
43
43
  end
44
44
 
45
+ # "conversation_id" → "conversationId"
46
+ def camelize_lower(word)
47
+ parts = word.to_s.split('_')
48
+ return word if parts.empty?
49
+
50
+ parts.first + parts[1..].map(&:capitalize).join
51
+ end
52
+
45
53
  # "BlogPost" → "blog_post"
46
54
  def underscore(word)
47
55
  word.to_s
@@ -65,8 +65,9 @@ module Belt
65
65
  resource_name = name.to_s
66
66
  singular = @gateway.send(:singularize, resource_name)
67
67
  param_name = options[:param] || "#{singular}_id"
68
+ # Auto-add this resource's table before merging inherited tables
69
+ options = options.merge(tables: [resource_name.to_sym]) unless options.key?(:tables)
68
70
  options = merge_inherited_options(options)
69
- options = @gateway.send(:auto_infer_tables, resource_name, options)
70
71
  resource_options = options.merge(route_type: :resources)
71
72
  actions = @gateway.send(:determine_actions, options)
72
73
 
@@ -156,7 +157,7 @@ module Belt
156
157
  def initialize(name, options = {})
157
158
  @name = name.to_s
158
159
  @routes = []
159
- @default_auth = options[:auth] || :cognito
160
+ @default_auth = options[:auth] || :none
160
161
  @default_lambda = options[:lambda] || name
161
162
  @default_cors = options.fetch(:cors, true)
162
163
  @default_tables = Array(options[:tables] || [])
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.18'
4
+ VERSION = '0.2.20'
5
5
  end
@@ -13,6 +13,14 @@ resource "aws_cognito_user_pool" "<%= pool[:name] %>" {
13
13
  require_uppercase = true
14
14
  }
15
15
 
16
+ # Admin-only signup — users cannot self-register.
17
+ # Remove this block to allow public sign-up.
18
+ <% unless @signup -%>
19
+ admin_create_user_config {
20
+ allow_admin_create_user_only = true
21
+ }
22
+
23
+ <% end -%>
16
24
  auto_verified_attributes = ["email"]
17
25
 
18
26
  account_recovery_setting {
@@ -43,6 +51,9 @@ resource "aws_cognito_user_pool_client" "<%= pool[:name] %>" {
43
51
 
44
52
  explicit_auth_flows = [
45
53
  "ALLOW_USER_SRP_AUTH",
54
+ <% if @signup -%>
55
+ "ALLOW_USER_PASSWORD_AUTH",
56
+ <% end -%>
46
57
  "ALLOW_REFRESH_TOKEN_AUTH"
47
58
  ]
48
59
 
@@ -0,0 +1,55 @@
1
+ import { useState } from 'react'
2
+ import { confirmSignUp } from '../../lib/auth'
3
+
4
+ export default function ConfirmEmail() {
5
+ const params = new URLSearchParams(window.location.search)
6
+ const [email, setEmail] = useState(params.get('email') || '')
7
+ const [code, setCode] = useState('')
8
+ const [error, setError] = useState('')
9
+ const [loading, setLoading] = useState(false)
10
+ const [confirmed, setConfirmed] = useState(false)
11
+
12
+ async function handleSubmit(e) {
13
+ e.preventDefault()
14
+ setError('')
15
+ setLoading(true)
16
+
17
+ try {
18
+ await confirmSignUp(email, code)
19
+ setConfirmed(true)
20
+ } catch (err) {
21
+ setError(err.message || 'Confirmation failed')
22
+ } finally {
23
+ setLoading(false)
24
+ }
25
+ }
26
+
27
+ if (confirmed) {
28
+ return (
29
+ <div className="auth-page">
30
+ <div className="auth-form">
31
+ <h2>Email Verified! ✓</h2>
32
+ <p>Your account is ready.</p>
33
+ <a href="/login" className="auth-btn">Sign In</a>
34
+ </div>
35
+ </div>
36
+ )
37
+ }
38
+
39
+ return (
40
+ <div className="auth-page">
41
+ <form className="auth-form" onSubmit={handleSubmit}>
42
+ <h2>Verify Email</h2>
43
+ <p>Enter the code sent to your email.</p>
44
+ {error && <div className="auth-error">{error}</div>}
45
+ <input type="email" placeholder="Email" value={email}
46
+ onChange={e => setEmail(e.target.value)} required />
47
+ <input type="text" placeholder="Verification code" value={code}
48
+ onChange={e => setCode(e.target.value)} required autoFocus />
49
+ <button type="submit" disabled={loading}>
50
+ {loading ? 'Verifying...' : 'Verify'}
51
+ </button>
52
+ </form>
53
+ </div>
54
+ )
55
+ }
@@ -0,0 +1,79 @@
1
+ import { useState } from 'react'
2
+ import { signIn, completeNewPassword } from '../../lib/auth'
3
+ import './auth.css'
4
+
5
+ export default function Login({ onLogin }) {
6
+ const [email, setEmail] = useState('')
7
+ const [password, setPassword] = useState('')
8
+ const [newPassword, setNewPassword] = useState('')
9
+ const [error, setError] = useState('')
10
+ const [challenge, setChallenge] = useState(null)
11
+ const [loading, setLoading] = useState(false)
12
+
13
+ async function handleSubmit(e) {
14
+ e.preventDefault()
15
+ setError('')
16
+ setLoading(true)
17
+
18
+ try {
19
+ const result = await signIn(email, password)
20
+ if (result.challenge === 'NEW_PASSWORD_REQUIRED') {
21
+ setChallenge(result)
22
+ } else {
23
+ onLogin()
24
+ }
25
+ } catch (err) {
26
+ setError(err.message || 'Sign in failed')
27
+ } finally {
28
+ setLoading(false)
29
+ }
30
+ }
31
+
32
+ async function handleNewPassword(e) {
33
+ e.preventDefault()
34
+ setError('')
35
+ setLoading(true)
36
+
37
+ try {
38
+ await completeNewPassword(email, newPassword, challenge.session)
39
+ onLogin()
40
+ } catch (err) {
41
+ setError(err.message || 'Password change failed')
42
+ } finally {
43
+ setLoading(false)
44
+ }
45
+ }
46
+
47
+ if (challenge) {
48
+ return (
49
+ <div className="auth-page">
50
+ <form className="auth-form" onSubmit={handleNewPassword}>
51
+ <h2>Set New Password</h2>
52
+ <p>Please set a permanent password for your account.</p>
53
+ {error && <div className="auth-error">{error}</div>}
54
+ <input type="password" placeholder="New password" value={newPassword}
55
+ onChange={e => setNewPassword(e.target.value)} required autoFocus />
56
+ <button type="submit" disabled={loading}>
57
+ {loading ? 'Saving...' : 'Set Password'}
58
+ </button>
59
+ </form>
60
+ </div>
61
+ )
62
+ }
63
+
64
+ return (
65
+ <div className="auth-page">
66
+ <form className="auth-form" onSubmit={handleSubmit}>
67
+ <h2>Sign In</h2>
68
+ {error && <div className="auth-error">{error}</div>}
69
+ <input type="email" placeholder="Email" value={email}
70
+ onChange={e => setEmail(e.target.value)} required autoFocus />
71
+ <input type="password" placeholder="Password" value={password}
72
+ onChange={e => setPassword(e.target.value)} required />
73
+ <button type="submit" disabled={loading}>
74
+ {loading ? 'Signing in...' : 'Sign In'}
75
+ </button>
76
+ </form>
77
+ </div>
78
+ )
79
+ }
@@ -0,0 +1,9 @@
1
+ import { isAuthenticated } from '../lib/auth'
2
+ import { Navigate } from 'react-router-dom'
3
+
4
+ export default function ProtectedRoute({ children }) {
5
+ if (!isAuthenticated()) {
6
+ return <Navigate to="/login" replace />
7
+ }
8
+ return children
9
+ }
@@ -0,0 +1,58 @@
1
+ import { useState } from 'react'
2
+ import { signUp } from '../../lib/auth'
3
+
4
+ export default function SignUp() {
5
+ const [email, setEmail] = useState('')
6
+ const [password, setPassword] = useState('')
7
+ const [error, setError] = useState('')
8
+ const [loading, setLoading] = useState(false)
9
+ const [needsConfirmation, setNeedsConfirmation] = useState(false)
10
+
11
+ async function handleSubmit(e) {
12
+ e.preventDefault()
13
+ setError('')
14
+ setLoading(true)
15
+
16
+ try {
17
+ const result = await signUp(email, password)
18
+ if (result.needsConfirmation) setNeedsConfirmation(true)
19
+ } catch (err) {
20
+ setError(err.message || 'Sign up failed')
21
+ } finally {
22
+ setLoading(false)
23
+ }
24
+ }
25
+
26
+ if (needsConfirmation) {
27
+ return (
28
+ <div className="auth-page">
29
+ <div className="auth-form">
30
+ <h2>Check Your Email</h2>
31
+ <p>We sent a verification code to <strong>{email}</strong>.</p>
32
+ <a href={`/confirm?email=${encodeURIComponent(email)}`} className="auth-btn">
33
+ Enter Code
34
+ </a>
35
+ </div>
36
+ </div>
37
+ )
38
+ }
39
+
40
+ return (
41
+ <div className="auth-page">
42
+ <form className="auth-form" onSubmit={handleSubmit}>
43
+ <h2>Create Account</h2>
44
+ {error && <div className="auth-error">{error}</div>}
45
+ <input type="email" placeholder="Email" value={email}
46
+ onChange={e => setEmail(e.target.value)} required autoFocus />
47
+ <input type="password" placeholder="Password (8+ characters)" value={password}
48
+ onChange={e => setPassword(e.target.value)} required minLength={8} />
49
+ <button type="submit" disabled={loading}>
50
+ {loading ? 'Creating...' : 'Create Account'}
51
+ </button>
52
+ <p className="auth-link">
53
+ Already have an account? <a href="/login">Sign in</a>
54
+ </p>
55
+ </form>
56
+ </div>
57
+ )
58
+ }
@@ -0,0 +1,34 @@
1
+ import { getToken } from './auth'
2
+
3
+ const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'
4
+
5
+ export async function apiClient(path, options = {}) {
6
+ const { method = 'GET', body, headers = {} } = options
7
+
8
+ const token = getToken()
9
+ const config = {
10
+ method,
11
+ headers: {
12
+ Accept: 'application/json',
13
+ ...(token ? { Authorization: token } : {}),
14
+ ...headers
15
+ }
16
+ }
17
+
18
+ if (body !== undefined && body !== null) {
19
+ config.headers['Content-Type'] = 'application/json'
20
+ config.body = JSON.stringify(body)
21
+ }
22
+
23
+ const response = await fetch(`${API_URL}${path}`, config)
24
+ const data = await response.json()
25
+
26
+ if (response.status === 401) {
27
+ window.location.href = '/login'
28
+ throw new Error('Unauthorized')
29
+ }
30
+
31
+ if (!response.ok) throw new Error(data.error || `Request failed: ${response.status}`)
32
+
33
+ return data
34
+ }