plutonium 0.50.0 → 0.51.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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +85 -102
  3. data/.claude/skills/plutonium-app/SKILL.md +572 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +163 -300
  5. data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
  6. data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
  7. data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
  8. data/.claude/skills/plutonium-testing/SKILL.md +6 -5
  9. data/.claude/skills/plutonium-ui/SKILL.md +900 -0
  10. data/CHANGELOG.md +27 -2
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1009 -1214
  14. data/app/assets/plutonium.js.map +3 -3
  15. data/app/assets/plutonium.min.js +52 -51
  16. data/app/assets/plutonium.min.js.map +3 -3
  17. data/docs/.vitepress/config.ts +37 -27
  18. data/docs/getting-started/index.md +22 -29
  19. data/docs/getting-started/installation.md +37 -80
  20. data/docs/getting-started/tutorial/index.md +4 -5
  21. data/docs/guides/adding-resources.md +66 -377
  22. data/docs/guides/authentication.md +94 -463
  23. data/docs/guides/authorization.md +124 -370
  24. data/docs/guides/creating-packages.md +94 -296
  25. data/docs/guides/custom-actions.md +121 -441
  26. data/docs/guides/index.md +22 -42
  27. data/docs/guides/multi-tenancy.md +116 -187
  28. data/docs/guides/nested-resources.md +103 -431
  29. data/docs/guides/search-filtering.md +123 -240
  30. data/docs/guides/testing.md +5 -4
  31. data/docs/guides/theming.md +157 -407
  32. data/docs/guides/troubleshooting.md +5 -3
  33. data/docs/guides/user-invites.md +106 -425
  34. data/docs/guides/user-profile.md +76 -243
  35. data/docs/index.md +1 -1
  36. data/docs/reference/app/generators.md +517 -0
  37. data/docs/reference/app/index.md +158 -0
  38. data/docs/reference/app/packages.md +146 -0
  39. data/docs/reference/app/portals.md +377 -0
  40. data/docs/reference/auth/accounts.md +230 -0
  41. data/docs/reference/auth/index.md +88 -0
  42. data/docs/reference/auth/profile.md +185 -0
  43. data/docs/reference/behavior/controllers.md +395 -0
  44. data/docs/reference/behavior/index.md +22 -0
  45. data/docs/reference/behavior/interactions.md +341 -0
  46. data/docs/reference/behavior/policies.md +417 -0
  47. data/docs/reference/index.md +56 -49
  48. data/docs/reference/resource/actions.md +423 -0
  49. data/docs/reference/resource/definition.md +508 -0
  50. data/docs/reference/resource/index.md +50 -0
  51. data/docs/reference/resource/model.md +348 -0
  52. data/docs/reference/resource/query.md +305 -0
  53. data/docs/reference/tenancy/entity-scoping.md +361 -0
  54. data/docs/reference/tenancy/index.md +36 -0
  55. data/docs/reference/tenancy/invites.md +393 -0
  56. data/docs/reference/tenancy/nested-resources.md +267 -0
  57. data/docs/reference/testing/index.md +287 -0
  58. data/docs/reference/ui/assets.md +400 -0
  59. data/docs/reference/ui/components.md +165 -0
  60. data/docs/reference/ui/displays.md +104 -0
  61. data/docs/reference/ui/forms.md +284 -0
  62. data/docs/reference/ui/index.md +30 -0
  63. data/docs/reference/ui/layouts.md +106 -0
  64. data/docs/reference/ui/pages.md +189 -0
  65. data/docs/reference/ui/tables.md +117 -0
  66. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  67. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  68. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  69. data/gemfiles/rails_7.gemfile.lock +1 -1
  70. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  71. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  72. data/lib/generators/pu/core/update/update_generator.rb +0 -20
  73. data/lib/generators/pu/invites/install_generator.rb +1 -0
  74. data/lib/plutonium/definition/base.rb +1 -1
  75. data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
  76. data/lib/plutonium/helpers/turbo_helper.rb +11 -0
  77. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  78. data/lib/plutonium/resource/controller.rb +1 -0
  79. data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
  80. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  81. data/lib/plutonium/resource/policy.rb +7 -0
  82. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  83. data/lib/plutonium/ui/component/methods.rb +4 -0
  84. data/lib/plutonium/ui/form/base.rb +6 -2
  85. data/lib/plutonium/ui/form/components/json.rb +58 -0
  86. data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
  87. data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
  88. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  89. data/lib/plutonium/ui/form/resource.rb +0 -4
  90. data/lib/plutonium/ui/grid/resource.rb +1 -1
  91. data/lib/plutonium/ui/layout/base.rb +1 -0
  92. data/lib/plutonium/ui/page/base.rb +0 -7
  93. data/lib/plutonium/ui/page/index.rb +4 -4
  94. data/lib/plutonium/ui/table/resource.rb +1 -1
  95. data/lib/plutonium/version.rb +1 -1
  96. data/lib/plutonium.rb +8 -0
  97. data/lib/tasks/release.rake +15 -1
  98. data/package.json +10 -10
  99. data/src/css/slim_select.css +4 -0
  100. data/src/js/controllers/slim_select_controller.js +61 -0
  101. data/src/js/turbo/turbo_actions.js +33 -0
  102. data/yarn.lock +553 -543
  103. metadata +44 -33
  104. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  105. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  106. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  107. data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
  108. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  109. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  110. data/.claude/skills/plutonium-installation/SKILL.md +0 -331
  111. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  112. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  113. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  114. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  115. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  116. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  117. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  118. data/.claude/skills/plutonium-views/SKILL.md +0 -651
  119. data/docs/reference/assets/index.md +0 -496
  120. data/docs/reference/controller/index.md +0 -412
  121. data/docs/reference/definition/actions.md +0 -462
  122. data/docs/reference/definition/fields.md +0 -383
  123. data/docs/reference/definition/index.md +0 -326
  124. data/docs/reference/definition/query.md +0 -351
  125. data/docs/reference/generators/index.md +0 -648
  126. data/docs/reference/interaction/index.md +0 -449
  127. data/docs/reference/model/features.md +0 -248
  128. data/docs/reference/model/index.md +0 -218
  129. data/docs/reference/policy/index.md +0 -456
  130. data/docs/reference/portal/index.md +0 -379
  131. data/docs/reference/views/forms.md +0 -411
  132. data/docs/reference/views/index.md +0 -544
