plutonium 0.33.1 → 0.34.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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/# Plutonium: The pre-alpha demo.md +4 -2
  3. data/.claude/skills/assets/SKILL.md +416 -0
  4. data/.claude/skills/connect-resource/SKILL.md +112 -0
  5. data/.claude/skills/controller/SKILL.md +302 -0
  6. data/.claude/skills/create-resource/SKILL.md +240 -0
  7. data/.claude/skills/definition/SKILL.md +218 -0
  8. data/.claude/skills/definition-actions/SKILL.md +386 -0
  9. data/.claude/skills/definition-fields/SKILL.md +474 -0
  10. data/.claude/skills/definition-query/SKILL.md +334 -0
  11. data/.claude/skills/forms/SKILL.md +439 -0
  12. data/.claude/skills/installation/SKILL.md +300 -0
  13. data/.claude/skills/interaction/SKILL.md +382 -0
  14. data/.claude/skills/model/SKILL.md +267 -0
  15. data/.claude/skills/model-features/SKILL.md +286 -0
  16. data/.claude/skills/nested-resources/SKILL.md +274 -0
  17. data/.claude/skills/package/SKILL.md +191 -0
  18. data/.claude/skills/policy/SKILL.md +352 -0
  19. data/.claude/skills/portal/SKILL.md +400 -0
  20. data/.claude/skills/resource/SKILL.md +281 -0
  21. data/.claude/skills/rodauth/SKILL.md +452 -0
  22. data/.claude/skills/views/SKILL.md +563 -0
  23. data/Appraisals +46 -4
  24. data/CHANGELOG.md +32 -1
  25. data/app/assets/plutonium.css +2 -2
  26. data/config/brakeman.ignore +239 -0
  27. data/config/initializers/action_policy.rb +1 -1
  28. data/docs/.vitepress/config.ts +132 -47
  29. data/docs/concepts/architecture.md +226 -0
  30. data/docs/concepts/auto-detection.md +254 -0
  31. data/docs/concepts/index.md +61 -0
  32. data/docs/concepts/packages-portals.md +304 -0
  33. data/docs/concepts/resources.md +224 -0
  34. data/docs/cookbook/blog.md +412 -0
  35. data/docs/cookbook/index.md +289 -0
  36. data/docs/cookbook/saas.md +481 -0
  37. data/docs/getting-started/index.md +56 -0
  38. data/docs/getting-started/installation.md +146 -0
  39. data/docs/getting-started/tutorial/01-setup.md +118 -0
  40. data/docs/getting-started/tutorial/02-first-resource.md +180 -0
  41. data/docs/getting-started/tutorial/03-authentication.md +246 -0
  42. data/docs/getting-started/tutorial/04-authorization.md +170 -0
  43. data/docs/getting-started/tutorial/05-custom-actions.md +202 -0
  44. data/docs/getting-started/tutorial/06-nested-resources.md +147 -0
  45. data/docs/getting-started/tutorial/07-customizing-ui.md +254 -0
  46. data/docs/getting-started/tutorial/index.md +64 -0
  47. data/docs/guides/adding-resources.md +420 -0
  48. data/docs/guides/authentication.md +551 -0
  49. data/docs/guides/authorization.md +468 -0
  50. data/docs/guides/creating-packages.md +380 -0
  51. data/docs/guides/custom-actions.md +523 -0
  52. data/docs/guides/index.md +45 -0
  53. data/docs/guides/multi-tenancy.md +302 -0
  54. data/docs/guides/nested-resources.md +411 -0
  55. data/docs/guides/search-filtering.md +266 -0
  56. data/docs/guides/theming.md +321 -0
  57. data/docs/index.md +67 -26
  58. data/docs/public/CLAUDE.md +64 -21
  59. data/docs/reference/assets/index.md +496 -0
  60. data/docs/reference/controller/index.md +363 -0
  61. data/docs/reference/definition/actions.md +400 -0
  62. data/docs/reference/definition/fields.md +350 -0
  63. data/docs/reference/definition/index.md +252 -0
  64. data/docs/reference/definition/query.md +342 -0
  65. data/docs/reference/generators/index.md +469 -0
  66. data/docs/reference/index.md +49 -0
  67. data/docs/reference/interaction/index.md +445 -0
  68. data/docs/reference/model/features.md +248 -0
  69. data/docs/reference/model/index.md +219 -0
  70. data/docs/reference/policy/index.md +385 -0
  71. data/docs/reference/portal/index.md +382 -0
  72. data/docs/reference/views/forms.md +396 -0
  73. data/docs/reference/views/index.md +479 -0
  74. data/gemfiles/rails_7.gemfile +9 -2
  75. data/gemfiles/rails_7.gemfile.lock +146 -111
  76. data/gemfiles/rails_8.0.gemfile +20 -0
  77. data/gemfiles/rails_8.0.gemfile.lock +417 -0
  78. data/gemfiles/rails_8.1.gemfile +20 -0
  79. data/gemfiles/rails_8.1.gemfile.lock +419 -0
  80. data/lib/generators/pu/gem/dotenv/templates/.env +2 -0
  81. data/lib/generators/pu/gem/dotenv/templates/config/initializers/001_ensure_required_env.rb +3 -1
  82. data/lib/generators/pu/lib/plutonium_generators/model_generator_base.rb +13 -16
  83. data/lib/generators/pu/pkg/portal/USAGE +65 -0
  84. data/lib/generators/pu/pkg/portal/portal_generator.rb +22 -9
  85. data/lib/generators/pu/res/conn/USAGE +71 -0
  86. data/lib/generators/pu/res/model/USAGE +106 -110
  87. data/lib/generators/pu/res/model/templates/model.rb.tt +6 -2
  88. data/lib/generators/pu/res/scaffold/USAGE +85 -0
  89. data/lib/generators/pu/rodauth/install_generator.rb +2 -6
  90. data/lib/generators/pu/rodauth/templates/config/initializers/url_options.rb +17 -0
  91. data/lib/generators/pu/skills/sync/USAGE +14 -0
  92. data/lib/generators/pu/skills/sync/sync_generator.rb +66 -0
  93. data/lib/plutonium/action_policy/sti_policy_lookup.rb +1 -1
  94. data/lib/plutonium/core/controller.rb +2 -2
  95. data/lib/plutonium/interaction/base.rb +1 -0
  96. data/lib/plutonium/package/engine.rb +2 -2
  97. data/lib/plutonium/query/adhoc_block.rb +6 -2
  98. data/lib/plutonium/query/model_scope.rb +1 -1
  99. data/lib/plutonium/railtie.rb +4 -0
  100. data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +1 -1
  101. data/lib/plutonium/resource/query_object.rb +38 -8
  102. data/lib/plutonium/ui/table/components/scopes_bar.rb +39 -34
  103. data/lib/plutonium/version.rb +1 -1
  104. data/lib/tasks/release.rake +19 -4
  105. data/package.json +1 -1
  106. metadata +76 -39
  107. data/brakeman.ignore +0 -28
  108. data/docs/api-examples.md +0 -49
  109. data/docs/guide/claude-code-guide.md +0 -74
  110. data/docs/guide/deep-dive/authorization.md +0 -189
  111. data/docs/guide/deep-dive/multitenancy.md +0 -256
  112. data/docs/guide/deep-dive/resources.md +0 -390
  113. data/docs/guide/getting-started/01-installation.md +0 -165
  114. data/docs/guide/index.md +0 -28
  115. data/docs/guide/introduction/01-what-is-plutonium.md +0 -211
  116. data/docs/guide/introduction/02-core-concepts.md +0 -440
  117. data/docs/guide/tutorial/01-project-setup.md +0 -75
  118. data/docs/guide/tutorial/02-creating-a-feature-package.md +0 -45
  119. data/docs/guide/tutorial/03-defining-resources.md +0 -90
  120. data/docs/guide/tutorial/04-creating-a-portal.md +0 -101
  121. data/docs/guide/tutorial/05-customizing-the-ui.md +0 -128
  122. data/docs/guide/tutorial/06-adding-custom-actions.md +0 -101
  123. data/docs/guide/tutorial/07-implementing-authorization.md +0 -90
  124. data/docs/markdown-examples.md +0 -85
  125. data/docs/modules/action.md +0 -244
  126. data/docs/modules/authentication.md +0 -236
  127. data/docs/modules/configuration.md +0 -599
  128. data/docs/modules/controller.md +0 -443
  129. data/docs/modules/core.md +0 -316
  130. data/docs/modules/definition.md +0 -1308
  131. data/docs/modules/display.md +0 -759
  132. data/docs/modules/form.md +0 -495
  133. data/docs/modules/generator.md +0 -400
  134. data/docs/modules/index.md +0 -167
  135. data/docs/modules/interaction.md +0 -642
  136. data/docs/modules/package.md +0 -151
  137. data/docs/modules/policy.md +0 -176
  138. data/docs/modules/portal.md +0 -710
  139. data/docs/modules/query.md +0 -297
  140. data/docs/modules/resource_record.md +0 -618
  141. data/docs/modules/routing.md +0 -690
  142. data/docs/modules/table.md +0 -301
  143. data/docs/modules/ui.md +0 -631
