agentcode 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +59 -0
  3. data/lib/agentcode/blueprint/blueprint_parser.rb +198 -0
  4. data/lib/agentcode/blueprint/blueprint_validator.rb +209 -0
  5. data/lib/agentcode/blueprint/generators/factory_generator.rb +74 -0
  6. data/lib/agentcode/blueprint/generators/policy_generator.rb +154 -0
  7. data/lib/agentcode/blueprint/generators/seeder_generator.rb +160 -0
  8. data/lib/agentcode/blueprint/generators/test_generator.rb +291 -0
  9. data/lib/agentcode/blueprint/manifest_manager.rb +81 -0
  10. data/lib/agentcode/commands/base_command.rb +57 -0
  11. data/lib/agentcode/commands/blueprint_command.rb +549 -0
  12. data/lib/agentcode/commands/export_postman_command.rb +328 -0
  13. data/lib/agentcode/commands/generate_command.rb +563 -0
  14. data/lib/agentcode/commands/install_command.rb +441 -0
  15. data/lib/agentcode/commands/invitation_link_command.rb +107 -0
  16. data/lib/agentcode/concerns/belongs_to_organization.rb +49 -0
  17. data/lib/agentcode/concerns/has_agentcode.rb +93 -0
  18. data/lib/agentcode/concerns/has_audit_trail.rb +125 -0
  19. data/lib/agentcode/concerns/has_auto_scope.rb +91 -0
  20. data/lib/agentcode/concerns/has_permissions.rb +117 -0
  21. data/lib/agentcode/concerns/has_uuid.rb +26 -0
  22. data/lib/agentcode/concerns/has_validation.rb +250 -0
  23. data/lib/agentcode/concerns/hidable_columns.rb +180 -0
  24. data/lib/agentcode/configuration.rb +98 -0
  25. data/lib/agentcode/controllers/auth_controller.rb +242 -0
  26. data/lib/agentcode/controllers/invitations_controller.rb +231 -0
  27. data/lib/agentcode/controllers/resources_controller.rb +813 -0
  28. data/lib/agentcode/engine.rb +65 -0
  29. data/lib/agentcode/mailers/invitation_mailer.rb +22 -0
  30. data/lib/agentcode/middleware/resolve_organization_from_route.rb +72 -0
  31. data/lib/agentcode/models/agentcode_model.rb +387 -0
  32. data/lib/agentcode/models/audit_log.rb +17 -0
  33. data/lib/agentcode/models/organization_invitation.rb +57 -0
  34. data/lib/agentcode/policies/invitation_policy.rb +54 -0
  35. data/lib/agentcode/policies/resource_policy.rb +197 -0
  36. data/lib/agentcode/query_builder.rb +278 -0
  37. data/lib/agentcode/railtie.rb +11 -0
  38. data/lib/agentcode/resource_scope.rb +59 -0
  39. data/lib/agentcode/routes.rb +124 -0
  40. data/lib/agentcode/tasks/agentcode.rake +39 -0
  41. data/lib/agentcode/templates/agentcode.rb +71 -0
  42. data/lib/agentcode/templates/agentcode_model.rb +104 -0
  43. data/lib/agentcode/templates/audit_trail/create_audit_logs.rb.erb +26 -0
  44. data/lib/agentcode/templates/generate/factory.rb.erb +43 -0
  45. data/lib/agentcode/templates/generate/migration.rb.erb +26 -0
  46. data/lib/agentcode/templates/generate/model.rb.erb +55 -0
  47. data/lib/agentcode/templates/generate/policy.rb.erb +52 -0
  48. data/lib/agentcode/templates/generate/scope.rb.erb +31 -0
  49. data/lib/agentcode/templates/multi_tenant/factories/organizations.rb.erb +9 -0
  50. data/lib/agentcode/templates/multi_tenant/factories/roles.rb.erb +9 -0
  51. data/lib/agentcode/templates/multi_tenant/factories/user_roles.rb.erb +10 -0
  52. data/lib/agentcode/templates/multi_tenant/factories/users.rb.erb +9 -0
  53. data/lib/agentcode/templates/multi_tenant/migrations/create_organizations.rb.erb +15 -0
  54. data/lib/agentcode/templates/multi_tenant/migrations/create_roles.rb.erb +15 -0
  55. data/lib/agentcode/templates/multi_tenant/migrations/create_user_roles.rb.erb +16 -0
  56. data/lib/agentcode/templates/multi_tenant/migrations/create_users.rb.erb +15 -0
  57. data/lib/agentcode/templates/multi_tenant/models/organization.rb.erb +18 -0
  58. data/lib/agentcode/templates/multi_tenant/models/role.rb.erb +11 -0
  59. data/lib/agentcode/templates/multi_tenant/models/user.rb.erb +14 -0
  60. data/lib/agentcode/templates/multi_tenant/models/user_role.rb.erb +9 -0
  61. data/lib/agentcode/templates/multi_tenant/policies/organization_policy.rb.erb +6 -0
  62. data/lib/agentcode/templates/multi_tenant/policies/role_policy.rb.erb +6 -0
  63. data/lib/agentcode/templates/multi_tenant/seeders/organization_seeder.rb.erb +9 -0
  64. data/lib/agentcode/templates/multi_tenant/seeders/role_seeder.rb.erb +19 -0
  65. data/lib/agentcode/templates/routes.rb +13 -0
  66. data/lib/agentcode/version.rb +5 -0
  67. data/lib/agentcode/views/lumina/invitation_mailer/invite.html.erb +29 -0
  68. data/lib/agentcode-rails.rb +3 -0
  69. data/lib/agentcode.rb +26 -0
  70. metadata +281 -0
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentCode
4
+ # Column-level visibility control concern.
5
+ # Mirrors the Laravel HidableColumns trait.
6
+ #
7
+ # Base hidden columns: password, remember_token, created_at, updated_at,
8
+ # deleted_at, discarded_at, email_verified_at
9
+ #
10
+ # Usage:
11
+ # class User < ApplicationRecord
12
+ # include AgentCode::HidableColumns
13
+ #
14
+ # agentcode_additional_hidden :secret_field, :internal_notes
15
+ # end
16
+ #
17
+ # Adding computed attributes to JSON responses:
18
+ # class Comment < AgentCode::AgentCodeModel
19
+ # def author_name
20
+ # user&.name || 'Anonymous'
21
+ # end
22
+ #
23
+ # def agentcode_computed_attributes
24
+ # {
25
+ # 'author_name' => author_name
26
+ # }
27
+ # end
28
+ # end
29
+ #
30
+ # Policy-based hiding:
31
+ # class UserPolicy < AgentCode::ResourcePolicy
32
+ # def hidden_attributes_for_show(user)
33
+ # has_role?(user, 'admin') ? [] : ['email', 'phone']
34
+ # end
35
+ #
36
+ # def permitted_attributes_for_show(user)
37
+ # has_role?(user, 'admin') ? ['*'] : ['id', 'name', 'avatar']
38
+ # end
39
+ # end
40
+ module HidableColumns
41
+ extend ActiveSupport::Concern
42
+
43
+ BASE_HIDDEN_COLUMNS = %w[
44
+ password
45
+ password_digest
46
+ remember_token
47
+ created_at
48
+ updated_at
49
+ deleted_at
50
+ discarded_at
51
+ email_verified_at
52
+ ].freeze
53
+
54
+ included do
55
+ class_attribute :additional_hidden_columns, default: []
56
+ end
57
+
58
+ class_methods do
59
+ def agentcode_additional_hidden(*columns)
60
+ self.additional_hidden_columns = columns.map(&:to_s)
61
+ end
62
+ end
63
+
64
+ # Get the list of columns to hide for the current user.
65
+ # Merges base + static + policy-defined hidden columns.
66
+ # Resolves the user from RequestStore automatically.
67
+ #
68
+ # @return [Array<String>] Column names to hide
69
+ def hidden_columns_for(user = nil)
70
+ user ||= agentcode_current_user
71
+ columns = BASE_HIDDEN_COLUMNS.dup
72
+ columns.concat(additional_hidden_columns)
73
+ columns.concat(policy_hidden_columns(user))
74
+ columns.uniq
75
+ end
76
+
77
+ # Serialize to JSON excluding hidden columns and respecting policy whitelist.
78
+ #
79
+ # The current user is resolved automatically from RequestStore. Policy
80
+ # filtering (blacklist + whitelist) is applied AFTER computed attributes
81
+ # are merged, so computed attributes are always subject to policy control.
82
+ #
83
+ # Do NOT override this method. Override +agentcode_computed_attributes+ instead
84
+ # to add computed/virtual attributes to the JSON response.
85
+ #
86
+ # @return [Hash]
87
+ def as_agentcode_json
88
+ user = agentcode_current_user
89
+ hidden = hidden_columns_for(user)
90
+ result = as_json(except: hidden)
91
+
92
+ # Merge computed attributes from model BEFORE applying policy filtering
93
+ computed = agentcode_computed_attributes
94
+ result.merge!(computed) if computed.is_a?(Hash) && computed.any?
95
+
96
+ # Apply blacklist to the final hash (covers DB columns from as_json
97
+ # overrides AND computed attributes from agentcode_computed_attributes)
98
+ hidden_set = Set.new(hidden)
99
+ result.reject! { |key, _| hidden_set.include?(key) }
100
+
101
+ # Apply whitelist to the final hash (covers computed attributes too)
102
+ permitted = policy_permitted_attributes(user)
103
+ if permitted && permitted != ['*']
104
+ permitted_set = Set.new(permitted.map(&:to_s))
105
+ permitted_set.add('id') # id is always allowed
106
+ result.select! { |key, _| permitted_set.include?(key) }
107
+ end
108
+
109
+ result
110
+ end
111
+
112
+ # Override this method in your model to add computed/virtual attributes
113
+ # to the JSON response. These attributes are subject to policy-level
114
+ # blacklist (+hidden_attributes_for_show+) and whitelist
115
+ # (+permitted_attributes_for_show+) just like database columns.
116
+ #
117
+ # @example
118
+ # def agentcode_computed_attributes
119
+ # {
120
+ # 'full_name' => "#{first_name} #{last_name}",
121
+ # 'is_overdue' => due_date&.past?,
122
+ # 'days_until_expiry' => expiry_date ? (expiry_date - Date.current).to_i : nil
123
+ # }
124
+ # end
125
+ #
126
+ # @return [Hash] key-value pairs to merge into the JSON response
127
+ def agentcode_computed_attributes
128
+ {}
129
+ end
130
+
131
+ private
132
+
133
+ # Resolves the current user from RequestStore.
134
+ # @return [Object, nil]
135
+ def agentcode_current_user
136
+ RequestStore.store[:agentcode_current_user] if defined?(RequestStore)
137
+ end
138
+
139
+ # Returns the permitted attributes list from the policy, or nil if no policy.
140
+ def policy_permitted_attributes(user)
141
+ policy_class = Pundit::PolicyFinder.new(self).policy
142
+ return nil unless policy_class
143
+
144
+ policy = policy_class.new(user, self)
145
+ if policy.respond_to?(:permitted_attributes_for_show)
146
+ policy.permitted_attributes_for_show(user)
147
+ end
148
+ rescue StandardError
149
+ nil
150
+ end
151
+
152
+ def policy_hidden_columns(user)
153
+ policy_class = Pundit::PolicyFinder.new(self).policy
154
+ return [] unless policy_class
155
+
156
+ policy = policy_class.new(user, self)
157
+ hidden = []
158
+
159
+ # Blacklist: hidden_attributes_for_show
160
+ if policy.respond_to?(:hidden_attributes_for_show)
161
+ hidden.concat(policy.hidden_attributes_for_show(user))
162
+ end
163
+
164
+ # Whitelist: permitted_attributes_for_show
165
+ # Hide DB columns not in permitted list (computed attributes handled in as_agentcode_json)
166
+ if policy.respond_to?(:permitted_attributes_for_show)
167
+ permitted = policy.permitted_attributes_for_show(user)
168
+ if permitted != ['*']
169
+ all_columns = self.class.column_names
170
+ not_permitted = all_columns - permitted.map(&:to_s)
171
+ hidden.concat(not_permitted)
172
+ end
173
+ end
174
+
175
+ hidden
176
+ rescue StandardError
177
+ []
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentCode
4
+ class Configuration
5
+ attr_accessor :models, :route_groups, :multi_tenant, :invitations, :nested, :test_framework
6
+
7
+ def initialize
8
+ @models = {}
9
+ @route_groups = {}
10
+ @multi_tenant = {
11
+ organization_identifier_column: "id"
12
+ }
13
+ @invitations = {
14
+ expires_days: 7,
15
+ allowed_roles: nil
16
+ }
17
+ @nested = {
18
+ path: "nested",
19
+ max_operations: 50,
20
+ allowed_models: nil
21
+ }
22
+ @test_framework = "rspec"
23
+ end
24
+
25
+ # Register a model with its slug
26
+ # Usage: config.model :posts, 'Post'
27
+ def model(slug, klass_name)
28
+ @models[slug.to_sym] = klass_name.to_s
29
+ end
30
+
31
+ # Register a route group with its configuration
32
+ # Usage: config.route_group :tenant, prefix: ':organization', middleware: [AgentCode::Middleware::ResolveOrganizationFromRoute], models: :all
33
+ def route_group(name, prefix: "", middleware: [], models: :all)
34
+ @route_groups[name.to_sym] = {
35
+ prefix: prefix.to_s,
36
+ middleware: Array(middleware),
37
+ models: models
38
+ }
39
+ end
40
+
41
+ # Resolve a model class from its slug
42
+ def resolve_model(slug)
43
+ klass_name = @models[slug.to_sym]
44
+ raise ActiveRecord::RecordNotFound, "The #{slug} model does not exist" unless klass_name
45
+
46
+ klass = klass_name.constantize
47
+ raise ActiveRecord::RecordNotFound, "The #{slug} model does not exist" unless klass
48
+
49
+ klass
50
+ rescue NameError
51
+ raise ActiveRecord::RecordNotFound, "The #{slug} model does not exist"
52
+ end
53
+
54
+ # Find the slug for a given model class
55
+ def slug_for(model_class)
56
+ class_name = model_class.is_a?(Class) ? model_class.name : model_class.class.name
57
+ @models.each do |slug, klass_name|
58
+ return slug if klass_name == class_name
59
+ end
60
+ nil
61
+ end
62
+
63
+ # Whether a 'tenant' route group is configured
64
+ def has_tenant_group?
65
+ @route_groups.key?(:tenant)
66
+ end
67
+
68
+ # Whether a 'public' route group is configured
69
+ def has_public_group?
70
+ @route_groups.key?(:public)
71
+ end
72
+
73
+ # Resolve the model slugs for a given route group
74
+ def models_for_group(group_name)
75
+ group = @route_groups[group_name.to_sym]
76
+ return [] unless group
77
+
78
+ group_models = group[:models]
79
+ if group_models == :all || group_models == "*"
80
+ @models.keys
81
+ else
82
+ Array(group_models).map(&:to_sym) & @models.keys
83
+ end
84
+ end
85
+
86
+ # Check if a model belongs to the 'public' route group
87
+ def public_model?(slug)
88
+ return false unless has_public_group?
89
+
90
+ models_for_group(:public).include?(slug.to_sym)
91
+ end
92
+
93
+ # Check if a specific slug belongs to a specific group
94
+ def model_in_group?(slug, group_name)
95
+ models_for_group(group_name).include?(slug.to_sym)
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentCode
4
+ # Authentication controller — mirrors Laravel AuthController exactly.
5
+ #
6
+ # Endpoints:
7
+ # POST /api/auth/login
8
+ # POST /api/auth/logout
9
+ # POST /api/auth/password/recover
10
+ # POST /api/auth/password/reset
11
+ # POST /api/auth/register
12
+ class AuthController < ActionController::API
13
+ before_action :authenticate_user!, only: [:logout]
14
+
15
+ # POST /api/auth/login
16
+ def login
17
+ email = params[:email].to_s.strip
18
+ password = params[:password].to_s
19
+
20
+ if email.blank? || password.blank?
21
+ return render json: { message: "Invalid credentials" }, status: :unauthorized
22
+ end
23
+
24
+ user_class = "User".safe_constantize
25
+ return render json: { message: "Invalid credentials" }, status: :unauthorized unless user_class
26
+
27
+ user = user_class.find_by(email: email)
28
+
29
+ unless user&.authenticate(password)
30
+ return render json: { message: "Invalid credentials" }, status: :unauthorized
31
+ end
32
+
33
+ token = generate_api_token(user)
34
+
35
+ # Get the first organization the user belongs to
36
+ organization_slug = nil
37
+ if user.respond_to?(:organizations)
38
+ first_org = user.organizations.first
39
+ organization_slug = first_org&.slug
40
+ end
41
+
42
+ render json: {
43
+ token: token,
44
+ organization_slug: organization_slug
45
+ }, status: :ok
46
+ end
47
+
48
+ # POST /api/auth/logout
49
+ def logout
50
+ user = current_user
51
+
52
+ if user.respond_to?(:regenerate_api_token)
53
+ user.regenerate_api_token
54
+ elsif user.respond_to?(:update_column) && user.class.column_names.include?("api_token")
55
+ user.update_column(:api_token, SecureRandom.hex(32))
56
+ end
57
+
58
+ render json: { message: "Logged out successfully" }, status: :ok
59
+ end
60
+
61
+ # POST /api/auth/password/recover
62
+ def recover_password
63
+ email = params[:email].to_s.strip
64
+
65
+ if email.blank?
66
+ return render json: { errors: { email: ["The email field is required."] } }, status: :unprocessable_entity
67
+ end
68
+
69
+ user_class = "User".safe_constantize
70
+ user = user_class&.find_by(email: email)
71
+
72
+ if user
73
+ token = SecureRandom.hex(32)
74
+
75
+ # Store reset token
76
+ if user.respond_to?(:update_columns)
77
+ user.update_columns(
78
+ reset_password_token: token,
79
+ reset_password_sent_at: Time.current
80
+ )
81
+ end
82
+
83
+ # Send email via mailer if available
84
+ mailer_class = "AgentCode::PasswordRecoveryMailer".safe_constantize
85
+ mailer_class&.recover(user, token)&.deliver_later
86
+ end
87
+
88
+ # Always return success to prevent email enumeration
89
+ render json: { message: "Password recovery email sent." }, status: :ok
90
+ end
91
+
92
+ # POST /api/auth/password/reset
93
+ def reset
94
+ errors = {}
95
+ errors[:token] = ["The token field is required."] if params[:token].blank?
96
+ errors[:email] = ["The email field is required."] if params[:email].blank?
97
+ errors[:password] = ["The password field is required."] if params[:password].blank?
98
+
99
+ if params[:password].present? && params[:password].length < 8
100
+ errors[:password] = ["The password must be at least 8 characters."]
101
+ end
102
+
103
+ if params[:password].present? && params[:password] != params[:password_confirmation]
104
+ errors[:password_confirmation] = ["The password confirmation does not match."]
105
+ end
106
+
107
+ unless errors.empty?
108
+ return render json: { errors: errors }, status: :unprocessable_entity
109
+ end
110
+
111
+ user_class = "User".safe_constantize
112
+ user = user_class&.find_by(email: params[:email])
113
+
114
+ unless user
115
+ return render json: { message: "Token is invalid or expired." }, status: :bad_request
116
+ end
117
+
118
+ # Verify token
119
+ valid_token = user.respond_to?(:reset_password_token) &&
120
+ user.reset_password_token == params[:token] &&
121
+ user.respond_to?(:reset_password_sent_at) &&
122
+ user.reset_password_sent_at.present? &&
123
+ user.reset_password_sent_at > 1.hour.ago
124
+
125
+ unless valid_token
126
+ return render json: { message: "Token is invalid or expired." }, status: :bad_request
127
+ end
128
+
129
+ # Update password
130
+ user.password = params[:password]
131
+ user.reset_password_token = nil
132
+ user.reset_password_sent_at = nil
133
+ user.save!
134
+
135
+ render json: { message: "Password has been reset." }, status: :ok
136
+ end
137
+
138
+ # POST /api/auth/register
139
+ def register_with_invitation
140
+ errors = {}
141
+ errors[:token] = ["The token field is required."] if params[:token].blank?
142
+ errors[:name] = ["The name field is required."] if params[:name].blank?
143
+ errors[:email] = ["The email field is required."] if params[:email].blank?
144
+ errors[:password] = ["The password field is required."] if params[:password].blank?
145
+
146
+ if params[:password].present? && params[:password].length < 8
147
+ errors[:password] = ["The password must be at least 8 characters."]
148
+ end
149
+
150
+ if params[:password].present? && params[:password] != params[:password_confirmation]
151
+ errors[:password_confirmation] = ["The password confirmation does not match."]
152
+ end
153
+
154
+ user_class = "User".safe_constantize
155
+ if user_class && params[:email].present? && user_class.exists?(email: params[:email])
156
+ errors[:email] = ["The email has already been taken."]
157
+ end
158
+
159
+ unless errors.empty?
160
+ return render json: { errors: errors }, status: :unprocessable_entity
161
+ end
162
+
163
+ # Find invitation
164
+ invitation = OrganizationInvitation.find_by(token: params[:token], status: "pending")
165
+
166
+ unless invitation
167
+ return render json: { message: "Invalid or expired invitation token" }, status: :not_found
168
+ end
169
+
170
+ if invitation.expired?
171
+ invitation.update!(status: "expired")
172
+ return render json: { message: "This invitation has expired" }, status: :unprocessable_entity
173
+ end
174
+
175
+ # Validate email matches invitation
176
+ unless invitation.email == params[:email]
177
+ return render json: { message: "Email does not match the invitation" }, status: :unprocessable_entity
178
+ end
179
+
180
+ # Create user
181
+ user = user_class.create!(
182
+ name: params[:name],
183
+ email: params[:email],
184
+ password: params[:password]
185
+ )
186
+
187
+ # Accept invitation (adds user to organization)
188
+ invitation.accept!(user)
189
+
190
+ # Generate token
191
+ token = generate_api_token(user)
192
+
193
+ # Get organization slug for redirect
194
+ organization = invitation.organization
195
+ organization_slug = organization&.slug
196
+
197
+ render json: {
198
+ message: "Registration successful",
199
+ token: token,
200
+ user: user.as_json(except: %w[password_digest api_token reset_password_token]),
201
+ organization_slug: organization_slug
202
+ }, status: :created
203
+ end
204
+
205
+ private
206
+
207
+ def authenticate_user!
208
+ unless current_user
209
+ render json: { message: "Unauthenticated." }, status: :unauthorized
210
+ end
211
+ end
212
+
213
+ def current_user
214
+ @current_user ||= begin
215
+ token = request.headers["Authorization"]&.sub(/\ABearer /, "")
216
+ return nil unless token
217
+
218
+ user_class = "User".safe_constantize
219
+ return nil unless user_class
220
+
221
+ if user_class.respond_to?(:find_by_api_token)
222
+ user_class.find_by_api_token(token)
223
+ elsif user_class.column_names.include?("api_token")
224
+ user_class.find_by(api_token: token)
225
+ end
226
+ end
227
+ end
228
+
229
+ def generate_api_token(user)
230
+ if user.respond_to?(:regenerate_api_token)
231
+ user.regenerate_api_token
232
+ user.api_token
233
+ elsif user.class.column_names.include?("api_token")
234
+ token = SecureRandom.hex(32)
235
+ user.update_column(:api_token, token)
236
+ token
237
+ else
238
+ SecureRandom.hex(32)
239
+ end
240
+ end
241
+ end
242
+ end