@@ -1,317 +0,0 @@
1
- ---
2
- name: plutonium-entity-scoping
3
- description: Use BEFORE writing relation_scope, associated_with, scoping a model to a tenant, or any multi-tenancy work. Also when configuring entity strategies on a portal. The single source of truth for Plutonium entity scoping.
4
- ---
5
-
6
- # Plutonium Entity Scoping
7
-
8
- The single source of truth for how Plutonium scopes records to a tenant/entity in multi-tenant apps. Entity scoping spans models, policies, portals, and invites — this skill consolidates the canonical rules so you don't have to stitch them together from four other skills.
9
-
10
- ## 🚨 Critical (read first)
11
-
12
- - **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins to the entity skips Plutonium's scoping and triggers `verify_default_relation_scope_applied!`. Always call `default_relation_scope(relation)` explicitly (not `super`).
13
- - **Always declare an association path from the model to the entity.** If `associated_with` can't find a path — direct `belongs_to`, `has_one :through`, or a custom `associated_with_<entity>` scope — Plutonium raises. Fix the **model**, not the policy.
14
- - **Use a generator to scaffold scoped resources.** `pu:saas:setup`, `pu:pkg:portal --scope=Entity`, and `pu:res:scaffold` do the right thing. Hand-wiring scoping is how leaks happen.
15
- - **Parent scoping beats entity scoping.** When a parent is present (nested resource), `default_relation_scope` scopes via the parent, not via `entity_scope`. Don't double-scope.
16
- - **Related skills:** `plutonium-model` (associations, `associated_with`), `plutonium-policy` (`relation_scope` overrides), `plutonium-portal` (entity strategies), `plutonium-invites` (membership-backed scoping).
17
-
18
- ## Quick checklist
19
-
20
- Scoping a new model to a tenant:
21
-
22
- 1. Pick the shape: direct child, join table, or grandchild (see [Three model shapes](#three-model-shapes)).
23
- 2. Declare the association path on the model (`belongs_to`, `has_one :through`, or a custom `associated_with_<entity>` scope).
24
- 3. Verify `Model.associated_with(entity)` returns the right records in `rails runner`.
25
- 4. Confirm the portal is scoped: `scope_to_entity Entity, strategy: :path` (or custom) in the portal engine.
26
- 5. Leave `relation_scope` alone in the policy unless you need **extra** filters on top of the default.
27
- 6. If you do override `relation_scope`, wrap with `default_relation_scope(relation).where(...)`.
28
- 7. Add compound uniqueness scoped to the entity on the model (`validates :code, uniqueness: {scope: :organization_id}`).
29
- 8. Test: create a record in org A, confirm it does NOT appear when scoped to org B.
30
-
31
- ## How entity scoping works
32
-
33
- Plutonium's entity scoping is built on three cooperating pieces:
34
-
35
- - **Portal**: declares which entity class it scopes to (`scope_to_entity Organization, strategy: :path`) and how to resolve the current entity from the request.
36
- - **Policy**: `default_relation_scope(relation)` calls `relation.associated_with(entity_scope)`, applying the scope to every collection query.
37
- - **Model**: `associated_with(entity)` resolves the scope via a custom scope, a direct association, or auto-detected `has_one :through` chain.
38
-
39
- The `default_relation_scope` is enforced — if you override `relation_scope` without calling it, `verify_default_relation_scope_applied!` raises at runtime.
40
-
41
- ## `associated_with` resolution
42
-
43
- `Model.associated_with(entity)` resolves in this order:
44
-
45
- 1. **Custom named scope** `associated_with_<model_name>` (e.g. `associated_with_organization`) — highest priority, full control over the SQL.
46
- 2. **Direct `belongs_to` to the entity class** — `WHERE <entity>_id = ?`, most efficient.
47
- 3. **`has_one` / `has_one :through` to the entity class** — JOIN + WHERE, auto-detected via `reflect_on_all_associations`.
48
- 4. **Reverse `has_many` from the entity** — JOIN required, logs a warning (less efficient).
49
-
50
- If none apply, raises:
51
-
52
- ```
53
- Could not resolve the association between 'Model' and 'Entity'
54
- ```
55
-
56
- with guidance to either add an association or define the custom scope.
57
-
58
- ## `default_relation_scope` and safe `relation_scope` overrides
59
-
60
- `default_relation_scope(relation)` does two things:
61
-
62
- 1. If a **parent** is present (nested resource), scopes the relation via the parent association.
63
- 2. Otherwise, applies `relation.associated_with(entity_scope)`.
64
-
65
- ### Correct overrides
66
-
67
- ```ruby
68
- # ✅ Best: don't override at all — the inherited scope already calls default_relation_scope.
69
-
70
- # ✅ Add extra filters on top of default scope
71
- relation_scope do |relation|
72
- default_relation_scope(relation).where(archived: false)
73
- end
74
-
75
- # ✅ Role-based extra filter
76
- relation_scope do |relation|
77
- relation = default_relation_scope(relation)
78
- user.admin? ? relation : relation.where(author: user)
79
- end
80
- ```
81
-
82
- ### Wrong overrides
83
-
84
- ```ruby
85
- # ❌ Manually filtering by the scoped entity — bypasses default_relation_scope
86
- relation_scope do |relation|
87
- relation.where(organization: current_scoped_entity)
88
- end
89
-
90
- # ❌ Manual joins — same problem
91
- relation_scope do |relation|
92
- relation.joins(:project).where(projects: {organization_id: current_scoped_entity.id})
93
- end
94
-
95
- # ❌ Missing default_relation_scope entirely — raises at runtime
96
- relation_scope do |relation|
97
- relation.where(published: true)
98
- end
99
- ```
100
-
101
- **Do not rely on `super`** from inside `relation_scope do ... end`. `default_relation_scope` is the documented public contract; `super` semantics depend on how ActionPolicy's DSL registered the scope and aren't guaranteed.
102
-
103
- ### Intentionally skipping the scope
104
-
105
- Rare, but possible:
106
-
107
- ```ruby
108
- relation_scope do |relation|
109
- skip_default_relation_scope!
110
- relation
111
- end
112
- ```
113
-
114
- Before reaching for this, consider a separate portal without scoping.
115
-
116
- ## Entity strategies (portal configuration)
117
-
118
- The portal declares how the current entity is resolved from the request.
119
-
120
- ### Path strategy
121
-
122
- ```ruby
123
- module AdminPortal
124
- class Engine < Rails::Engine
125
- include Plutonium::Portal::Engine
126
-
127
- config.after_initialize do
128
- scope_to_entity Organization, strategy: :path
129
- end
130
- end
131
- end
132
- ```
133
-
134
- Routes become `/organizations/:organization_id/posts`. The portal extracts `params[:organization_id]` and loads the entity automatically.
135
-
136
- ### Custom strategy
137
-
138
- ```ruby
139
- module AdminPortal
140
- class Engine < Rails::Engine
141
- include Plutonium::Portal::Engine
142
-
143
- config.after_initialize do
144
- scope_to_entity Organization, strategy: :current_organization
145
- end
146
- end
147
- end
148
-
149
- module AdminPortal
150
- module Concerns
151
- module Controller
152
- extend ActiveSupport::Concern
153
- include Plutonium::Portal::Controller
154
-
155
- private
156
-
157
- def current_organization
158
- @current_organization ||= Organization.find_by!(subdomain: request.subdomain)
159
- end
160
- end
161
- end
162
- end
163
- ```
164
-
165
- The strategy symbol must match a method name on the controller.
166
-
167
- ### Accessing the scoped entity
168
-
169
- ```ruby
170
- current_scoped_entity # => current Organization
171
- scoped_to_entity? # => true/false
172
- ```
173
-
174
- Inside a policy, the same entity is available as `entity_scope`.
175
-
176
- ## Three model shapes
177
-
178
- The `associated_with` resolver handles three common model shapes. Pick the lightest one that fits.
179
-
180
- ### Shape 1: Direct child (belongs_to the entity)
181
-
182
- ```ruby
183
- class Organization < ResourceRecord
184
- has_many :projects
185
- end
186
-
187
- class Project < ResourceRecord
188
- belongs_to :organization
189
- end
190
-
191
- # Usage
192
- Project.associated_with(org)
193
- # => Project.where(organization: org) # simple WHERE, most efficient
194
- ```
195
-
196
- **When to use:** the model naturally has a direct foreign key to the entity. No extra work; auto-detected.
197
-
198
- ### Shape 2: Join table (membership-style)
199
-
200
- A join table linking users to entities, where the entity is reachable via one of the `belongs_to`:
201
-
202
- ```ruby
203
- class User < ResourceRecord
204
- has_many :memberships
205
- has_many :organizations, through: :memberships
206
- end
207
-
208
- class Organization < ResourceRecord
209
- has_many :memberships
210
- has_many :users, through: :memberships
211
- end
212
-
213
- class Membership < ResourceRecord
214
- belongs_to :user
215
- belongs_to :organization
216
-
217
- # ← auto-detection already finds :organization via belongs_to
218
- end
219
-
220
- # Usage
221
- Membership.associated_with(org)
222
- # => Membership.where(organization: org)
223
- ```
224
-
225
- **When to use:** a pure join table. The `belongs_to :organization` is sufficient.
226
-
227
- If instead the join table is the scope target and you want to scope `Project` → `Membership` → `Organization`, add a `has_one :through`:
228
-
229
- ```ruby
230
- class ProjectMember < ResourceRecord
231
- belongs_to :project
232
- belongs_to :user
233
- has_one :organization, through: :project # ← enables auto-scoping
234
- end
235
- ```
236
-
237
- Now `ProjectMember.associated_with(org)` resolves via the `has_one :through` automatically.
238
-
239
- ### Shape 3: Grandchild (multiple hops via `has_one :through`)
240
-
241
- ```ruby
242
- class Organization < ResourceRecord
243
- has_many :projects
244
- end
245
-
246
- class Project < ResourceRecord
247
- belongs_to :organization
248
- has_many :tasks
249
- end
250
-
251
- class Task < ResourceRecord
252
- belongs_to :project
253
- has_one :organization, through: :project # ← critical line
254
- end
255
-
256
- # Deeper
257
- class Comment < ResourceRecord
258
- belongs_to :task
259
- has_one :project, through: :task
260
- has_one :organization, through: :project # ← enables auto-scoping
261
- end
262
-
263
- # Usage
264
- Task.associated_with(org)
265
- # => resolves via the :organization has_one :through
266
-
267
- Comment.associated_with(org)
268
- # => resolves via Comment -> Task -> Project -> Organization
269
- ```
270
-
271
- **When to use:** the model is two+ hops away from the entity. Declaring `has_one :organization, through: ...` is the **lightest fix** — `associated_with` finds it via `reflect_on_all_associations` with no policy override needed.
272
-
273
- ### When to fall back to a custom scope
274
-
275
- Use a custom `associated_with_<model_name>` scope when:
276
-
277
- - The path is polymorphic.
278
- - The path needs conditional logic.
279
- - You want explicit SQL for performance (e.g. avoid a multi-join chain).
280
-
281
- ```ruby
282
- class Comment < ResourceRecord
283
- scope :associated_with_organization, ->(org) do
284
- joins(task: :project).where(projects: {organization_id: org.id})
285
- end
286
- end
287
-
288
- # Plutonium picks this up BEFORE trying association detection.
289
- ```
290
-
291
- ## How the pieces fit together
292
-
293
- 1. An admin opens `/organizations/42/projects`.
294
- 2. Portal's `scope_to_entity Organization, strategy: :path` extracts `42`, loads the `Organization`, sets `current_scoped_entity`.
295
- 3. The controller calls the policy. The policy's inherited `relation_scope` calls `default_relation_scope(relation)`.
296
- 4. `default_relation_scope` has no parent (this is a top-level nested resource from the portal's perspective), so it calls `relation.associated_with(current_scoped_entity)`.
297
- 5. `Project.associated_with(org)` resolves via the direct `belongs_to :organization` → `Project.where(organization: org)`.
298
- 6. The controller renders only that organization's projects. Records from other orgs are invisible.
299
-
300
- Any model that cannot be reached from the entity via these rules must declare a `has_one :through` or a custom scope. Policies must never work around this — work around it in the **model**.
301
-
302
- ## Gotchas
303
-
304
- - **Policy tries to filter by entity directly.** Wrong — that bypasses `default_relation_scope`. Add the association path to the model instead.
305
- - **`super` inside `relation_scope`.** Unreliable. Call `default_relation_scope(relation)` explicitly.
306
- - **Multiple associations to the same entity class.** E.g. `Match belongs_to :home_team, :away_team` both pointing at `Team`. Plutonium raises — override `scoped_entity_association` on the controller to pick one.
307
- - **`param_key` differs from association name.** Fine — Plutonium finds the association by **class**, not param key. You can still `scope_to_entity Competition::Team, param_key: :team` and have the model use `belongs_to :competition_team`.
308
- - **Forgetting compound uniqueness.** A unique constraint on `:code` alone leaks uniqueness across tenants. Use `validates :code, uniqueness: {scope: :organization_id}`.
309
- - **Skipping the scope "temporarily" for debugging.** Use `skip_default_relation_scope!` explicitly — never leave a `where` bypass in the code.
310
-
311
- ## Related skills
312
-
313
- - `plutonium-model` — `associated_with` mechanics, declaring associations, `has_one :through` patterns.
314
- - `plutonium-policy` — writing `relation_scope` safely, bulk authorization, attribute permissions.
315
- - `plutonium-portal` — entity strategies (path, custom), `scope_to_entity`, mounting.
316
- - `plutonium-invites` — how invites and memberships interact with entity scoping.
317
- - `plutonium-nested-resources` — parent scoping semantics, which take precedence over entity scoping.