rubyn-code 0.1.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +620 -0
- data/db/migrations/000_create_schema_migrations.sql +4 -0
- data/db/migrations/001_create_sessions.sql +16 -0
- data/db/migrations/002_create_messages.sql +16 -0
- data/db/migrations/003_create_tasks.sql +17 -0
- data/db/migrations/004_create_task_dependencies.sql +8 -0
- data/db/migrations/005_create_memories.sql +44 -0
- data/db/migrations/006_create_cost_records.sql +16 -0
- data/db/migrations/007_create_hooks.sql +12 -0
- data/db/migrations/008_create_skills_cache.sql +8 -0
- data/db/migrations/009_create_teams.sql +27 -0
- data/db/migrations/010_create_instincts.sql +15 -0
- data/exe/rubyn-code +6 -0
- data/lib/rubyn_code/agent/conversation.rb +193 -0
- data/lib/rubyn_code/agent/loop.rb +517 -0
- data/lib/rubyn_code/agent/loop_detector.rb +78 -0
- data/lib/rubyn_code/auth/oauth.rb +174 -0
- data/lib/rubyn_code/auth/server.rb +126 -0
- data/lib/rubyn_code/auth/token_store.rb +153 -0
- data/lib/rubyn_code/autonomous/daemon.rb +233 -0
- data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
- data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
- data/lib/rubyn_code/background/job.rb +19 -0
- data/lib/rubyn_code/background/notifier.rb +44 -0
- data/lib/rubyn_code/background/worker.rb +146 -0
- data/lib/rubyn_code/cli/app.rb +118 -0
- data/lib/rubyn_code/cli/input_handler.rb +79 -0
- data/lib/rubyn_code/cli/renderer.rb +205 -0
- data/lib/rubyn_code/cli/repl.rb +519 -0
- data/lib/rubyn_code/cli/spinner.rb +100 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
- data/lib/rubyn_code/config/defaults.rb +43 -0
- data/lib/rubyn_code/config/project_config.rb +120 -0
- data/lib/rubyn_code/config/settings.rb +127 -0
- data/lib/rubyn_code/context/auto_compact.rb +81 -0
- data/lib/rubyn_code/context/compactor.rb +89 -0
- data/lib/rubyn_code/context/manager.rb +91 -0
- data/lib/rubyn_code/context/manual_compact.rb +87 -0
- data/lib/rubyn_code/context/micro_compact.rb +135 -0
- data/lib/rubyn_code/db/connection.rb +176 -0
- data/lib/rubyn_code/db/migrator.rb +146 -0
- data/lib/rubyn_code/db/schema.rb +106 -0
- data/lib/rubyn_code/hooks/built_in.rb +124 -0
- data/lib/rubyn_code/hooks/registry.rb +99 -0
- data/lib/rubyn_code/hooks/runner.rb +88 -0
- data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
- data/lib/rubyn_code/learning/extractor.rb +191 -0
- data/lib/rubyn_code/learning/injector.rb +138 -0
- data/lib/rubyn_code/learning/instinct.rb +172 -0
- data/lib/rubyn_code/llm/client.rb +218 -0
- data/lib/rubyn_code/llm/message_builder.rb +116 -0
- data/lib/rubyn_code/llm/streaming.rb +203 -0
- data/lib/rubyn_code/mcp/client.rb +139 -0
- data/lib/rubyn_code/mcp/config.rb +83 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
- data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
- data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
- data/lib/rubyn_code/memory/models.rb +62 -0
- data/lib/rubyn_code/memory/search.rb +181 -0
- data/lib/rubyn_code/memory/session_persistence.rb +194 -0
- data/lib/rubyn_code/memory/store.rb +199 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
- data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
- data/lib/rubyn_code/observability/models.rb +29 -0
- data/lib/rubyn_code/observability/token_counter.rb +42 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
- data/lib/rubyn_code/output/diff_renderer.rb +212 -0
- data/lib/rubyn_code/output/formatter.rb +120 -0
- data/lib/rubyn_code/permissions/deny_list.rb +49 -0
- data/lib/rubyn_code/permissions/policy.rb +59 -0
- data/lib/rubyn_code/permissions/prompter.rb +80 -0
- data/lib/rubyn_code/permissions/tier.rb +22 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
- data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
- data/lib/rubyn_code/skills/catalog.rb +70 -0
- data/lib/rubyn_code/skills/document.rb +80 -0
- data/lib/rubyn_code/skills/loader.rb +57 -0
- data/lib/rubyn_code/sub_agents/runner.rb +168 -0
- data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
- data/lib/rubyn_code/tasks/dag.rb +208 -0
- data/lib/rubyn_code/tasks/manager.rb +212 -0
- data/lib/rubyn_code/tasks/models.rb +31 -0
- data/lib/rubyn_code/teams/mailbox.rb +128 -0
- data/lib/rubyn_code/teams/manager.rb +175 -0
- data/lib/rubyn_code/teams/teammate.rb +38 -0
- data/lib/rubyn_code/tools/background_run.rb +41 -0
- data/lib/rubyn_code/tools/base.rb +84 -0
- data/lib/rubyn_code/tools/bash.rb +81 -0
- data/lib/rubyn_code/tools/bundle_add.rb +53 -0
- data/lib/rubyn_code/tools/bundle_install.rb +41 -0
- data/lib/rubyn_code/tools/compact.rb +57 -0
- data/lib/rubyn_code/tools/db_migrate.rb +52 -0
- data/lib/rubyn_code/tools/edit_file.rb +49 -0
- data/lib/rubyn_code/tools/executor.rb +62 -0
- data/lib/rubyn_code/tools/git_commit.rb +97 -0
- data/lib/rubyn_code/tools/git_diff.rb +61 -0
- data/lib/rubyn_code/tools/git_log.rb +59 -0
- data/lib/rubyn_code/tools/git_status.rb +59 -0
- data/lib/rubyn_code/tools/glob.rb +44 -0
- data/lib/rubyn_code/tools/grep.rb +81 -0
- data/lib/rubyn_code/tools/load_skill.rb +41 -0
- data/lib/rubyn_code/tools/memory_search.rb +77 -0
- data/lib/rubyn_code/tools/memory_write.rb +52 -0
- data/lib/rubyn_code/tools/rails_generate.rb +54 -0
- data/lib/rubyn_code/tools/read_file.rb +38 -0
- data/lib/rubyn_code/tools/read_inbox.rb +64 -0
- data/lib/rubyn_code/tools/registry.rb +48 -0
- data/lib/rubyn_code/tools/review_pr.rb +145 -0
- data/lib/rubyn_code/tools/run_specs.rb +75 -0
- data/lib/rubyn_code/tools/schema.rb +59 -0
- data/lib/rubyn_code/tools/send_message.rb +53 -0
- data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
- data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
- data/lib/rubyn_code/tools/task.rb +148 -0
- data/lib/rubyn_code/tools/web_fetch.rb +108 -0
- data/lib/rubyn_code/tools/web_search.rb +196 -0
- data/lib/rubyn_code/tools/write_file.rb +30 -0
- data/lib/rubyn_code/version.rb +5 -0
- data/lib/rubyn_code.rb +203 -0
- data/skills/code_quality/fits_in_your_head.md +189 -0
- data/skills/code_quality/naming_conventions.md +213 -0
- data/skills/code_quality/null_object.md +205 -0
- data/skills/code_quality/technical_debt.md +135 -0
- data/skills/code_quality/value_objects.md +216 -0
- data/skills/code_quality/yagni.md +176 -0
- data/skills/design_patterns/adapter.md +191 -0
- data/skills/design_patterns/bridge_memento_visitor.md +254 -0
- data/skills/design_patterns/builder.md +158 -0
- data/skills/design_patterns/command.md +126 -0
- data/skills/design_patterns/composite.md +147 -0
- data/skills/design_patterns/decorator.md +204 -0
- data/skills/design_patterns/facade.md +133 -0
- data/skills/design_patterns/factory_method.md +169 -0
- data/skills/design_patterns/iterator.md +116 -0
- data/skills/design_patterns/mediator.md +133 -0
- data/skills/design_patterns/observer.md +177 -0
- data/skills/design_patterns/proxy.md +140 -0
- data/skills/design_patterns/singleton.md +124 -0
- data/skills/design_patterns/state.md +207 -0
- data/skills/design_patterns/strategy.md +127 -0
- data/skills/design_patterns/template_method.md +173 -0
- data/skills/gems/devise.md +365 -0
- data/skills/gems/dry_rb.md +186 -0
- data/skills/gems/factory_bot.md +268 -0
- data/skills/gems/faraday.md +263 -0
- data/skills/gems/graphql_ruby.md +514 -0
- data/skills/gems/pundit.md +446 -0
- data/skills/gems/redis.md +219 -0
- data/skills/gems/rubocop.md +257 -0
- data/skills/gems/sidekiq.md +360 -0
- data/skills/gems/stripe.md +224 -0
- data/skills/minitest/assertions.md +185 -0
- data/skills/minitest/fixtures.md +238 -0
- data/skills/minitest/integration_tests.md +210 -0
- data/skills/minitest/mailers_and_jobs.md +218 -0
- data/skills/minitest/mocking_stubbing.md +202 -0
- data/skills/minitest/service_tests_and_performance.md +246 -0
- data/skills/minitest/structure_and_conventions.md +169 -0
- data/skills/minitest/system_tests.md +237 -0
- data/skills/rails/action_cable.md +160 -0
- data/skills/rails/active_record_basics.md +174 -0
- data/skills/rails/active_storage.md +242 -0
- data/skills/rails/api_design.md +212 -0
- data/skills/rails/associations.md +182 -0
- data/skills/rails/background_jobs.md +212 -0
- data/skills/rails/caching.md +158 -0
- data/skills/rails/callbacks.md +135 -0
- data/skills/rails/concerns_controllers.md +218 -0
- data/skills/rails/concerns_models.md +280 -0
- data/skills/rails/controllers.md +190 -0
- data/skills/rails/engines.md +201 -0
- data/skills/rails/form_objects.md +168 -0
- data/skills/rails/hotwire.md +229 -0
- data/skills/rails/internationalization.md +192 -0
- data/skills/rails/logging.md +198 -0
- data/skills/rails/mailers.md +180 -0
- data/skills/rails/migrations.md +200 -0
- data/skills/rails/multitenancy.md +207 -0
- data/skills/rails/n_plus_one.md +151 -0
- data/skills/rails/presenters.md +244 -0
- data/skills/rails/query_objects.md +177 -0
- data/skills/rails/routing.md +194 -0
- data/skills/rails/scopes.md +187 -0
- data/skills/rails/security.md +233 -0
- data/skills/rails/serializers.md +243 -0
- data/skills/rails/service_objects.md +184 -0
- data/skills/rails/testing_strategy.md +258 -0
- data/skills/rails/validations.md +206 -0
- data/skills/refactoring/code_smells.md +251 -0
- data/skills/refactoring/command_query_separation.md +166 -0
- data/skills/refactoring/encapsulate_collection.md +125 -0
- data/skills/refactoring/extract_class.md +138 -0
- data/skills/refactoring/extract_method.md +185 -0
- data/skills/refactoring/replace_conditional.md +211 -0
- data/skills/refactoring/value_objects.md +246 -0
- data/skills/rspec/build_stubbed.md +199 -0
- data/skills/rspec/factory_design.md +206 -0
- data/skills/rspec/let_vs_let_bang.md +161 -0
- data/skills/rspec/mocking_stubbing.md +209 -0
- data/skills/rspec/request_specs.md +212 -0
- data/skills/rspec/service_specs.md +262 -0
- data/skills/rspec/shared_examples.md +244 -0
- data/skills/rspec/system_specs.md +286 -0
- data/skills/rspec/test_performance.md +215 -0
- data/skills/ruby/blocks_procs_lambdas.md +204 -0
- data/skills/ruby/classes.md +155 -0
- data/skills/ruby/concurrency.md +194 -0
- data/skills/ruby/data_struct_openstruct.md +158 -0
- data/skills/ruby/debugging_profiling.md +204 -0
- data/skills/ruby/enumerable_patterns.md +168 -0
- data/skills/ruby/exception_handling.md +199 -0
- data/skills/ruby/file_io.md +217 -0
- data/skills/ruby/hashes.md +195 -0
- data/skills/ruby/metaprogramming.md +170 -0
- data/skills/ruby/modules.md +210 -0
- data/skills/ruby/pattern_matching.md +177 -0
- data/skills/ruby/regular_expressions.md +166 -0
- data/skills/ruby/result_objects.md +200 -0
- data/skills/ruby/strings.md +177 -0
- data/skills/ruby_project/bundler_dependencies.md +181 -0
- data/skills/ruby_project/cli_tools.md +224 -0
- data/skills/ruby_project/rake_tasks.md +146 -0
- data/skills/ruby_project/structure.md +261 -0
- data/skills/sinatra/application_structure.md +241 -0
- data/skills/sinatra/middleware_and_deployment.md +221 -0
- data/skills/sinatra/testing.md +233 -0
- data/skills/solid/dependency_inversion.md +195 -0
- data/skills/solid/interface_segregation.md +237 -0
- data/skills/solid/liskov_substitution.md +263 -0
- data/skills/solid/open_closed.md +212 -0
- data/skills/solid/single_responsibility.md +183 -0
- metadata +397 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# Rails: Security
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Security is not a feature — it's a property of every feature. Rails provides strong defaults, but you must use them correctly. This document covers the critical security practices for every Rails application.
|
|
6
|
+
|
|
7
|
+
### Strong Parameters (Mass Assignment Protection)
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# GOOD: Explicitly permit only expected params
|
|
11
|
+
class OrdersController < ApplicationController
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def order_params
|
|
15
|
+
params.require(:order).permit(:shipping_address, :notes,
|
|
16
|
+
line_items_attributes: [:product_id, :quantity])
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# BAD: Permitting everything
|
|
21
|
+
def order_params
|
|
22
|
+
params.require(:order).permit! # NEVER do this
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# BAD: Permitting role or admin fields
|
|
26
|
+
def user_params
|
|
27
|
+
params.require(:user).permit(:name, :email, :role, :admin) # User can make themselves admin!
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# GOOD: Separate param sets for different contexts
|
|
31
|
+
def user_params
|
|
32
|
+
params.require(:user).permit(:name, :email)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def admin_user_params
|
|
36
|
+
params.require(:user).permit(:name, :email, :role, :admin) # Only in admin controllers
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### SQL Injection Prevention
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
# GOOD: Parameterized queries (Rails does this by default)
|
|
44
|
+
User.where(email: params[:email])
|
|
45
|
+
User.where("email = ?", params[:email])
|
|
46
|
+
User.where("email = :email", email: params[:email])
|
|
47
|
+
Order.where(status: params[:status], user_id: current_user.id)
|
|
48
|
+
|
|
49
|
+
# BAD: String interpolation in SQL
|
|
50
|
+
User.where("email = '#{params[:email]}'") # SQL injection!
|
|
51
|
+
Order.where("status = #{params[:status]}") # SQL injection!
|
|
52
|
+
User.order("#{params[:sort_column]} #{params[:sort_direction]}") # SQL injection!
|
|
53
|
+
|
|
54
|
+
# GOOD: Safe column sorting
|
|
55
|
+
ALLOWED_SORT_COLUMNS = %w[created_at total status].freeze
|
|
56
|
+
ALLOWED_DIRECTIONS = %w[asc desc].freeze
|
|
57
|
+
|
|
58
|
+
def safe_order(scope)
|
|
59
|
+
column = ALLOWED_SORT_COLUMNS.include?(params[:sort]) ? params[:sort] : "created_at"
|
|
60
|
+
direction = ALLOWED_DIRECTIONS.include?(params[:dir]) ? params[:dir] : "desc"
|
|
61
|
+
scope.order(column => direction)
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### XSS (Cross-Site Scripting) Prevention
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
# GOOD: Rails auto-escapes by default in ERB
|
|
69
|
+
<%= user.name %> <%# Automatically escaped — safe %>
|
|
70
|
+
|
|
71
|
+
# BAD: raw/html_safe bypasses escaping
|
|
72
|
+
<%= raw user.bio %> # If bio contains <script>, it executes!
|
|
73
|
+
<%= user.bio.html_safe %> # Same vulnerability
|
|
74
|
+
|
|
75
|
+
# GOOD: When you need HTML, sanitize it
|
|
76
|
+
<%= sanitize user.bio, tags: %w[p br strong em a], attributes: %w[href] %>
|
|
77
|
+
|
|
78
|
+
# GOOD: JSON in script tags (Rails 7+)
|
|
79
|
+
<script>
|
|
80
|
+
const data = <%= raw json_escape(data.to_json) %>;
|
|
81
|
+
</script>
|
|
82
|
+
|
|
83
|
+
# Or better — use data attributes
|
|
84
|
+
<div data-order="<%= order.to_json %>">
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### CSRF Protection
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
# Rails includes CSRF protection by default for HTML forms
|
|
91
|
+
class ApplicationController < ActionController::Base
|
|
92
|
+
protect_from_forgery with: :exception
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# API controllers skip CSRF (they use token auth instead)
|
|
96
|
+
class Api::BaseController < ActionController::API
|
|
97
|
+
# No CSRF — API uses Bearer token authentication
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Authentication Security
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
# GOOD: Secure API key storage — hash the key, store the hash
|
|
105
|
+
class ApiKey < ApplicationRecord
|
|
106
|
+
before_create :generate_key_pair
|
|
107
|
+
|
|
108
|
+
# Store only the hash — never the raw key
|
|
109
|
+
def self.find_by_token(raw_token)
|
|
110
|
+
hashed = Digest::SHA256.hexdigest(raw_token)
|
|
111
|
+
find_by(key_hash: hashed, revoked_at: nil)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def generate_key_pair
|
|
117
|
+
raw_key = SecureRandom.urlsafe_base64(32)
|
|
118
|
+
self.key_hash = Digest::SHA256.hexdigest(raw_key)
|
|
119
|
+
self.key_prefix = raw_key[0..7] # For identification in UI
|
|
120
|
+
|
|
121
|
+
# Return the raw key ONCE — it's never stored
|
|
122
|
+
@raw_key = raw_key
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# GOOD: Constant-time comparison to prevent timing attacks
|
|
127
|
+
def authenticate_token(provided_token)
|
|
128
|
+
expected_hash = Digest::SHA256.hexdigest(provided_token)
|
|
129
|
+
api_key = ApiKey.find_by(key_prefix: provided_token[0..7])
|
|
130
|
+
return nil unless api_key
|
|
131
|
+
|
|
132
|
+
# Constant-time comparison
|
|
133
|
+
ActiveSupport::SecurityUtils.secure_compare(api_key.key_hash, expected_hash) ? api_key : nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# GOOD: Password requirements with Devise
|
|
137
|
+
class User < ApplicationRecord
|
|
138
|
+
devise :database_authenticatable, :registerable,
|
|
139
|
+
:recoverable, :rememberable, :validatable,
|
|
140
|
+
:lockable, :trackable
|
|
141
|
+
|
|
142
|
+
validates :password, length: { minimum: 8 }, if: :password_required?
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Authorization (Scoping Queries)
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
# GOOD: Always scope queries to the current user
|
|
150
|
+
class OrdersController < ApplicationController
|
|
151
|
+
def show
|
|
152
|
+
@order = current_user.orders.find(params[:id])
|
|
153
|
+
# If the order doesn't belong to current_user, raises RecordNotFound (404)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def index
|
|
157
|
+
@orders = current_user.orders.recent
|
|
158
|
+
# Never see other users' orders
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# BAD: Global lookup — any user can access any order
|
|
163
|
+
class OrdersController < ApplicationController
|
|
164
|
+
def show
|
|
165
|
+
@order = Order.find(params[:id]) # IDOR vulnerability!
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Secrets Management
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
# GOOD: Use Rails credentials
|
|
174
|
+
# Edit: rails credentials:edit
|
|
175
|
+
# Access:
|
|
176
|
+
Rails.application.credentials.anthropic_api_key
|
|
177
|
+
Rails.application.credentials.dig(:database, :password)
|
|
178
|
+
|
|
179
|
+
# GOOD: Environment variables for deployment
|
|
180
|
+
ENV.fetch("ANTHROPIC_API_KEY") # Fails loudly if missing
|
|
181
|
+
ENV["OPTIONAL_KEY"] # Returns nil if missing
|
|
182
|
+
|
|
183
|
+
# BAD: Secrets in code
|
|
184
|
+
ANTHROPIC_API_KEY = "sk-ant-abc123..." # NEVER commit secrets
|
|
185
|
+
|
|
186
|
+
# BAD: Secrets in database seeds
|
|
187
|
+
User.create!(api_key: "real-production-key")
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Content Security Policy
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
# config/initializers/content_security_policy.rb
|
|
194
|
+
Rails.application.configure do
|
|
195
|
+
config.content_security_policy do |policy|
|
|
196
|
+
policy.default_src :self
|
|
197
|
+
policy.font_src :self, "https://fonts.googleapis.com"
|
|
198
|
+
policy.img_src :self, :data, "https://gravatar.com"
|
|
199
|
+
policy.script_src :self
|
|
200
|
+
policy.style_src :self, :unsafe_inline # Required for some frameworks
|
|
201
|
+
policy.connect_src :self
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
|
|
205
|
+
config.content_security_policy_nonce_directives = %w[script-src]
|
|
206
|
+
end
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Rate Limiting (Rails 8+)
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
class Api::V1::AiController < Api::V1::BaseController
|
|
213
|
+
rate_limit to: 20, within: 1.minute, by: -> { current_user.id }, with: -> {
|
|
214
|
+
render json: { error: "Rate limited. Try again in a moment." }, status: :too_many_requests
|
|
215
|
+
}
|
|
216
|
+
end
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Security Checklist
|
|
220
|
+
|
|
221
|
+
Every Rails app should verify:
|
|
222
|
+
|
|
223
|
+
- [ ] Strong parameters on every controller action
|
|
224
|
+
- [ ] No string interpolation in SQL queries
|
|
225
|
+
- [ ] No `raw` or `html_safe` on user input
|
|
226
|
+
- [ ] CSRF protection enabled for web controllers
|
|
227
|
+
- [ ] API authentication via tokens (not cookies)
|
|
228
|
+
- [ ] All queries scoped to `current_user` (no IDOR)
|
|
229
|
+
- [ ] Secrets in credentials or ENV, never in code
|
|
230
|
+
- [ ] `force_ssl` enabled in production
|
|
231
|
+
- [ ] Dependencies updated regularly (`bundle audit`)
|
|
232
|
+
- [ ] Rate limiting on expensive endpoints
|
|
233
|
+
- [ ] CSP headers configured
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# Rails: Serializers
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Serializers control the exact shape of your JSON API responses. They decouple the API surface from the database schema, prevent accidental exposure of internal fields, and provide a single place to manage field inclusion, formatting, and nested associations.
|
|
6
|
+
|
|
7
|
+
### Manual Serializer (Simplest, No Gems)
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# app/serializers/order_serializer.rb
|
|
11
|
+
class OrderSerializer
|
|
12
|
+
def initialize(order)
|
|
13
|
+
@order = order
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def as_json(*)
|
|
17
|
+
{
|
|
18
|
+
id: @order.id,
|
|
19
|
+
reference: @order.reference,
|
|
20
|
+
status: @order.status,
|
|
21
|
+
total_cents: @order.total,
|
|
22
|
+
total_formatted: format_currency(@order.total),
|
|
23
|
+
shipping_address: @order.shipping_address,
|
|
24
|
+
line_items: serialize_line_items,
|
|
25
|
+
created_at: @order.created_at.iso8601,
|
|
26
|
+
updated_at: @order.updated_at.iso8601
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def serialize_line_items
|
|
33
|
+
@order.line_items.map do |item|
|
|
34
|
+
{
|
|
35
|
+
id: item.id,
|
|
36
|
+
product_name: item.product.name,
|
|
37
|
+
quantity: item.quantity,
|
|
38
|
+
unit_price_cents: item.unit_price,
|
|
39
|
+
total_cents: item.quantity * item.unit_price
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def format_currency(cents)
|
|
45
|
+
"$#{format('%.2f', cents / 100.0)}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Controller usage
|
|
50
|
+
class Api::V1::OrdersController < Api::V1::BaseController
|
|
51
|
+
def show
|
|
52
|
+
order = current_user.orders.includes(line_items: :product).find(params[:id])
|
|
53
|
+
render json: { order: OrderSerializer.new(order).as_json }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def index
|
|
57
|
+
orders = current_user.orders.includes(line_items: :product).recent
|
|
58
|
+
render json: {
|
|
59
|
+
orders: orders.map { |o| OrderSerializer.new(o).as_json },
|
|
60
|
+
meta: pagination_meta(orders)
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Collection Serializer Helper
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
# app/serializers/base_serializer.rb
|
|
70
|
+
class BaseSerializer
|
|
71
|
+
def initialize(object)
|
|
72
|
+
@object = object
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def as_json(*)
|
|
76
|
+
raise NotImplementedError
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Class method for serializing collections
|
|
80
|
+
def self.collection(objects)
|
|
81
|
+
objects.map { |obj| new(obj).as_json }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
class OrderSerializer < BaseSerializer
|
|
86
|
+
def as_json(*)
|
|
87
|
+
{
|
|
88
|
+
id: @object.id,
|
|
89
|
+
reference: @object.reference,
|
|
90
|
+
status: @object.status,
|
|
91
|
+
total_cents: @object.total,
|
|
92
|
+
created_at: @object.created_at.iso8601
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Usage
|
|
98
|
+
render json: { orders: OrderSerializer.collection(orders) }
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Conditional Fields and Includes
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
class OrderSerializer
|
|
105
|
+
def initialize(order, includes: [])
|
|
106
|
+
@order = order
|
|
107
|
+
@includes = includes.map(&:to_sym)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def as_json(*)
|
|
111
|
+
data = {
|
|
112
|
+
id: @order.id,
|
|
113
|
+
reference: @order.reference,
|
|
114
|
+
status: @order.status,
|
|
115
|
+
total_cents: @order.total,
|
|
116
|
+
created_at: @order.created_at.iso8601
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
data[:line_items] = serialize_line_items if include?(:line_items)
|
|
120
|
+
data[:user] = serialize_user if include?(:user)
|
|
121
|
+
data[:shipment] = serialize_shipment if include?(:shipment)
|
|
122
|
+
|
|
123
|
+
data
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def include?(association)
|
|
129
|
+
@includes.include?(association)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def serialize_line_items
|
|
133
|
+
@order.line_items.map { |li| LineItemSerializer.new(li).as_json }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def serialize_user
|
|
137
|
+
UserSerializer.new(@order.user).as_json
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def serialize_shipment
|
|
141
|
+
return nil unless @order.shipment
|
|
142
|
+
ShipmentSerializer.new(@order.shipment).as_json
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Controller — caller decides what to include
|
|
147
|
+
def show
|
|
148
|
+
order = current_user.orders.find(params[:id])
|
|
149
|
+
includes = (params[:include] || "").split(",").map(&:strip)
|
|
150
|
+
|
|
151
|
+
# Preload only what's requested
|
|
152
|
+
order = preload_includes(order, includes)
|
|
153
|
+
|
|
154
|
+
render json: { order: OrderSerializer.new(order, includes: includes).as_json }
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Jbuilder (Rails Built-In)
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
# app/views/api/v1/orders/show.json.jbuilder
|
|
162
|
+
json.order do
|
|
163
|
+
json.id @order.id
|
|
164
|
+
json.reference @order.reference
|
|
165
|
+
json.status @order.status
|
|
166
|
+
json.total_cents @order.total
|
|
167
|
+
json.total_formatted number_to_currency(@order.total / 100.0)
|
|
168
|
+
json.created_at @order.created_at.iso8601
|
|
169
|
+
|
|
170
|
+
json.line_items @order.line_items do |item|
|
|
171
|
+
json.id item.id
|
|
172
|
+
json.product_name item.product.name
|
|
173
|
+
json.quantity item.quantity
|
|
174
|
+
json.unit_price_cents item.unit_price
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# app/views/api/v1/orders/index.json.jbuilder
|
|
179
|
+
json.orders @orders do |order|
|
|
180
|
+
json.partial! "api/v1/orders/order", order: order
|
|
181
|
+
end
|
|
182
|
+
json.meta do
|
|
183
|
+
json.total_count @orders.total_count
|
|
184
|
+
json.current_page @orders.current_page
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Why This Is Good
|
|
189
|
+
|
|
190
|
+
- **API surface is explicit.** The serializer lists every field the API returns. Adding or removing a field is a one-line change in one file.
|
|
191
|
+
- **No accidental exposure.** `render json: @order` would expose `password_digest`, `internal_notes`, `api_cost_usd`, and every other column. Serializers whitelist only the fields clients should see.
|
|
192
|
+
- **Formatting is centralized.** Dates are always ISO8601, money is always in cents with a formatted version, statuses are always lowercase. Clients get consistent data shapes.
|
|
193
|
+
- **Nested associations are controlled.** You decide how deep the nesting goes. Clients can request includes, but you control what's available.
|
|
194
|
+
- **Testable.** `OrderSerializer.new(order).as_json` returns a hash you can assert on without HTTP.
|
|
195
|
+
|
|
196
|
+
## Anti-Pattern
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
# BAD: Rendering the model directly
|
|
200
|
+
render json: @order
|
|
201
|
+
# Exposes EVERYTHING: password_digest, internal_notes, admin fields, timestamps you don't want
|
|
202
|
+
|
|
203
|
+
# BAD: Inline hash construction in the controller
|
|
204
|
+
render json: {
|
|
205
|
+
id: @order.id,
|
|
206
|
+
ref: @order.reference, # Inconsistent naming
|
|
207
|
+
total: "$#{@order.total / 100.0}", # Formatting in controller
|
|
208
|
+
items: @order.line_items.map { |li| { name: li.product.name, qty: li.quantity } }
|
|
209
|
+
}
|
|
210
|
+
# Repeated in every controller, inconsistent across endpoints
|
|
211
|
+
|
|
212
|
+
# BAD: as_json override on the model
|
|
213
|
+
class Order < ApplicationRecord
|
|
214
|
+
def as_json(options = {})
|
|
215
|
+
super(only: [:id, :reference, :status, :total], include: { line_items: { only: [:id, :quantity] } })
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
# Now EVERY json render uses this shape — can't have different shapes for different endpoints
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## When To Apply
|
|
222
|
+
|
|
223
|
+
- **Every JSON API endpoint.** No exceptions. Even a simple `{ id: 1, name: "test" }` should go through a serializer once you have more than 2 API endpoints.
|
|
224
|
+
- **When different endpoints need different shapes.** A list endpoint shows `id, reference, status, total`. A detail endpoint adds `line_items, user, shipment`. Serializers with `includes:` handle this cleanly.
|
|
225
|
+
- **When you need versioning.** `Api::V1::OrderSerializer` and `Api::V2::OrderSerializer` can coexist. Can't do that with `as_json` on the model.
|
|
226
|
+
|
|
227
|
+
## When NOT To Apply
|
|
228
|
+
|
|
229
|
+
- **HTML-only apps.** Views render HTML directly from models/presenters. Serializers are for JSON APIs.
|
|
230
|
+
- **Single internal endpoint.** A health check returning `{ status: "ok" }` doesn't need a serializer class.
|
|
231
|
+
- **GraphQL.** GraphQL types serve the same purpose as serializers. Don't layer serializers on top of GraphQL.
|
|
232
|
+
|
|
233
|
+
## Gem Alternatives
|
|
234
|
+
|
|
235
|
+
| Approach | Pros | Cons |
|
|
236
|
+
|---|---|---|
|
|
237
|
+
| Manual class | Zero dependencies, full control, simplest | More boilerplate for large APIs |
|
|
238
|
+
| Jbuilder | Ships with Rails, template-based | Slower (renders views), harder to test |
|
|
239
|
+
| `jsonapi-serializer` | JSON:API compliant, relationships, sparse fieldsets | Heavy for simple APIs |
|
|
240
|
+
| `alba` | Fast, flexible, modern | Another dependency |
|
|
241
|
+
| `blueprinter` | Declarative DSL, views, associations | Another dependency |
|
|
242
|
+
|
|
243
|
+
**Recommendation:** Start with manual serializers. They're fast, testable, and dependency-free. Switch to a gem only when you have 20+ serializers and need features like sparse fieldsets or JSON:API compliance.
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# Rails: Service Object Extraction
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Extract business logic from controllers into service objects when the action does more than receive params and persist a single record.
|
|
6
|
+
|
|
7
|
+
Service objects live in `app/services/`, namespaced by resource. They have a single public class method `.call` that returns a result object.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# app/services/orders/create_service.rb
|
|
11
|
+
module Orders
|
|
12
|
+
class CreateService
|
|
13
|
+
def self.call(params, user)
|
|
14
|
+
new(params, user).call
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(params, user)
|
|
18
|
+
@params = params
|
|
19
|
+
@user = user
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
order = @user.orders.build(@params)
|
|
24
|
+
|
|
25
|
+
unless inventory_available?(order)
|
|
26
|
+
order.errors.add(:base, "Insufficient inventory")
|
|
27
|
+
return Result.new(success: false, order: order)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if order.save
|
|
31
|
+
send_confirmation(order)
|
|
32
|
+
notify_warehouse(order)
|
|
33
|
+
Result.new(success: true, order: order)
|
|
34
|
+
else
|
|
35
|
+
Result.new(success: false, order: order)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def inventory_available?(order)
|
|
42
|
+
order.line_items.all? { |item| item.product.stock >= item.quantity }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def send_confirmation(order)
|
|
46
|
+
OrderMailer.confirmation(order).deliver_later
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def notify_warehouse(order)
|
|
50
|
+
WarehouseNotificationJob.perform_later(order.id)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
Result = Struct.new(:success, :order, keyword_init: true) do
|
|
54
|
+
alias_method :success?, :success
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The controller becomes a thin delegation layer:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
# app/controllers/orders_controller.rb
|
|
64
|
+
class OrdersController < ApplicationController
|
|
65
|
+
def create
|
|
66
|
+
result = Orders::CreateService.call(order_params, current_user)
|
|
67
|
+
|
|
68
|
+
if result.success?
|
|
69
|
+
redirect_to result.order, notice: "Order placed successfully."
|
|
70
|
+
else
|
|
71
|
+
@order = result.order
|
|
72
|
+
render :new, status: :unprocessable_entity
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def order_params
|
|
79
|
+
params.require(:order).permit(:shipping_address, line_items_attributes: [:product_id, :quantity])
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Why This Is Good
|
|
85
|
+
|
|
86
|
+
- **Testable in isolation.** The service object can be tested without routing, request/response cycles, or controller setup. Pass in params and a user, assert the result.
|
|
87
|
+
- **Single responsibility.** The controller handles HTTP concerns (params, redirects, status codes). The service handles business logic (validation, persistence, side effects).
|
|
88
|
+
- **Reusable.** When the same order creation logic is needed from an API endpoint, a Sidekiq job, or a rake task, call the same service. No duplication.
|
|
89
|
+
- **Readable.** A 5-line controller action tells you instantly what happens. The service object's private methods read like a checklist of the business process.
|
|
90
|
+
- **Debuggable.** When order creation breaks, you look at one file — the service. Not a 40-line controller action mixed with HTTP logic.
|
|
91
|
+
|
|
92
|
+
## Anti-Pattern
|
|
93
|
+
|
|
94
|
+
A controller action that handles business logic, persistence, mailer calls, and external notifications directly:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
# app/controllers/orders_controller.rb
|
|
98
|
+
class OrdersController < ApplicationController
|
|
99
|
+
def create
|
|
100
|
+
@order = current_user.orders.build(order_params)
|
|
101
|
+
|
|
102
|
+
@order.line_items.each do |item|
|
|
103
|
+
if item.product.stock < item.quantity
|
|
104
|
+
@order.errors.add(:base, "#{item.product.name} is out of stock")
|
|
105
|
+
render :new, status: :unprocessable_entity
|
|
106
|
+
return
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
if @order.save
|
|
111
|
+
@order.line_items.each do |item|
|
|
112
|
+
item.product.update!(stock: item.product.stock - item.quantity)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
OrderMailer.confirmation(@order).deliver_later
|
|
116
|
+
|
|
117
|
+
payload = { order_id: @order.id, items: @order.line_items.map(&:id) }
|
|
118
|
+
WarehouseNotificationJob.perform_later(payload.to_json)
|
|
119
|
+
|
|
120
|
+
if @order.total > 1000
|
|
121
|
+
HighValueOrderNotificationJob.perform_later(@order.id)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
redirect_to @order, notice: "Order placed successfully."
|
|
125
|
+
else
|
|
126
|
+
render :new, status: :unprocessable_entity
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Why This Is Bad
|
|
133
|
+
|
|
134
|
+
- **Untestable without full stack.** Testing this requires building a request, setting up authentication, creating products with stock, and asserting redirects — all to test business logic that has nothing to do with HTTP.
|
|
135
|
+
- **Impossible to reuse.** When you need to create orders from an API controller, you copy-paste this logic. When the logic changes, you update it in two places (or forget one).
|
|
136
|
+
- **Hard to read.** A developer looking at this action has to mentally separate "what's HTTP" from "what's business logic" from "what's side effects." At 30+ lines, that takes real effort.
|
|
137
|
+
- **Fragile.** Stock decrementation, mailer calls, and warehouse notifications are scattered in the controller. Missing one in a new code path causes inventory errors or silent failures.
|
|
138
|
+
- **Violates SRP.** The controller is handling params, validation, persistence, stock management, email, background jobs, and conditional notifications. That's 7 responsibilities in one method.
|
|
139
|
+
|
|
140
|
+
## When To Apply
|
|
141
|
+
|
|
142
|
+
Extract to a service object when ANY of these are true:
|
|
143
|
+
|
|
144
|
+
- The action exceeds **8 lines** of logic (excluding param handling and response rendering)
|
|
145
|
+
- The action touches **2 or more models** (e.g., creates an order AND updates product stock)
|
|
146
|
+
- The action has **side effects** beyond persistence — sending emails, enqueuing jobs, calling external APIs, publishing events
|
|
147
|
+
- The **same business logic** is needed in more than one place (API controller, background job, rake task, console)
|
|
148
|
+
- The action contains **conditional business logic** (if order > $1000, do X)
|
|
149
|
+
|
|
150
|
+
## When NOT To Apply
|
|
151
|
+
|
|
152
|
+
Do NOT extract to a service object when:
|
|
153
|
+
|
|
154
|
+
- The action is **simple CRUD** — receives params, saves one record, responds. A 4-line create action does not need a service object. The overhead of an extra file and class adds complexity without benefit.
|
|
155
|
+
- The action only **reads data** — index and show actions that query and render rarely need services. Use scopes on the model or query objects instead.
|
|
156
|
+
- You're extracting **just to extract**. If the service object would contain 3 lines that mirror what the controller already does, it's not adding value.
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
# This does NOT need a service object. Leave it in the controller.
|
|
160
|
+
def create
|
|
161
|
+
@comment = @post.comments.build(comment_params)
|
|
162
|
+
@comment.user = current_user
|
|
163
|
+
|
|
164
|
+
if @comment.save
|
|
165
|
+
redirect_to @post, notice: "Comment added."
|
|
166
|
+
else
|
|
167
|
+
render :show, status: :unprocessable_entity
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Edge Cases
|
|
173
|
+
|
|
174
|
+
**The action is 10 lines but only touches one model:**
|
|
175
|
+
Look at what those lines do. If it's complex validation logic, consider a form object instead. If it's complex querying, consider a query object. Service objects are best for multi-step processes with side effects.
|
|
176
|
+
|
|
177
|
+
**The service object would only be called from one place:**
|
|
178
|
+
That's fine. Single-use services are still valuable for testability and readability. The reuse benefit is a bonus, not a requirement.
|
|
179
|
+
|
|
180
|
+
**The team uses `interactor` or `dry-transaction` gems:**
|
|
181
|
+
Follow the team's established pattern. If they use interactors, write an interactor. Rubyn adapts to the project's conventions (detected via codebase memory), not the other way around.
|
|
182
|
+
|
|
183
|
+
**The existing codebase has no `app/services/` directory:**
|
|
184
|
+
Create it. This is a standard Rails convention even though Rails doesn't generate it by default. Place the service at `app/services/{resource}/{action}_service.rb`.
|