plutonium 0.60.5 → 0.61.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 (175) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +19 -1
  3. data/.claude/skills/plutonium-app/SKILL.md +41 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +40 -0
  5. data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
  6. data/.claude/skills/plutonium-kanban/SKILL.md +313 -0
  7. data/.claude/skills/plutonium-resource/SKILL.md +40 -0
  8. data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
  9. data/.claude/skills/plutonium-testing/SKILL.md +38 -0
  10. data/.claude/skills/plutonium-ui/SKILL.md +51 -0
  11. data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
  12. data/.cliff.toml +6 -0
  13. data/Appraisals +3 -0
  14. data/CHANGELOG.md +549 -439
  15. data/CLAUDE.md +15 -7
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +895 -193
  18. data/app/assets/plutonium.js.map +4 -4
  19. data/app/assets/plutonium.min.js +53 -53
  20. data/app/assets/plutonium.min.js.map +4 -4
  21. data/app/views/layouts/basic.html.erb +7 -0
  22. data/app/views/plutonium/_flash_toasts.html.erb +2 -46
  23. data/app/views/plutonium/_toast.html.erb +52 -0
  24. data/app/views/resource/_resource_kanban.html.erb +1 -0
  25. data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
  26. data/docs/.vitepress/config.ts +24 -0
  27. data/docs/guides/index.md +2 -0
  28. data/docs/guides/kanban.md +447 -0
  29. data/docs/guides/wizards.md +447 -0
  30. data/docs/public/images/guides/kanban-after-move.png +0 -0
  31. data/docs/public/images/guides/kanban-board-light.png +0 -0
  32. data/docs/public/images/guides/kanban-board.png +0 -0
  33. data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
  34. data/docs/public/images/guides/kanban-wip-toast.png +0 -0
  35. data/docs/public/images/guides/wizards-chooser.png +0 -0
  36. data/docs/public/images/guides/wizards-completed.png +0 -0
  37. data/docs/public/images/guides/wizards-index-action.png +0 -0
  38. data/docs/public/images/guides/wizards-repeater.png +0 -0
  39. data/docs/public/images/guides/wizards-review.png +0 -0
  40. data/docs/public/images/guides/wizards-step.png +0 -0
  41. data/docs/reference/behavior/policies.md +1 -1
  42. data/docs/reference/index.md +14 -0
  43. data/docs/reference/kanban/authorization.md +62 -0
  44. data/docs/reference/kanban/dsl.md +293 -0
  45. data/docs/reference/kanban/index.md +40 -0
  46. data/docs/reference/kanban/positioning.md +162 -0
  47. data/docs/reference/resource/definition.md +16 -0
  48. data/docs/reference/ui/forms.md +36 -0
  49. data/docs/reference/ui/pages.md +2 -0
  50. data/docs/reference/wizard/anchoring-resume.md +194 -0
  51. data/docs/reference/wizard/dsl.md +332 -0
  52. data/docs/reference/wizard/index.md +33 -0
  53. data/docs/reference/wizard/one-time.md +129 -0
  54. data/docs/reference/wizard/registration-launch.md +177 -0
  55. data/docs/reference/wizard/storage-config.md +151 -0
  56. data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
  57. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
  58. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
  59. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
  60. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
  61. data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
  62. data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
  63. data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
  64. data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
  65. data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
  66. data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
  67. data/gemfiles/postgres.gemfile +8 -0
  68. data/gemfiles/postgres.gemfile.lock +321 -0
  69. data/gemfiles/rails_7.gemfile +1 -0
  70. data/gemfiles/rails_7.gemfile.lock +1 -1
  71. data/gemfiles/rails_8.0.gemfile +1 -0
  72. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  73. data/gemfiles/rails_8.1.gemfile +1 -0
  74. data/gemfiles/rails_8.1.gemfile.lock +14 -1
  75. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
  76. data/lib/plutonium/action/base.rb +9 -0
  77. data/lib/plutonium/auth/rodauth.rb +1 -2
  78. data/lib/plutonium/configuration.rb +4 -0
  79. data/lib/plutonium/core/controller.rb +20 -1
  80. data/lib/plutonium/definition/base.rb +25 -0
  81. data/lib/plutonium/definition/form_layout.rb +54 -35
  82. data/lib/plutonium/definition/index_views.rb +54 -1
  83. data/lib/plutonium/definition/wizards.rb +209 -0
  84. data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
  85. data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
  86. data/lib/plutonium/invites/controller.rb +4 -1
  87. data/lib/plutonium/kanban/action.rb +7 -0
  88. data/lib/plutonium/kanban/board.rb +40 -0
  89. data/lib/plutonium/kanban/broadcaster.rb +54 -0
  90. data/lib/plutonium/kanban/column.rb +69 -0
  91. data/lib/plutonium/kanban/context.rb +15 -0
  92. data/lib/plutonium/kanban/dsl.rb +71 -0
  93. data/lib/plutonium/kanban/grouping.rb +51 -0
  94. data/lib/plutonium/kanban/positioning.rb +75 -0
  95. data/lib/plutonium/kanban.rb +11 -0
  96. data/lib/plutonium/migrations.rb +40 -0
  97. data/lib/plutonium/positioning.rb +146 -0
  98. data/lib/plutonium/railtie.rb +33 -0
  99. data/lib/plutonium/resource/controller.rb +2 -0
  100. data/lib/plutonium/resource/controllers/crud_actions.rb +1 -1
  101. data/lib/plutonium/resource/controllers/kanban_actions.rb +455 -0
  102. data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
  103. data/lib/plutonium/resource/policy.rb +8 -0
  104. data/lib/plutonium/routing/mapper_extensions.rb +44 -0
  105. data/lib/plutonium/routing/wizard_registration.rb +289 -0
  106. data/lib/plutonium/ui/display/resource.rb +17 -12
  107. data/lib/plutonium/ui/form/base.rb +19 -5
  108. data/lib/plutonium/ui/form/components/password.rb +126 -0
  109. data/lib/plutonium/ui/form/components/uppy.rb +6 -3
  110. data/lib/plutonium/ui/form/options/inferred_types.rb +20 -0
  111. data/lib/plutonium/ui/form/resource.rb +1 -1
  112. data/lib/plutonium/ui/form/wizard.rb +63 -0
  113. data/lib/plutonium/ui/grid/card.rb +16 -5
  114. data/lib/plutonium/ui/kanban/card.rb +67 -0
  115. data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
  116. data/lib/plutonium/ui/kanban/column.rb +324 -0
  117. data/lib/plutonium/ui/kanban/resource.rb +212 -0
  118. data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
  119. data/lib/plutonium/ui/modal/base.rb +30 -3
  120. data/lib/plutonium/ui/modal/centered.rb +5 -2
  121. data/lib/plutonium/ui/page/index.rb +1 -0
  122. data/lib/plutonium/ui/page/show.rb +23 -0
  123. data/lib/plutonium/ui/page/wizard.rb +371 -0
  124. data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
  125. data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
  126. data/lib/plutonium/ui/table/base.rb +1 -1
  127. data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
  128. data/lib/plutonium/ui/wizard/review.rb +196 -0
  129. data/lib/plutonium/ui/wizard/stepper.rb +122 -0
  130. data/lib/plutonium/ui/wizard/summary_display.rb +59 -0
  131. data/lib/plutonium/version.rb +1 -1
  132. data/lib/plutonium/wizard/attachment_data.rb +42 -0
  133. data/lib/plutonium/wizard/attachments.rb +226 -0
  134. data/lib/plutonium/wizard/base.rb +216 -0
  135. data/lib/plutonium/wizard/base_controller.rb +31 -0
  136. data/lib/plutonium/wizard/configuration.rb +42 -0
  137. data/lib/plutonium/wizard/controller.rb +162 -0
  138. data/lib/plutonium/wizard/data.rb +134 -0
  139. data/lib/plutonium/wizard/driving.rb +639 -0
  140. data/lib/plutonium/wizard/dsl.rb +336 -0
  141. data/lib/plutonium/wizard/errors.rb +27 -0
  142. data/lib/plutonium/wizard/field_capture.rb +157 -0
  143. data/lib/plutonium/wizard/field_importer.rb +208 -0
  144. data/lib/plutonium/wizard/gate.rb +171 -0
  145. data/lib/plutonium/wizard/instance_key.rb +97 -0
  146. data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
  147. data/lib/plutonium/wizard/resume.rb +250 -0
  148. data/lib/plutonium/wizard/review_step.rb +48 -0
  149. data/lib/plutonium/wizard/route_resolution.rb +40 -0
  150. data/lib/plutonium/wizard/runner.rb +684 -0
  151. data/lib/plutonium/wizard/session.rb +53 -0
  152. data/lib/plutonium/wizard/state.rb +35 -0
  153. data/lib/plutonium/wizard/step.rb +61 -0
  154. data/lib/plutonium/wizard/step_adapter.rb +103 -0
  155. data/lib/plutonium/wizard/store/active_record.rb +174 -0
  156. data/lib/plutonium/wizard/store/base.rb +42 -0
  157. data/lib/plutonium/wizard/store/memory.rb +44 -0
  158. data/lib/plutonium/wizard/sweep_job.rb +76 -0
  159. data/lib/plutonium/wizard.rb +86 -0
  160. data/lib/plutonium.rb +5 -0
  161. data/lib/rodauth/features/case_insensitive_login.rb +1 -1
  162. data/lib/tasks/release.rake +144 -191
  163. data/package.json +3 -3
  164. data/src/css/components.css +132 -0
  165. data/src/js/controllers/attachment_input_controller.js +15 -1
  166. data/src/js/controllers/dirty_form_guard_controller.js +155 -27
  167. data/src/js/controllers/kanban_controller.js +330 -0
  168. data/src/js/controllers/password_sentinel_controller.js +39 -0
  169. data/src/js/controllers/register_controllers.js +6 -0
  170. data/src/js/controllers/remote_modal_controller.js +10 -0
  171. data/src/js/controllers/row_click_controller.js +14 -1
  172. data/src/js/controllers/wizard_controller.js +54 -0
  173. data/src/js/turbo/turbo_confirm.js +1 -1
  174. data/yarn.lock +271 -282
  175. metadata +100 -1
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Wizard
5
+ # Resolves a step's `using:` option (§2.4) — importing a field surface from a
6
+ # **model (ActiveRecord class)** instead of re-declaring it.
7
+ #
8
+ # `using:` targets a model only. A `Plutonium::Resource::Definition` carries no
9
+ # link to its model (it's an empty class the controller binds at request time),
10
+ # so the only reliable direction is **model → definition**: the importer
11
+ # auto-resolves `"#{Model}Definition"` to overlay input styling, best-effort.
12
+ #
13
+ # The imported surface is:
14
+ # - **attribute_schema** ({name => type}) — the field universe is
15
+ # `Model.attribute_names` (filtered by selectors); types are
16
+ # `Model.attribute_types[name].type`.
17
+ # - **inputs** ({name => {options:, block:}}) — overlaid from the resolved
18
+ # `<Model>Definition`'s `field`/`input` config (`as:`, options, labels),
19
+ # sliced to the imported names. No definition → empty input config.
20
+ # - **form_layout** — the resolved `<Model>Definition`'s `defined_form_layout`,
21
+ # filtered to the imported fields, **plus a trailing ungrouped section** for
22
+ # imported fields not named in any explicit section (skipped when
23
+ # `layout: false`).
24
+ # - **validate_fn** — runs a transient `Model.new(slice).valid?` and keeps
25
+ # errors only on the imported fields **plus `:base`** (skipped when
26
+ # `validate: false`).
27
+ #
28
+ # Validation is *run-and-filtered* rather than cloned: AR validators can't be
29
+ # cloned cleanly. Filtering to imported fields + `:base` is what prevents a
30
+ # partial model reporting presence errors for columns this step never collects.
31
+ module FieldImporter
32
+ # The validator kinds the form pipeline reads for field metadata (required
33
+ # marker, maxlength/minlength, min/max, pattern, auto-choices). Other kinds
34
+ # (uniqueness, custom EachValidators) carry no form meaning, so we skip them
35
+ # — and replaying a custom kind through `validates` would raise.
36
+ FORM_VALIDATOR_KINDS = %i[presence length numericality format inclusion].freeze
37
+
38
+ # The resolved import surface for one `using:` declaration.
39
+ Spec = Struct.new(:attribute_schema, :inputs, :form_layout, :validate_fn, :form_validators) do
40
+ # Run the imported validation over a staged data slice, returning a hash of
41
+ # {attribute => [messages]} for the imported fields + :base. Empty when
42
+ # `validate: false`.
43
+ def validate(data_slice)
44
+ validate_fn ? validate_fn.call(data_slice) : {}
45
+ end
46
+ end
47
+
48
+ class << self
49
+ # @param using [Class] an ActiveRecord model class
50
+ # @param opts [Hash] fields:/only:/except:/validate:/layout:/validation_context:
51
+ # @return [Spec]
52
+ def resolve(using:, opts:)
53
+ model = model!(using)
54
+ opts ||= {}
55
+ only = normalize(opts[:fields] || opts[:only])
56
+ except = normalize(opts[:except]) || []
57
+ do_validate = opts.fetch(:validate, true)
58
+ do_layout = opts.fetch(:layout, true)
59
+ context = opts[:validation_context]
60
+
61
+ names = select(model.attribute_names.map(&:to_sym), only:, except:)
62
+ definition = "#{model.name}Definition".safe_constantize
63
+
64
+ schema = names.index_with { |n| record_type(model, n) }
65
+
66
+ validate_fn = build_validate(do_validate) do |slice|
67
+ record = model.new(string_slice(slice, names))
68
+ run_and_filter(record, names, context)
69
+ end
70
+
71
+ Spec.new(
72
+ attribute_schema: schema,
73
+ inputs: inputs_for(definition, names),
74
+ form_layout: do_layout ? layout_for(definition, names) : nil,
75
+ validate_fn:,
76
+ form_validators: form_validators_for(model, names)
77
+ )
78
+ end
79
+
80
+ private
81
+
82
+ # The imported fields' form-relevant validators, as raw `[[name], options]`
83
+ # pairs replayable through `validates` onto the typed data class — so the
84
+ # form pipeline surfaces imported required/length/etc. the same as inline
85
+ # `validates`. Kept SEPARATE from `validate_fn`: the runner validates
86
+ # imported fields through the transient model, so these must not feed it.
87
+ def form_validators_for(model, names)
88
+ names.flat_map do |name|
89
+ model.validators_on(name).filter_map do |validator|
90
+ next unless FORM_VALIDATOR_KINDS.include?(validator.kind)
91
+ [[name], {validator.kind => validator.options.presence || true}]
92
+ end
93
+ end
94
+ end
95
+
96
+ # `using:` accepts a model class only. Anything else is a programming error.
97
+ def model!(using)
98
+ unless using.is_a?(Class) && using < ActiveRecord::Base
99
+ raise ArgumentError,
100
+ "using: expects a model class (an ActiveRecord::Base subclass), got #{using.inspect}"
101
+ end
102
+ using
103
+ end
104
+
105
+ def normalize(value)
106
+ return nil if value.nil?
107
+ Array(value).map(&:to_sym).presence
108
+ end
109
+
110
+ # `names & only` preserves source order while restricting to the selection.
111
+ def select(names, only:, except:)
112
+ names = names.select { |n| only.include?(n) } if only
113
+ names - except
114
+ end
115
+
116
+ def record_type(model, name)
117
+ # AR enum columns are integer-backed, but forms submit the string enum
118
+ # *key* ("active"), not the integer. Importing the raw :integer type would
119
+ # cast the key to 0. Keep enum fields as :string so the key round-trips —
120
+ # the author's `Model.new(field: data.field)` then lets AR map key → int,
121
+ # and the review summary shows the key, not a meaningless integer.
122
+ return :string if model.defined_enums.key?(name.to_s)
123
+ model.attribute_types[name.to_s]&.type || :string
124
+ end
125
+
126
+ def string_slice(slice, names)
127
+ slice = (slice || {}).stringify_keys
128
+ slice.slice(*names.map(&:to_s))
129
+ end
130
+
131
+ def build_validate(do_validate, &block)
132
+ do_validate ? block : nil
133
+ end
134
+
135
+ # Run `valid?` (with the optional context) and keep only the errors on the
136
+ # imported fields + :base.
137
+ def run_and_filter(obj, names, context)
138
+ context ? obj.valid?(context) : obj.valid?
139
+ keep = names + [:base]
140
+ obj.errors.group_by_attribute.slice(*keep)
141
+ end
142
+
143
+ # Build an input config for every imported name (so each gets rendered),
144
+ # overlaying styling from the resolved `<Model>Definition` where present. A
145
+ # definition's `field` and `input` declarations both render on the form;
146
+ # merge them per name (input wins on conflicting options, matching the
147
+ # resource form pipeline). No definition → each name maps to an empty config.
148
+ def inputs_for(definition, names)
149
+ fields = definition&.defined_fields || {}
150
+ inputs = definition&.defined_inputs || {}
151
+ names.index_with { |n| overlay_field_input(fields[n], inputs[n]) }
152
+ end
153
+
154
+ def overlay_field_input(field, input)
155
+ field ||= {}
156
+ input ||= {}
157
+ options = (field[:options] || {}).merge(input[:options] || {})
158
+ block = input[:block] || field[:block]
159
+ {options: options.presence, block:}.compact
160
+ end
161
+
162
+ # Inherit the resolved definition's form_layout, filtered to the imported
163
+ # fields. Mirrors the canonical `resolve_form_sections` leftover handling
164
+ # (form_layout.rb): each imported field is claimed by the first explicit
165
+ # section that lists it; imported fields no section names fall into a
166
+ # trailing **ungrouped** section, so none silently disappears.
167
+ def layout_for(definition, names)
168
+ return nil unless definition
169
+ layout = definition.defined_form_layout
170
+ return nil unless layout
171
+
172
+ # First-section-wins ownership among imported fields (shared with the
173
+ # canonical resolver). The ASSEMBLY below differs deliberately: an
174
+ # imported layout drops explicit sections that resolve to zero imported
175
+ # fields, and only synthesizes a trailing ungrouped section when there
176
+ # are leftovers — so it can't share the full `resolve_sections`.
177
+ owner, leftovers = Plutonium::Definition::FormLayout.assign_ownership(layout, names)
178
+
179
+ resolved = layout.filter_map do |section|
180
+ fields =
181
+ if section.ungrouped?
182
+ leftovers
183
+ else
184
+ section.fields.map(&:to_sym).select { |f| owner[f] == section.key }
185
+ end
186
+ # Drop explicit sections that resolve to zero imported fields; keep an
187
+ # explicit ungrouped section as the leftover home even when empty-listed.
188
+ next if fields.empty? && !section.ungrouped?
189
+ Plutonium::Definition::FormLayout::ResolvedSection.new(section, fields)
190
+ end
191
+
192
+ # No explicit ungrouped section, but leftover imported fields exist →
193
+ # synthesize a trailing ungrouped section so nothing disappears.
194
+ if leftovers.any? && layout.none?(&:ungrouped?)
195
+ ungrouped = Plutonium::Definition::FormLayout::Section.new(
196
+ key: Plutonium::Definition::FormLayout::UNGROUPED_KEY,
197
+ fields: [].freeze,
198
+ options: {}.freeze
199
+ )
200
+ resolved.push(Plutonium::Definition::FormLayout::ResolvedSection.new(ungrouped, leftovers))
201
+ end
202
+
203
+ resolved
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Wizard
5
+ # Controller concern that gates access behind a **one-time wizard** (§9).
6
+ #
7
+ # +ensure_wizard_completed(WizardClass)+ installs a +before_action+ that
8
+ # recomputes the wizard's +instance_key+ (from its +concurrency_key+ — resolved
9
+ # against the host controller's identity context, with the tenant folded in,
10
+ # §4.4) and checks whether a retained +completed+ row exists at that key. If
11
+ # not, it stashes the intended destination and redirects into the wizard's
12
+ # entry step; once the wizard's own finalize retains the completion marker, the
13
+ # gate lets the user through and the controller bounces back to the stashed
14
+ # destination (PRG, wired in {Controller}).
15
+ #
16
+ # class DashboardController < AdminPortal::PlutoniumController
17
+ # include Plutonium::Wizard::Gate
18
+ # ensure_wizard_completed OnboardingWizard
19
+ # end
20
+ #
21
+ # Only **one-time** wizards (a +concurrency_key+ plus +one_time+) are gateable —
22
+ # they are the only ones with a durable retained marker. Gating any other
23
+ # wizard raises a clear error at install time.
24
+ #
25
+ # The instance_key recomputation MUST match the runner/driving digest exactly
26
+ # (both go through {Plutonium::Wizard.compute_instance_key}), or the gate would
27
+ # never see the completion the wizard recorded.
28
+ module Gate
29
+ extend ActiveSupport::Concern
30
+
31
+ class_methods do
32
+ # Install the gating +before_action+. Extra options (e.g. +only:/except:+)
33
+ # are forwarded to +before_action+.
34
+ #
35
+ # +anchor:+ tells the gate how to resolve an ANCHORED wizard's anchor in
36
+ # THIS controller's context (a symbol method name or a proc evaluated on the
37
+ # controller) — required to recompute an anchor-keyed wizard's instance_key.
38
+ # A `via:`-anchored wizard whose anchor method the controller exposes is
39
+ # resolved automatically, so this is only needed when the anchor isn't in
40
+ # scope (e.g. a `with:`-anchored wizard, or gating from another portal).
41
+ def ensure_wizard_completed(wizard_class, anchor: nil, **before_action_opts)
42
+ unless wizard_class.one_time?
43
+ raise ArgumentError,
44
+ "#{wizard_class.name} is not a one-time wizard (needs a " \
45
+ "`concurrency_key` + `one_time`); only one-time wizards are gateable (§9)."
46
+ end
47
+
48
+ before_action(**before_action_opts) do
49
+ enforce_wizard_completion!(wizard_class, anchor)
50
+ end
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ # The before_action body: pass through when completed, else stash + redirect.
57
+ def enforce_wizard_completion!(wizard_class, anchor_resolver = nil)
58
+ return if wizard_completed?(wizard_class, anchor_resolver)
59
+
60
+ session[:return_to] ||= request.fullpath
61
+ redirect_to wizard_entry_path(wizard_class)
62
+ end
63
+
64
+ def wizard_completed?(wizard_class, anchor_resolver = nil)
65
+ wizard_gate_store.completed?(instance_key: wizard_gate_instance_key(wizard_class, anchor_resolver))
66
+ end
67
+
68
+ # Recompute the wizard's instance_key on the host controller (§9). The
69
+ # identity context (`current_user`, `current_scoped_entity`, `anchor`, custom
70
+ # host methods) is read from this controller; a referenced method that's
71
+ # missing raises a clear error via {compute_instance_key}.
72
+ def wizard_gate_instance_key(wizard_class, anchor_resolver = nil)
73
+ Plutonium::Wizard.compute_instance_key(
74
+ wizard_class: wizard_class,
75
+ current_user: current_user,
76
+ current_scoped_entity: wizard_gate_scoped_entity,
77
+ anchor: wizard_gate_anchor(wizard_class, anchor_resolver),
78
+ wizard_token: nil
79
+ )
80
+ end
81
+
82
+ # Resolve an anchored wizard's anchor in this controller's context, so an
83
+ # anchor-keyed wizard's `instance_key` recomputes to the SAME digest the run
84
+ # used (§9). Order: an explicit `anchor:` resolver wins; otherwise a
85
+ # `via:`-anchored wizard whose anchor method the controller exposes is
86
+ # resolved automatically (the same method the wizard uses); otherwise nil — a
87
+ # non-anchored wizard, or an anchor that can't be reached here. The latter is a
88
+ # misconfiguration for an anchor-keyed wizard, so raise rather than mis-key.
89
+ def wizard_gate_anchor(wizard_class, anchor_resolver)
90
+ return resolve_gate_anchor(anchor_resolver) if anchor_resolver
91
+ return nil unless wizard_class.anchored?
92
+
93
+ via = wizard_class.anchor_via
94
+ return send(via) if via && respond_to?(via, true)
95
+
96
+ # Couldn't auto-resolve the anchor. When the wizard relies on the IMPLIED
97
+ # anchor key, the anchor is DEFINITELY part of the identity, so a nil would
98
+ # silently mis-key (the gate would loop forever) — raise instead. For an
99
+ # explicit key we can't tell whether it references the anchor, so leave it
100
+ # nil (best-effort, same contract as any host-method the key may reference).
101
+ if wizard_class.implied_anchor_key?
102
+ raise ArgumentError,
103
+ "#{wizard_class.name} is anchored and keyed by its anchor, but #{self.class} " \
104
+ "can't resolve it#{" (no `#{via}` here)" if via}. Pass `anchor:` to " \
105
+ "`ensure_wizard_completed` (a method name or proc)."
106
+ end
107
+ nil
108
+ end
109
+
110
+ # Evaluate an explicit `anchor:` resolver: a proc runs in the controller
111
+ # context, a symbol is sent to the controller.
112
+ def resolve_gate_anchor(resolver)
113
+ resolver.respond_to?(:call) ? instance_exec(&resolver) : send(resolver)
114
+ end
115
+
116
+ # The tenant folded into the gate's key recomputation (§4.4) — the portal
117
+ # scoping entity when the host portal is entity-scoped, else nil. Mirrors the
118
+ # driving layer's `resolved_wizard_scope`. `current_user`/`scoped_to_entity?`
119
+ # are private on portal controllers, so call them directly (a `respond_to?`
120
+ # check would be false for private methods and silently mis-key).
121
+ def wizard_gate_scoped_entity
122
+ return unless scoped_to_entity?
123
+
124
+ current_scoped_entity
125
+ end
126
+
127
+ def wizard_gate_store
128
+ Plutonium::Wizard::Store::ActiveRecord.new
129
+ end
130
+
131
+ # The entry URL for the wizard (§5.3): the bare LAUNCH route `register_wizard`
132
+ # drew, resolved from THIS portal's route set by the wizard's `wizard_class`
133
+ # route default — so the actual `at:`/`as:` used at registration is honored
134
+ # (re-deriving a slug from the class name breaks whenever they differ, e.g.
135
+ # `register_wizard W, at: "onboarding"`). The launch action resolves/mints the
136
+ # run and PRGs to its current (resumed) step, so no `:step` is needed here. The
137
+ # tenant scope path segment is threaded for an entity-scoped portal (the route
138
+ # requires it). Override for a custom mount.
139
+ def wizard_entry_path(wizard_class)
140
+ route_set = wizard_gate_route_set
141
+ name = Plutonium::Wizard::RouteResolution.route_name(route_set, wizard_class, action: "launch")
142
+ unless name
143
+ raise ArgumentError,
144
+ "#{self.class} gates #{wizard_class.name} but no `register_wizard` launch " \
145
+ "route for it was found in #{(route_set === Rails.application.routes) ? "the application" : "this portal"}. " \
146
+ "Register it with `register_wizard #{wizard_class.name}, at: \"…\"`, or override " \
147
+ "`wizard_entry_path` for a custom mount."
148
+ end
149
+
150
+ route_set.url_helpers.public_send(:"#{name}_path", **wizard_gate_scope_param)
151
+ end
152
+
153
+ # The route set the gated wizard is mounted in — the host portal's engine
154
+ # routes (it's registered alongside the portal's resources). Override if the
155
+ # gate lives outside the wizard's portal.
156
+ def wizard_gate_route_set
157
+ current_engine.routes
158
+ end
159
+
160
+ # The tenant scope path segment for an entity-scoped portal, threaded from the
161
+ # current request so the entry URL stays inside the tenant. The route's param
162
+ # key is the engine's own `scoped_entity_param_key` (honors a custom
163
+ # `param_key:`); empty for a non-scoped portal.
164
+ def wizard_gate_scope_param
165
+ return {} unless scoped_to_entity?
166
+
167
+ {scoped_entity_param_key => params[scoped_entity_param_key]}
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Plutonium
7
+ module Wizard
8
+ # Computes the deterministic identity digest a wizard session row is keyed by
9
+ # (§4). There are two builders, mirroring the two identity axes:
10
+ #
11
+ # - {.concurrency} — a wizard with a `concurrency_key`. The digest is over the
12
+ # wizard name and the serialized key value(s) (which already include the
13
+ # folded tenant, §4.4). The keyed row IS the lock — two launches with the
14
+ # same key collapse to one digest, so the second resumes the first.
15
+ # - {.tokened} — a wizard without a `concurrency_key`. The digest is over the
16
+ # wizard name and the per-launch `wizard_token`, so every launch is a fresh,
17
+ # independent (repeatable) run.
18
+ #
19
+ # The two recipes MUST stay byte-identical between the place that creates rows
20
+ # (runner/driving) and the place that recomputes the key (the gate), or
21
+ # one-time gating silently breaks.
22
+ module InstanceKey
23
+ # Keyed (concurrency_key) identity.
24
+ #
25
+ # @param wizard_name [String] the wizard class name
26
+ # @param key_values [Object, Array] the concurrency_key value(s); arrays are
27
+ # serialized element-wise then joined. The tenant is expected to already be
28
+ # folded in by the caller (§4.4).
29
+ # @return [String] a hex SHA256 digest
30
+ def self.concurrency(wizard_name, key_values)
31
+ digest("concurrency", wizard_name, serialize(key_values))
32
+ end
33
+
34
+ # Tokened (no concurrency_key) identity.
35
+ #
36
+ # @param wizard_name [String] the wizard class name
37
+ # @param token [String] the per-launch wizard token
38
+ # @return [String] a hex SHA256 digest
39
+ def self.tokened(wizard_name, token)
40
+ digest("tokened", wizard_name, token.to_s)
41
+ end
42
+
43
+ # Serialize a key value into a STRUCTURED, unambiguous form for the digest:
44
+ # - Array → each element serialized, kept as a nested array
45
+ # - AR record / GlobalID-able → its GlobalID string
46
+ # - nil → nil (a nil tenant folds to a stable, distinct blank)
47
+ # - scalar → to_s
48
+ #
49
+ # Structure (not a flat join) is the point: the digest hashes the JSON of the
50
+ # nested form, so `["a", "b"]` and the scalar `"a|b"` serialize to distinct
51
+ # JSON (`["a","b"]` vs `"a|b"`) and can never collide. A separator-joined form
52
+ # would make a key element containing the separator indistinguishable from a
53
+ # structural boundary (two distinct runs collapsing to one row — cross-run
54
+ # data exposure / one-time gating satisfied by the wrong run).
55
+ #
56
+ # @param value [Object]
57
+ # @return [Object] a JSON-serializable structure (String / nested Array / nil)
58
+ def self.serialize(value)
59
+ case value
60
+ when Array
61
+ value.map { |v| serialize(v) }
62
+ when nil
63
+ nil
64
+ when String, Symbol, Numeric, true, false
65
+ value.to_s
66
+ else
67
+ if value.respond_to?(:to_global_id)
68
+ value.to_global_id.to_s
69
+ else
70
+ value.to_s
71
+ end
72
+ end
73
+ end
74
+
75
+ # Hash the JSON of [salt, *parts]. JSON makes the boundaries between parts (and
76
+ # between nested array elements) unambiguous; the salt (the app secret) makes
77
+ # the digest a MAC over otherwise-public identifiers (wizard name + key GIDs),
78
+ # so it can't be recomputed off-app to probe another run's existence/state.
79
+ def self.digest(*parts)
80
+ Digest::SHA256.hexdigest(JSON.generate([salt, *parts]))
81
+ end
82
+ private_class_method :digest
83
+
84
+ # A stable, app-specific salt. Falls back to a constant when no Rails app /
85
+ # secret is configured (e.g. isolated unit contexts) so the digest is still
86
+ # deterministic — the MAC property simply degrades to the unsalted form there.
87
+ def self.salt
88
+ if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
89
+ ::Rails.application.secret_key_base.to_s
90
+ else
91
+ "plutonium-wizard"
92
+ end
93
+ end
94
+ private_class_method :salt
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Wizard
5
+ # Lazy view over a wizard run's persisted records (§2.2 / §4.5).
6
+ #
7
+ # The stored state only holds GIDs ({ "step_key" => [gid, ...] }); resolving
8
+ # them back into live records costs a `GlobalID::Locator.locate` per GID. Most
9
+ # requests (a GET render, a `back`, any step whose `condition:`/render never
10
+ # reads `persisted`) don't need those records at all, so we resolve LAZILY:
11
+ #
12
+ # - `persisted[:key]` returns the memoized live records when the key was SET
13
+ # this request (records created via the `persist` macro in `on_submit`) — no
14
+ # locate.
15
+ # - Otherwise it locates the GIDs stored under that key ONCE, memoizes the
16
+ # result, and returns it. A request that never reads `persisted` issues zero
17
+ # locate queries.
18
+ #
19
+ # `gid_source` is the `{ "step_key" => [gids] }` hash the runner injects from
20
+ # the stored state. Writes (`persisted[k] = records`) memoize live records
21
+ # directly and shadow any stored GIDs for that key.
22
+ class LazyPersisted
23
+ def initialize(gid_source = {})
24
+ @gid_source = gid_source || {}
25
+ @memo = {}
26
+ end
27
+
28
+ # Live records for a step key. Memoized records (set this request) are
29
+ # returned as-is; otherwise the key's stored GIDs are located once.
30
+ def [](key)
31
+ key = key.to_sym
32
+ return @memo[key] if @memo.key?(key)
33
+
34
+ @memo[key] = locate(@gid_source[key.to_s] || @gid_source[key])
35
+ end
36
+
37
+ # Memoize live records for a key directly (the runner's post-`on_submit`
38
+ # set, or an author assigning into `persisted`). No locate on later reads.
39
+ def []=(key, records)
40
+ @memo[key.to_sym] = Array(records)
41
+ end
42
+
43
+ # Whether this key has records available — either set this request or stored
44
+ # as GIDs. Does NOT trigger a locate.
45
+ def key?(key)
46
+ key = key.to_sym
47
+ @memo.key?(key) || @gid_source.key?(key.to_s) || @gid_source.key?(key)
48
+ end
49
+ alias_method :has_key?, :key?
50
+
51
+ # Resolve every known key to its located records (forces locates). Used where
52
+ # the full map is genuinely needed.
53
+ def to_h
54
+ keys.each_with_object({}) { |key, acc| acc[key] = self[key] }
55
+ end
56
+
57
+ # All known step keys (memoized + stored), as symbols, without locating.
58
+ def keys
59
+ (@memo.keys + @gid_source.keys.map(&:to_sym)).uniq
60
+ end
61
+
62
+ private
63
+
64
+ # Batch-resolve a key's GIDs in ONE query per model class (vs one locate per
65
+ # GID — an N+1 for a multi-record step). `ignore_missing: true` drops GIDs
66
+ # whose record no longer exists (e.g. swept/destroyed), matching the old
67
+ # per-GID `filter_map` that dropped nils; unparseable/invalid GIDs are dropped
68
+ # too, and input order is preserved.
69
+ def locate(gids)
70
+ gids = Array(gids)
71
+ return [] if gids.empty?
72
+
73
+ GlobalID::Locator.locate_many(gids, ignore_missing: true)
74
+ end
75
+ end
76
+ end
77
+ end