plutonium 0.45.2 → 0.46.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 +4 -4
- data/.claude/skills/plutonium/SKILL.md +146 -0
- data/.claude/skills/plutonium-assets/SKILL.md +248 -157
- data/.claude/skills/{plutonium-rodauth → plutonium-auth}/SKILL.md +195 -229
- data/.claude/skills/plutonium-controller/SKILL.md +9 -2
- data/.claude/skills/plutonium-create-resource/SKILL.md +22 -1
- data/.claude/skills/plutonium-definition/SKILL.md +521 -7
- data/.claude/skills/plutonium-entity-scoping/SKILL.md +317 -0
- data/.claude/skills/plutonium-forms/SKILL.md +8 -1
- data/.claude/skills/plutonium-installation/SKILL.md +25 -2
- data/.claude/skills/plutonium-interaction/SKILL.md +9 -2
- data/.claude/skills/plutonium-invites/SKILL.md +11 -7
- data/.claude/skills/plutonium-model/SKILL.md +50 -50
- data/.claude/skills/plutonium-nested-resources/SKILL.md +8 -1
- data/.claude/skills/plutonium-package/SKILL.md +8 -1
- data/.claude/skills/plutonium-policy/SKILL.md +69 -78
- data/.claude/skills/plutonium-portal/SKILL.md +26 -70
- data/.claude/skills/plutonium-views/SKILL.md +9 -2
- data/CHANGELOG.md +33 -0
- data/app/assets/plutonium.css +1 -1
- data/app/views/rodauth/_login_form.html.erb +0 -3
- data/app/views/rodauth/confirm_password.html.erb +0 -4
- data/app/views/rodauth/create_account.html.erb +0 -3
- data/app/views/rodauth/logout.html.erb +0 -3
- data/config/initializers/pagy.rb +1 -1
- data/docs/superpowers/plans/2026-04-08-plutonium-skills-overhaul.md +481 -0
- data/docs/superpowers/specs/2026-04-08-plutonium-skills-overhaul-design.md +236 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/update/update_generator.rb +8 -0
- data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +56 -0
- data/lib/generators/pu/invites/install_generator.rb +8 -1
- data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +43 -0
- data/lib/generators/pu/profile/concerns/profile_arguments.rb +10 -4
- data/lib/generators/pu/profile/conn_generator.rb +9 -12
- data/lib/generators/pu/profile/install_generator.rb +5 -2
- data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +3 -0
- data/lib/generators/pu/saas/portal_generator.rb +4 -9
- data/lib/generators/pu/saas/welcome/templates/app/views/welcome/onboarding.html.erb.tt +2 -2
- data/lib/plutonium/engine.rb +18 -5
- data/lib/plutonium/ui/layout/rodauth_layout.rb +6 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- metadata +7 -8
- data/.claude/skills/plutonium/skill.md +0 -130
- data/.claude/skills/plutonium-definition-actions/SKILL.md +0 -424
- data/.claude/skills/plutonium-definition-query/SKILL.md +0 -364
- data/.claude/skills/plutonium-profile/SKILL.md +0 -276
- data/.claude/skills/plutonium-theming/SKILL.md +0 -424
|
@@ -0,0 +1,317 @@
|
|
|
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.
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: plutonium-forms
|
|
3
|
-
description: Use
|
|
3
|
+
description: Use BEFORE customizing a form template, field builder, or input component in Plutonium. Also when overriding Form in a definition.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Plutonium Forms
|
|
7
7
|
|
|
8
|
+
## 🚨 Critical (read first)
|
|
9
|
+
- **Use `pu:field:input NAME` for custom input components.** Don't hand-write Phlexi field classes — the generator registers them correctly.
|
|
10
|
+
- **Configure inputs in the definition, render them in the form.** `input :foo, as: :markdown` in the definition; `render_resource_field :foo` in the custom form template.
|
|
11
|
+
- **Override via `class Form < Form` in the definition.** Don't replace the form root class.
|
|
12
|
+
- **`render_actions` renders the submit buttons.** Always call it at the end of a custom `form_template` or the form won't submit.
|
|
13
|
+
- **Related skills:** `plutonium-definition` (input configuration), `plutonium-views` (custom page classes), `plutonium-assets` (theming), `plutonium-interaction` (interaction forms).
|
|
14
|
+
|
|
8
15
|
**Use generators for custom field types:**
|
|
9
16
|
- `rails g pu:field:input NAME` creates a custom form input component
|
|
10
17
|
- `rails g pu:field:renderer NAME` creates a custom display renderer
|
|
@@ -1,10 +1,31 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: plutonium-installation
|
|
3
|
-
description: Use
|
|
3
|
+
description: Use BEFORE installing Plutonium in a Rails app, running pu:core:install, or configuring initial Plutonium setup. Covers generators, gemfile, and initial config.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Plutonium Installation
|
|
7
7
|
|
|
8
|
+
## 🚨 Critical (read first)
|
|
9
|
+
- **Use the generators.** `pu:core:install`, `pu:rodauth:install`, `pu:pkg:portal`, `pu:res:scaffold`, `pu:res:conn` — never hand-write base controllers, policies, or layouts.
|
|
10
|
+
- **Use `base.rb`, not `plutonium.rb`, for existing apps.** The `plutonium.rb` template reruns the full bootstrap (dotenv, annotate, solid_*, assets) and clobbers git history. For any pre-existing app, use `base.rb`.
|
|
11
|
+
- **Pass `--dest`, `--force`, `--auth`, `--skip-bundle` for unattended runs** so generators don't block on prompts. See `plutonium` index for the full flag matrix.
|
|
12
|
+
- **Related skills:** `plutonium` (architecture overview), `plutonium-auth` (Rodauth setup), `plutonium-portal` (portal config), `plutonium-create-resource` (scaffolding resources).
|
|
13
|
+
|
|
14
|
+
## Quick checklist
|
|
15
|
+
|
|
16
|
+
Fresh install in a new Rails app:
|
|
17
|
+
|
|
18
|
+
1. Generate the Rails app with `rails new myapp -a propshaft -j esbuild -c tailwind -m https://radioactive-labs.github.io/plutonium-core/templates/plutonium.rb` (greenfield) OR `bin/rails app:template LOCATION=.../base.rb` (existing app).
|
|
19
|
+
2. Run `bundle install` if you added the gem manually.
|
|
20
|
+
3. Run `rails generate pu:core:install` to create base controllers, policies, definitions, and config.
|
|
21
|
+
4. Run `rails generate pu:rodauth:install` + `rails generate pu:rodauth:account user` for auth.
|
|
22
|
+
5. Run `rails generate pu:pkg:portal admin --auth=user` to create a portal.
|
|
23
|
+
6. Run `rails generate pu:res:scaffold Post title:string 'content:text?' --dest=main_app` for a first resource.
|
|
24
|
+
7. Run `rails db:migrate`.
|
|
25
|
+
8. Run `rails generate pu:res:conn Post --dest=admin_portal` to connect the resource.
|
|
26
|
+
9. Mount the portal in `config/routes.rb`: `mount AdminPortal::Engine, at: "/admin"`.
|
|
27
|
+
10. Start the server and visit `/admin`.
|
|
28
|
+
|
|
8
29
|
## New Rails App (Recommended)
|
|
9
30
|
|
|
10
31
|
Use the Rails template for a fully configured setup:
|
|
@@ -18,6 +39,8 @@ This sets up Rails with Propshaft, esbuild, TailwindCSS, and Plutonium in one co
|
|
|
18
39
|
|
|
19
40
|
## Existing Rails App
|
|
20
41
|
|
|
42
|
+
> **⚠️ Use `base.rb`, not `plutonium.rb`.** The `plutonium.rb` template is for `rails new` only — it re-runs the full app bootstrap (dotenv, annotate, solid_*, assets) and creates generic "initial commit" commits that clobber history. For any pre-existing app, always use `base.rb`.
|
|
43
|
+
|
|
21
44
|
### Option 1: Rails Template
|
|
22
45
|
|
|
23
46
|
```bash
|
|
@@ -293,7 +316,7 @@ For models that already exist in your app:
|
|
|
293
316
|
## Related Skills
|
|
294
317
|
|
|
295
318
|
- `plutonium` - Resource architecture overview
|
|
296
|
-
- `plutonium-
|
|
319
|
+
- `plutonium-auth` - Authentication setup and configuration
|
|
297
320
|
- `plutonium-package` - Feature and portal packages
|
|
298
321
|
- `plutonium-portal` - Portal configuration
|
|
299
322
|
- `plutonium-views` - Custom pages, layouts, and Phlex components
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: plutonium-interaction
|
|
3
|
-
description: Use
|
|
3
|
+
description: Use BEFORE writing an interaction class, encapsulating business logic, or building multi-step operations beyond basic CRUD. Covers Plutonium::Resource::Interaction.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Plutonium Interactions
|
|
7
7
|
|
|
8
|
+
## 🚨 Critical (read first)
|
|
9
|
+
- **`ActiveRecord::RecordInvalid` is NOT rescued automatically.** Always rescue it when using `create!`/`update!`/`save!` and return `failed(e.record.errors)`.
|
|
10
|
+
- **Return `succeed(...)` or `failed(...)`** from `execute` — the controller won't know what happened otherwise.
|
|
11
|
+
- **Redirect is automatic on success.** Only call `with_redirect_response` for a *different* destination.
|
|
12
|
+
- **Bulk actions use `resources` (plural).** Policy methods are checked per record; if any fails, the whole request fails.
|
|
13
|
+
- **Related skills:** `plutonium-definition` (registering actions), `plutonium-policy` (authorizing actions), `plutonium-forms` (interaction form templates).
|
|
14
|
+
|
|
8
15
|
Interactions encapsulate business logic into reusable, testable units. They handle input validation, execution, and outcomes.
|
|
9
16
|
|
|
10
17
|
## Basic Structure
|
|
@@ -377,7 +384,7 @@ end
|
|
|
377
384
|
|
|
378
385
|
## Related Skills
|
|
379
386
|
|
|
380
|
-
- `plutonium-definition
|
|
387
|
+
- `plutonium-definition` - Declaring actions in definitions
|
|
381
388
|
- `plutonium-forms` - Custom interaction form templates
|
|
382
389
|
- `plutonium-policy` - Controlling access to actions
|
|
383
390
|
- `plutonium` - How interactions fit in the architecture
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: plutonium-invites
|
|
3
|
-
description: Use
|
|
3
|
+
description: Use BEFORE setting up user invitations, pu:invites:install, or entity membership in a multi-tenant Plutonium app. Also load plutonium-entity-scoping.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Plutonium User Invites
|
|
7
7
|
|
|
8
|
+
## 🚨 Critical (read first)
|
|
9
|
+
- **Use the generators.** `pu:invites:install` and `pu:invites:invitable` — never hand-write invite models, mailers, or controllers. Prerequisites: user model, entity model, membership model (`pu:saas:setup` creates all three).
|
|
10
|
+
- **Invite email must match the accepting user's email.** This is a security feature. Don't disable `enforce_email?` unless you fully understand the implications.
|
|
11
|
+
- **Entity scoping applies to invites** — invites are automatically filtered by the current entity. See `plutonium-entity-scoping`.
|
|
12
|
+
- **Implement `on_invite_accepted` on invitable models.** Plutonium calls it when the invite is accepted; without it, the invitable never learns about the new user.
|
|
13
|
+
- **Related skills:** `plutonium-entity-scoping` (tenant scoping for invites), `plutonium-auth` (Rodauth signup flow), `plutonium-portal` (portal connection), `plutonium-interaction` (custom invite logic).
|
|
14
|
+
|
|
8
15
|
Plutonium provides a complete user invitation system for multi-tenant applications. The system handles:
|
|
9
16
|
- Sending email invitations to new users
|
|
10
17
|
- Token-based invite acceptance flow
|
|
@@ -307,12 +314,9 @@ end
|
|
|
307
314
|
|
|
308
315
|
### Entity-Scoped Invite Management
|
|
309
316
|
|
|
310
|
-
|
|
317
|
+
Invites are automatically filtered by the current entity — admins only see invites for their organization. This works because `Invites::UserInvite` has `belongs_to :entity`, which `associated_with` picks up.
|
|
311
318
|
|
|
312
|
-
|
|
313
|
-
# In your portal, invites are automatically filtered by entity_scope
|
|
314
|
-
# Admins only see invites for their organization
|
|
315
|
-
```
|
|
319
|
+
> **For how entity scoping works end-to-end (model shapes, `default_relation_scope`, portal strategies), see the [plutonium-entity-scoping](../plutonium-entity-scoping/SKILL.md) skill. It is the single source of truth.**
|
|
316
320
|
|
|
317
321
|
## Troubleshooting
|
|
318
322
|
|
|
@@ -357,7 +361,7 @@ end
|
|
|
357
361
|
|
|
358
362
|
## Related Skills
|
|
359
363
|
|
|
360
|
-
- `plutonium-
|
|
364
|
+
- `plutonium-auth` - Authentication setup
|
|
361
365
|
- `plutonium-interaction` - Custom business logic
|
|
362
366
|
- `plutonium-portal` - Portal configuration
|
|
363
367
|
- `plutonium-policy` - Authorization for invite actions
|
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: plutonium-model
|
|
3
|
-
description: Use
|
|
3
|
+
description: Use BEFORE editing a Plutonium resource model, adding associations, has_cents, SGID, or routing helpers. For tenancy / associated_with / relation_scope, also load plutonium-entity-scoping.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Plutonium Resource Models
|
|
7
7
|
|
|
8
|
+
## 🚨 Critical (read first)
|
|
9
|
+
- **Use `pu:res:scaffold`.** Never hand-write resource model files — the scaffold sets up `Plutonium::Resource::Record`, associations, and the expected section layout.
|
|
10
|
+
- **Declare associations for the entity.** For multi-tenant apps, add `belongs_to`, `has_one :through`, or an `associated_with_<entity>` scope so `associated_with` can resolve. Fix the model, not the policy.
|
|
11
|
+
- **Compound uniqueness** — in multi-tenant models, scope unique constraints to the tenant FK (`uniqueness: {scope: :organization_id}`), or you leak across tenants.
|
|
12
|
+
- **Keep business logic out of the model.** Use interactions for multi-step ops, policies for authorization.
|
|
13
|
+
- **Related skills:** `plutonium-entity-scoping` (tenancy mechanics), `plutonium-create-resource` (scaffold), `plutonium-definition` (UI), `plutonium-policy` (authorization).
|
|
14
|
+
|
|
15
|
+
## Quick checklist
|
|
16
|
+
|
|
17
|
+
Adding/editing a Plutonium model:
|
|
18
|
+
|
|
19
|
+
1. Use `pu:res:scaffold` for new models; include `Plutonium::Resource::Record` on existing ones.
|
|
20
|
+
2. Place associations/enums/validations in the right section (enums → belongs_to → has_one → has_many → scopes → validations → callbacks).
|
|
21
|
+
3. For monetary fields, use `has_cents :field_cents`.
|
|
22
|
+
4. For multi-tenancy, declare an association path to the entity (`belongs_to`, `has_one :through`, or a custom `associated_with_<entity>` scope).
|
|
23
|
+
5. Add compound uniqueness scoped to the tenant FK.
|
|
24
|
+
6. For SEO URLs, override `path_parameter` or `dynamic_path_parameter`.
|
|
25
|
+
7. Override `to_label` if `:name`/`:title` isn't meaningful.
|
|
26
|
+
8. Verify with `rails runner "puts Model.first.associated_with(entity).count"`.
|
|
27
|
+
|
|
8
28
|
**Always use generators to create models** - never create model files manually:
|
|
9
29
|
```bash
|
|
10
30
|
rails g pu:res:scaffold Post title:string content:text --dest=main_app
|
|
@@ -161,6 +181,31 @@ has_cents :field_cents,
|
|
|
161
181
|
suffix: "amount" # Suffix for generated name (default: "amount")
|
|
162
182
|
```
|
|
163
183
|
|
|
184
|
+
### Using `has_cents` fields in policies and definitions
|
|
185
|
+
|
|
186
|
+
**Always reference the virtual accessor (`:price`), never the underlying column (`:price_cents`).**
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
# Model
|
|
190
|
+
class Product < ResourceRecord
|
|
191
|
+
has_cents :price_cents # exposes virtual :price
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# ✅ Policy — use the virtual name
|
|
195
|
+
class ProductPolicy < ResourcePolicy
|
|
196
|
+
def permitted_attributes_for_create
|
|
197
|
+
%i[name price] # NOT :price_cents
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# ✅ Definition — use the virtual name
|
|
202
|
+
class ProductDefinition < ResourceDefinition
|
|
203
|
+
field :price, as: :decimal
|
|
204
|
+
end
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The virtual accessor handles form input, validation, and display as a decimal. Using `:price_cents` directly in a policy or definition forces users to enter integer cents and bypasses the conversion. Generators sometimes emit the `_cents` name in the policy — fix it by hand if you see it (and add `has_cents` if it's missing from the model).
|
|
208
|
+
|
|
164
209
|
### Validation
|
|
165
210
|
|
|
166
211
|
```ruby
|
|
@@ -233,64 +278,19 @@ user.remove_post_sgid("BAh7CEkiCG...") # Remove from collection
|
|
|
233
278
|
|
|
234
279
|
## Entity Scoping (associated_with)
|
|
235
280
|
|
|
236
|
-
|
|
281
|
+
`Plutonium::Resource::Record` provides `Model.associated_with(entity)` for multi-tenant queries. It resolves via a custom `associated_with_<entity>` scope, a direct `belongs_to`, or an auto-detected `has_one :through` chain.
|
|
237
282
|
|
|
238
|
-
|
|
283
|
+
Quick example:
|
|
239
284
|
|
|
240
285
|
```ruby
|
|
241
286
|
class Comment < ResourceRecord
|
|
242
287
|
belongs_to :post
|
|
243
288
|
end
|
|
244
289
|
|
|
245
|
-
#
|
|
246
|
-
Comment.associated_with(post)
|
|
247
|
-
# => Comment.where(post: post)
|
|
290
|
+
Comment.associated_with(post) # => Comment.where(post: post)
|
|
248
291
|
```
|
|
249
292
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
Works with:
|
|
253
|
-
- `belongs_to` - Uses WHERE clause (most efficient)
|
|
254
|
-
- `has_one` - Uses JOIN + WHERE
|
|
255
|
-
- `has_many` - Uses JOIN + WHERE
|
|
256
|
-
|
|
257
|
-
```ruby
|
|
258
|
-
# Direct association (preferred)
|
|
259
|
-
Comment.associated_with(post) # WHERE post_id = ?
|
|
260
|
-
|
|
261
|
-
# Reverse association (less efficient, logs warning)
|
|
262
|
-
Post.associated_with(comment) # JOIN comments WHERE comments.id = ?
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
### Custom Scopes
|
|
266
|
-
|
|
267
|
-
For optimal performance, define custom scopes:
|
|
268
|
-
|
|
269
|
-
```ruby
|
|
270
|
-
class Comment < ResourceRecord
|
|
271
|
-
# Custom scope naming: associated_with_{model_name}
|
|
272
|
-
scope :associated_with_user, ->(user) do
|
|
273
|
-
joins(:post).where(posts: {user_id: user.id})
|
|
274
|
-
end
|
|
275
|
-
end
|
|
276
|
-
|
|
277
|
-
# Automatically uses custom scope
|
|
278
|
-
Comment.associated_with(user)
|
|
279
|
-
```
|
|
280
|
-
|
|
281
|
-
### Error Handling
|
|
282
|
-
|
|
283
|
-
```ruby
|
|
284
|
-
# When no association exists
|
|
285
|
-
UnrelatedModel.associated_with(user)
|
|
286
|
-
# Raises: Could not resolve the association between 'UnrelatedModel' and 'User'
|
|
287
|
-
#
|
|
288
|
-
# Define:
|
|
289
|
-
# 1. the associations between the models
|
|
290
|
-
# 2. a named scope on UnrelatedModel e.g.
|
|
291
|
-
#
|
|
292
|
-
# scope :associated_with_user, ->(user) { do_something_here }
|
|
293
|
-
```
|
|
293
|
+
> **For entity scoping details — the three model shapes (direct child, join table, grandchild), `has_one :through` patterns, custom scopes, `default_relation_scope`, and how it fits with policies and portals — see the [plutonium-entity-scoping](../plutonium-entity-scoping/SKILL.md) skill. It is the single source of truth.**
|
|
294
294
|
|
|
295
295
|
## URL Routing
|
|
296
296
|
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: plutonium-nested-resources
|
|
3
|
-
description: Use
|
|
3
|
+
description: Use BEFORE configuring parent/child resource relationships, nested routes, or scoped URL generation with resource_url_for(parent:).
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Nested Resources
|
|
7
7
|
|
|
8
|
+
## 🚨 Critical (read first)
|
|
9
|
+
- **Use `pu:res:scaffold` + `pu:res:conn`** for both parent and child. Nested routes are generated from the `belongs_to` + association on the parent — no manual route wiring.
|
|
10
|
+
- **Parent scoping beats entity scoping.** When a parent is present, `default_relation_scope` scopes via the parent, not via `entity_scope`. Don't double-scope in the policy.
|
|
11
|
+
- **Plutonium supports one level of nesting.** Grandparent → parent → child nested routes are NOT supported. Use top-level routes for deeper relationships.
|
|
12
|
+
- **Named custom routes only.** When adding member/collection routes on a nested resource, always pass `as:` — otherwise `resource_url_for` will fail.
|
|
13
|
+
- **Related skills:** `plutonium-entity-scoping` (how parent scoping interacts with entity scoping), `plutonium-policy` (parent scoping in `relation_scope`), `plutonium-controller` (presentation hooks), `plutonium-portal` (route registration).
|
|
14
|
+
|
|
8
15
|
**Always use generators** to create both parent and child resources, then connect them to portals:
|
|
9
16
|
```bash
|
|
10
17
|
rails g pu:res:scaffold Company name:string --dest=main_app
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: plutonium-package
|
|
3
|
-
description: Use
|
|
3
|
+
description: Use BEFORE creating a feature package or portal package via pu:pkg:package / pu:pkg:portal, or organizing a Plutonium app into modular engines.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Plutonium Packages
|
|
7
7
|
|
|
8
|
+
## 🚨 Critical (read first)
|
|
9
|
+
- **Use the generators.** `pu:pkg:package` for feature packages, `pu:pkg:portal` for portal packages — never hand-write engine files or directory structures.
|
|
10
|
+
- **Feature vs portal is a hard split.** Feature packages hold models/policies/definitions/interactions; portal packages hold controllers/views/routes/auth. Don't mix.
|
|
11
|
+
- **Package classes are auto-namespaced** (`packages/blogging/app/models/blogging/post.rb` → `Blogging::Post`). Don't fight the namespacing.
|
|
12
|
+
- **Cross-package resource references** use the full namespace: `rails g pu:res:conn Blogging::Post --dest=admin_portal`.
|
|
13
|
+
- **Related skills:** `plutonium-portal` (portal-specific features), `plutonium-create-resource` (creating resources in packages), `plutonium-installation` (package loading).
|
|
14
|
+
|
|
8
15
|
Packages are specialized Rails engines for organizing code. There are two types:
|
|
9
16
|
|
|
10
17
|
| Type | Purpose | Generator |
|