@@ -0,0 +1,302 @@
1
+ # Multi-tenancy
2
+
3
+ This guide covers isolating data by organization, account, or other entity.
4
+
5
+ ## Overview
6
+
7
+ Multi-tenancy means each tenant (organization, company, account) sees only their own data. Plutonium supports this through:
8
+
9
+ - **Entity Scoping** - Automatic query filtering via portal configuration
10
+ - **Path or Custom Strategies** - Flexible entity resolution
11
+ - **Policy Integration** - Authorization automatically respects tenancy
12
+
13
+ ## Setting Up Multi-tenancy
14
+
15
+ ### 1. Create the Entity Model
16
+
17
+ ```ruby
18
+ # app/models/organization.rb
19
+ class Organization < ApplicationRecord
20
+ include Plutonium::Resource::Record
21
+
22
+ has_many :users
23
+ has_many :posts
24
+ end
25
+ ```
26
+
27
+ ### 2. Add Entity Reference to Resources
28
+
29
+ Resources must have an association path to the entity:
30
+
31
+ ```ruby
32
+ # Direct association (preferred)
33
+ class Post < ResourceRecord
34
+ belongs_to :organization
35
+ belongs_to :user
36
+ end
37
+
38
+ # Through association
39
+ class Comment < ResourceRecord
40
+ belongs_to :post
41
+ has_one :organization, through: :post
42
+ end
43
+ ```
44
+
45
+ ### 3. Configure the Portal Engine
46
+
47
+ ```ruby
48
+ # packages/customer_portal/lib/engine.rb
49
+ module CustomerPortal
50
+ class Engine < Rails::Engine
51
+ include Plutonium::Portal::Engine
52
+
53
+ config.after_initialize do
54
+ # Path strategy - entity ID in URL
55
+ scope_to_entity Organization, strategy: :path
56
+ end
57
+ end
58
+ end
59
+ ```
60
+
61
+ Routes become: `/organizations/:organization_id/posts`
62
+
63
+ ## Scoping Strategies
64
+
65
+ ### Path Strategy (Default)
66
+
67
+ Entity ID is included in the URL path:
68
+
69
+ ```ruby
70
+ config.after_initialize do
71
+ scope_to_entity Organization, strategy: :path
72
+ end
73
+ ```
74
+
75
+ The user must have access to the organization (via `associated_with` scope).
76
+
77
+ ### Custom Strategy
78
+
79
+ Define a method that returns the current entity:
80
+
81
+ ```ruby
82
+ # packages/customer_portal/lib/engine.rb
83
+ config.after_initialize do
84
+ scope_to_entity Organization, strategy: :current_organization
85
+ end
86
+ ```
87
+
88
+ ```ruby
89
+ # packages/customer_portal/app/controllers/customer_portal/concerns/controller.rb
90
+ module CustomerPortal
91
+ module Concerns
92
+ module Controller
93
+ extend ActiveSupport::Concern
94
+ include Plutonium::Portal::Controller
95
+ include Plutonium::Auth::Rodauth(:user)
96
+
97
+ private
98
+
99
+ # Method name must match strategy
100
+ def current_organization
101
+ @current_organization ||= current_user.organization
102
+ end
103
+ end
104
+ end
105
+ end
106
+ ```
107
+
108
+ ## How Entity Scoping Works
109
+
110
+ ### Automatic Query Filtering
111
+
112
+ All resource queries are automatically scoped via `associated_with`:
113
+
114
+ ```ruby
115
+ # In a scoped portal
116
+ Post.all # Returns only current entity's posts
117
+ ```
118
+
119
+ ### Helper Methods
120
+
121
+ Inside controllers:
122
+
123
+ ```ruby
124
+ current_scoped_entity # The current Organization/Account/etc.
125
+ scoped_to_entity? # true if scoping is active
126
+ scoped_entity_class # Organization (the entity class)
127
+ ```
128
+
129
+ ### Model Requirements
130
+
131
+ Models must have an association path to the scoped entity. Plutonium automatically resolves:
132
+
133
+ 1. **Direct belongs_to** - `Post belongs_to :organization`
134
+ 2. **Through association** - `Comment has_one :organization, through: :post`
135
+ 3. **Custom scope** - For complex cases, define a named scope:
136
+
137
+ ```ruby
138
+ class AuditLog < ResourceRecord
139
+ # When automatic resolution fails, define this scope
140
+ scope :associated_with_organization, ->(org) {
141
+ joins(:user).where(users: { organization_id: org.id })
142
+ }
143
+ end
144
+ ```
145
+
146
+ ## User Membership Patterns
147
+
148
+ ### Single Organization per User
149
+
150
+ ```ruby
151
+ class User < ApplicationRecord
152
+ belongs_to :organization
153
+ end
154
+
155
+ # Custom strategy
156
+ def current_organization
157
+ current_user.organization
158
+ end
159
+ ```
160
+
161
+ ### Multiple Organizations per User
162
+
163
+ ```ruby
164
+ class User < ApplicationRecord
165
+ has_many :memberships
166
+ has_many :organizations, through: :memberships
167
+ end
168
+
169
+ # Custom strategy with session storage
170
+ def current_organization
171
+ @current_organization ||=
172
+ current_user.organizations.find_by(id: session[:organization_id]) ||
173
+ current_user.organizations.first
174
+ end
175
+ ```
176
+
177
+ ### Organization Switcher
178
+
179
+ ```ruby
180
+ class OrganizationSwitchController < ApplicationController
181
+ def update
182
+ org = current_user.organizations.find(params[:id])
183
+ session[:organization_id] = org.id
184
+ redirect_back(fallback_location: root_path)
185
+ end
186
+ end
187
+ ```
188
+
189
+ ## Policy Integration
190
+
191
+ Entity scoping is automatic. The base `Plutonium::Resource::Policy` includes:
192
+
193
+ ```ruby
194
+ relation_scope do |relation|
195
+ next relation unless entity_scope
196
+
197
+ relation.associated_with(entity_scope)
198
+ end
199
+ ```
200
+
201
+ The `entity_scope` context is automatically set to `current_scoped_entity`.
202
+
203
+ ### Additional Filtering
204
+
205
+ Add role-based filtering on top of entity scoping:
206
+
207
+ ```ruby
208
+ class PostPolicy < ResourcePolicy
209
+ relation_scope do |relation|
210
+ relation = super(relation) # Apply entity scoping first
211
+
212
+ if user.role == "viewer"
213
+ relation.where(published: true)
214
+ else
215
+ relation
216
+ end
217
+ end
218
+ end
219
+ ```
220
+
221
+ ## Subdomain-Based Tenancy
222
+
223
+ Route to different organizations by subdomain:
224
+
225
+ ### Routes
226
+
227
+ ```ruby
228
+ # config/routes.rb
229
+ constraints subdomain: /[a-z]+/ do
230
+ mount CustomerPortal::Engine, at: "/"
231
+ end
232
+ ```
233
+
234
+ ### Custom Strategy
235
+
236
+ ```ruby
237
+ # Engine configuration
238
+ scope_to_entity Organization, strategy: :current_organization
239
+
240
+ # Controller concern
241
+ def current_organization
242
+ @current_organization ||=
243
+ Organization.find_by!(subdomain: request.subdomain)
244
+ end
245
+ ```
246
+
247
+ ## Cross-Tenant Operations
248
+
249
+ Sometimes admins need to see all data:
250
+
251
+ ### Super Admin Portal (No Scoping)
252
+
253
+ ```ruby
254
+ # packages/super_admin_portal/lib/engine.rb
255
+ module SuperAdminPortal
256
+ class Engine < Rails::Engine
257
+ include Plutonium::Portal::Engine
258
+ # No scope_to_entity = sees everything
259
+ end
260
+ end
261
+ ```
262
+
263
+ ### Conditional Scoping
264
+
265
+ ```ruby
266
+ # Custom strategy that returns nil for super admins
267
+ def current_organization
268
+ return nil if current_user.super_admin?
269
+ current_user.organization
270
+ end
271
+ ```
272
+
273
+ When `current_scoped_entity` returns `nil`, scoping is bypassed.
274
+
275
+ ## Data Isolation Patterns
276
+
277
+ ### Shared Database, Scoped Queries (Recommended)
278
+
279
+ All tenants share tables, queries filter by entity association:
280
+
281
+ ```ruby
282
+ scope_to_entity Organization, strategy: :path
283
+ ```
284
+
285
+ Pros:
286
+ - Simple setup
287
+ - Easy migrations
288
+ - Efficient for many small tenants
289
+
290
+ Cons:
291
+ - Risk of data leakage if scoping fails
292
+ - Complex queries for cross-tenant reports
293
+
294
+ ### Schema-Based Isolation
295
+
296
+ Each tenant has separate database schema. This requires additional setup beyond Plutonium's built-in scoping.
297
+
298
+ ## Related
299
+
300
+ - [Authorization](./authorization)
301
+ - [Creating Packages](./creating-packages)
302
+ - [Authentication](./authentication)
@@ -0,0 +1,411 @@
1
+ # Nested Resources
2
+
3
+ This guide covers setting up parent/child resource relationships.
4
+
5
+ ## Overview
6
+
7
+ Nested resources create URLs like `/posts/1/comments` where comments belong to a specific post. Plutonium automatically handles:
8
+
9
+ - Scoping queries to the parent
10
+ - Assigning parent to new records
11
+ - Hiding parent field in forms
12
+ - URL generation with parent context
13
+ - Breadcrumb navigation
14
+
15
+ ## Setting Up Nested Resources
16
+
17
+ ### 1. Define the Association
18
+
19
+ ```ruby
20
+ # Parent model
21
+ class Post < ResourceRecord
22
+ has_many :comments, dependent: :destroy
23
+ end
24
+
25
+ # Child model
26
+ class Comment < ResourceRecord
27
+ belongs_to :post
28
+ end
29
+ ```
30
+
31
+ ### 2. Register Both Resources
32
+
33
+ ```ruby
34
+ # packages/admin_portal/config/routes.rb
35
+ AdminPortal::Engine.routes.draw do
36
+ register_resource ::Post
37
+ register_resource ::Comment
38
+ end
39
+ ```
40
+
41
+ Plutonium automatically creates nested routes based on the `belongs_to` association:
42
+ - `GET /posts/:post_id/comments`
43
+ - `GET /posts/:post_id/comments/new`
44
+ - `GET /posts/:post_id/comments/:id`
45
+ - etc.
46
+
47
+ ### 3. Enable Association Panel
48
+
49
+ Show comments on the post detail page:
50
+
51
+ ```ruby
52
+ class PostPolicy < ResourcePolicy
53
+ def permitted_associations
54
+ %i[comments]
55
+ end
56
+ end
57
+ ```
58
+
59
+ ## How It Works
60
+
61
+ ### Automatic Scoping
62
+
63
+ When accessing `/posts/1/comments`, queries are scoped to the parent:
64
+
65
+ ```ruby
66
+ # Internally uses: Comment.associated_with(post)
67
+ # Which resolves to: Comment.where(post: post)
68
+ ```
69
+
70
+ ### Automatic Parent Assignment
71
+
72
+ When creating a comment under a post, the parent is injected into params:
73
+
74
+ ```ruby
75
+ # POST /posts/1/comments
76
+ # resource_params automatically includes { post: <Post:1>, post_id: 1 }
77
+ ```
78
+
79
+ ### Automatic Field Hiding
80
+
81
+ The parent field (`post`) is automatically hidden in forms since it's determined by the URL.
82
+
83
+ ## Controller Helpers
84
+
85
+ ### current_parent
86
+
87
+ Returns the parent record resolved from the URL:
88
+
89
+ ```ruby
90
+ # URL: /posts/123/comments
91
+ current_parent # => Post.find(123)
92
+ ```
93
+
94
+ ### parent_route_param
95
+
96
+ The URL parameter containing the parent ID:
97
+
98
+ ```ruby
99
+ parent_route_param # => :post_id
100
+ ```
101
+
102
+ ### parent_input_param
103
+
104
+ The association name on the child model:
105
+
106
+ ```ruby
107
+ parent_input_param # => :post
108
+ ```
109
+
110
+ ## URL Generation
111
+
112
+ Use `resource_url_for` with the `parent:` option:
113
+
114
+ ```ruby
115
+ # Child collection
116
+ resource_url_for(Comment, parent: @post)
117
+ # => /posts/123/comments
118
+
119
+ # Child record
120
+ resource_url_for(@comment, parent: @post)
121
+ # => /posts/123/comments/456
122
+
123
+ # New child form
124
+ resource_url_for(Comment, action: :new, parent: @post)
125
+ # => /posts/123/comments/new
126
+
127
+ # Edit child
128
+ resource_url_for(@comment, action: :edit, parent: @post)
129
+ # => /posts/123/comments/456/edit
130
+ ```
131
+
132
+ Within a nested context, `parent:` defaults to `current_parent`:
133
+
134
+ ```ruby
135
+ # In CommentsController under /posts/:post_id/comments
136
+ resource_url_for(@comment) # parent: current_parent is automatic
137
+ ```
138
+
139
+ ## Presentation Hooks
140
+
141
+ Control whether the parent field appears:
142
+
143
+ ```ruby
144
+ class CommentsController < ResourceController
145
+ private
146
+
147
+ # Show parent in displays (default: false when nested)
148
+ def present_parent?
149
+ current_parent.nil? # Only show when accessed standalone
150
+ end
151
+
152
+ # Allow changing parent in forms (default: same as present_parent?)
153
+ def submit_parent?
154
+ false
155
+ end
156
+ end
157
+ ```
158
+
159
+ ## Policy Integration
160
+
161
+ ### Parent Authorization
162
+
163
+ The parent is authorized for `:read?` before being returned:
164
+
165
+ ```ruby
166
+ # Inside current_parent
167
+ authorize! parent, to: :read?
168
+ ```
169
+
170
+ ### Entity Scope Context
171
+
172
+ The parent is passed to child policies as `entity_scope`:
173
+
174
+ ```ruby
175
+ class CommentPolicy < ResourcePolicy
176
+ def create?
177
+ # entity_scope is the parent post
178
+ entity_scope.present? && user.can_comment_on?(entity_scope)
179
+ end
180
+
181
+ def update?
182
+ record.user_id == user.id
183
+ end
184
+
185
+ def destroy?
186
+ record.user_id == user.id || entity_scope&.user_id == user.id
187
+ end
188
+ end
189
+ ```
190
+
191
+ ### Additional Scoping
192
+
193
+ Add role-based filtering on top of parent scoping:
194
+
195
+ ```ruby
196
+ class CommentPolicy < ResourcePolicy
197
+ relation_scope do |relation|
198
+ relation = super(relation) # Applies associated_with(entity_scope)
199
+
200
+ if user.moderator?
201
+ relation
202
+ else
203
+ relation.where(approved: true).or(relation.where(user: user))
204
+ end
205
+ end
206
+ end
207
+ ```
208
+
209
+ ## Association Panels
210
+
211
+ Associations listed in `permitted_associations` appear on the parent's show page:
212
+
213
+ ```ruby
214
+ class PostPolicy < ResourcePolicy
215
+ def permitted_associations
216
+ %i[comments tags] # Shows panels for these
217
+ end
218
+ end
219
+ ```
220
+
221
+ Each panel displays:
222
+ - List of child records
223
+ - "Add" button linking to nested new action
224
+ - Edit/Delete actions per record
225
+
226
+ ## Nested Forms
227
+
228
+ Edit child records inline within the parent form:
229
+
230
+ ### 1. Enable Nested Attributes
231
+
232
+ ```ruby
233
+ class Post < ResourceRecord
234
+ has_many :comments
235
+
236
+ accepts_nested_attributes_for :comments,
237
+ allow_destroy: true,
238
+ reject_if: :all_blank
239
+ end
240
+ ```
241
+
242
+ ### 2. Configure as Nested Input
243
+
244
+ ```ruby
245
+ class PostDefinition < ResourceDefinition
246
+ input :comments, as: :nested
247
+ end
248
+ ```
249
+
250
+ ### 3. Permit in Policy
251
+
252
+ ```ruby
253
+ class PostPolicy < ResourcePolicy
254
+ def permitted_attributes_for_create
255
+ [:title, :content, comments_attributes: [:id, :body, :_destroy]]
256
+ end
257
+ end
258
+ ```
259
+
260
+ ## Nesting Depth
261
+
262
+ Plutonium supports **one level of nesting**:
263
+
264
+ - `/posts/:post_id/comments` (parent → child)
265
+ - `/comments/:comment_id/replies` (parent → child)
266
+
267
+ Not supported:
268
+ - `/posts/:post_id/comments/:comment_id/replies` (grandparent → parent → child)
269
+
270
+ ### Working with Deep Hierarchies
271
+
272
+ Use through associations for data access:
273
+
274
+ ```ruby
275
+ class Post < ResourceRecord
276
+ has_many :comments
277
+ has_many :replies, through: :comments
278
+ end
279
+ ```
280
+
281
+ ## Custom Routes on Nested Resources
282
+
283
+ Add member/collection routes:
284
+
285
+ ```ruby
286
+ register_resource ::Comment do
287
+ member do
288
+ post :approve
289
+ post :flag
290
+ end
291
+ collection do
292
+ get :pending
293
+ end
294
+ end
295
+ ```
296
+
297
+ Generates nested routes:
298
+ - `POST /posts/:post_id/comments/:id/approve`
299
+ - `POST /posts/:post_id/comments/:id/flag`
300
+ - `GET /posts/:post_id/comments/pending`
301
+
302
+ ## Breadcrumbs
303
+
304
+ Nested resources automatically include parent in breadcrumbs:
305
+
306
+ ```
307
+ Dashboard > Posts > My First Post > Comments > Comment #1
308
+ ```
309
+
310
+ ## Scoped Uniqueness
311
+
312
+ Validate uniqueness within parent:
313
+
314
+ ```ruby
315
+ class Comment < ResourceRecord
316
+ belongs_to :post
317
+ validates :position, uniqueness: { scope: :post_id }
318
+ end
319
+ ```
320
+
321
+ ## Example: Blog with Comments
322
+
323
+ ### Models
324
+
325
+ ```ruby
326
+ class Post < ResourceRecord
327
+ belongs_to :user
328
+ has_many :comments, dependent: :destroy
329
+
330
+ validates :title, :body, presence: true
331
+ end
332
+
333
+ class Comment < ResourceRecord
334
+ belongs_to :post
335
+ belongs_to :user
336
+
337
+ validates :body, presence: true
338
+ end
339
+ ```
340
+
341
+ ### Policies
342
+
343
+ ```ruby
344
+ class PostPolicy < ResourcePolicy
345
+ def create?
346
+ user.present?
347
+ end
348
+
349
+ def read?
350
+ true
351
+ end
352
+
353
+ def permitted_attributes_for_create
354
+ %i[title body]
355
+ end
356
+
357
+ def permitted_attributes_for_read
358
+ %i[title body user created_at]
359
+ end
360
+
361
+ def permitted_associations
362
+ %i[comments]
363
+ end
364
+ end
365
+
366
+ class CommentPolicy < ResourcePolicy
367
+ def create?
368
+ user.present? && entity_scope.present?
369
+ end
370
+
371
+ def read?
372
+ true
373
+ end
374
+
375
+ def update?
376
+ record.user_id == user.id
377
+ end
378
+
379
+ def destroy?
380
+ record.user_id == user.id || entity_scope&.user_id == user.id
381
+ end
382
+
383
+ def permitted_attributes_for_create
384
+ %i[body]
385
+ end
386
+
387
+ def permitted_attributes_for_read
388
+ %i[body user created_at]
389
+ end
390
+ end
391
+ ```
392
+
393
+ ### Controller (if customization needed)
394
+
395
+ ```ruby
396
+ class CommentsController < ResourceController
397
+ private
398
+
399
+ def build_resource
400
+ super.tap do |comment|
401
+ comment.user = current_user
402
+ end
403
+ end
404
+ end
405
+ ```
406
+
407
+ ## Related
408
+
409
+ - [Adding Resources](./adding-resources)
410
+ - [Authorization](./authorization)
411
+ - [Creating Packages](./creating-packages)