seams 0.1.0 → 0.2.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 (222) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -12
  3. data/README.md +235 -82
  4. data/lib/generators/seams/accounts/accounts_generator.rb +31 -3
  5. data/lib/generators/seams/accounts/templates/app/controllers/memberships_controller.rb.tt +100 -0
  6. data/lib/generators/seams/accounts/templates/app/views/memberships/index.html.erb.tt +52 -0
  7. data/lib/generators/seams/accounts/templates/config/routes.rb.tt +10 -4
  8. data/lib/generators/seams/accounts/templates/lib/concerns/authorization.rb.tt +35 -2
  9. data/lib/generators/seams/accounts/templates/lib/engine.rb.tt +13 -0
  10. data/lib/generators/seams/accounts/templates/spec/runtime/authorization_spec.rb.tt +102 -0
  11. data/lib/generators/seams/accounts/templates/spec/runtime/memberships_flow_spec.rb.tt +145 -0
  12. data/lib/generators/seams/admin/admin_generator.rb +51 -4
  13. data/lib/generators/seams/admin/templates/README.md.tt +33 -3
  14. data/lib/generators/seams/admin/templates/app/controllers/admin/application_controller.rb.tt +156 -8
  15. data/lib/generators/seams/admin/templates/app/dashboards/admin/account_dashboard.rb.tt +9 -1
  16. data/lib/generators/seams/admin/templates/app/dashboards/admin/accounts_membership_dashboard.rb.tt +9 -1
  17. data/lib/generators/seams/admin/templates/app/dashboards/admin/identity_dashboard.rb.tt +6 -0
  18. data/lib/generators/seams/admin/templates/app/dashboards/admin/invitation_dashboard.rb.tt +7 -1
  19. data/lib/generators/seams/admin/templates/app/dashboards/admin/invoice_dashboard.rb.tt +6 -0
  20. data/lib/generators/seams/admin/templates/app/dashboards/admin/lifetime_pass_dashboard.rb.tt +6 -0
  21. data/lib/generators/seams/admin/templates/app/dashboards/admin/notification_dashboard.rb.tt +6 -0
  22. data/lib/generators/seams/admin/templates/app/dashboards/admin/notification_preference_dashboard.rb.tt +6 -0
  23. data/lib/generators/seams/admin/templates/app/dashboards/admin/plan_dashboard.rb.tt +6 -0
  24. data/lib/generators/seams/admin/templates/app/dashboards/admin/subscription_dashboard.rb.tt +6 -0
  25. data/lib/generators/seams/admin/templates/app/dashboards/admin/team_dashboard.rb.tt +12 -2
  26. data/lib/generators/seams/admin/templates/app/dashboards/admin/teams_membership_dashboard.rb.tt +7 -1
  27. data/lib/generators/seams/admin/templates/app/fields/admin/fields/belongs_to.rb.tt +12 -0
  28. data/lib/generators/seams/admin/templates/app/fields/admin/fields/dashboard_option.rb.tt +22 -0
  29. data/lib/generators/seams/admin/templates/app/fields/admin/fields/has_many.rb.tt +12 -0
  30. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/account_policy.rb.tt +4 -0
  31. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/accounts_membership_policy.rb.tt +6 -0
  32. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/application_policy.rb.tt +51 -10
  33. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/identity_policy.rb.tt +4 -0
  34. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/invitation_policy.rb.tt +6 -0
  35. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/invoice_policy.rb.tt +6 -0
  36. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/lifetime_pass_policy.rb.tt +6 -0
  37. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/notification_policy.rb.tt +6 -0
  38. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/notification_preference_policy.rb.tt +6 -0
  39. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/plan_policy.rb.tt +7 -0
  40. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/subscription_policy.rb.tt +6 -0
  41. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/team_policy.rb.tt +6 -0
  42. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/teams_membership_policy.rb.tt +6 -0
  43. data/lib/generators/seams/admin/templates/app/views/seams/admin/application/_index_header.html.erb.tt +57 -0
  44. data/lib/generators/seams/admin/templates/app/views/seams/admin/application/_navigation.html.erb.tt +14 -0
  45. data/lib/generators/seams/admin/templates/config/routes.rb.tt +24 -14
  46. data/lib/generators/seams/admin/templates/lib/concerns/authenticator.rb.tt +3 -0
  47. data/lib/generators/seams/admin/templates/lib/configuration.rb.tt +3 -1
  48. data/lib/generators/seams/admin/templates/spec/runtime/admin_boot_spec.rb.tt +4 -2
  49. data/lib/generators/seams/auth/add_oauth_provider/add_oauth_provider_generator.rb +1 -2
  50. data/lib/generators/seams/auth/auth_generator.rb +1 -2
  51. data/lib/generators/seams/auth/templates/app/controllers/oauth/callbacks_controller.rb.tt +3 -0
  52. data/lib/generators/seams/auth/templates/app/controllers/password_resets_controller.rb.tt +4 -0
  53. data/lib/generators/seams/auth/templates/app/controllers/registrations_controller.rb.tt +5 -0
  54. data/lib/generators/seams/auth/templates/app/controllers/sessions_controller.rb.tt +5 -0
  55. data/lib/generators/seams/auth/templates/db/migrate/create_auth_oauth_providers.rb.tt +5 -1
  56. data/lib/generators/seams/auth/templates/lib/concerns/authentication.rb.tt +12 -1
  57. data/lib/generators/seams/auth/templates/lib/engine.rb.tt +27 -0
  58. data/lib/generators/seams/billing/billing_generator.rb +4 -3
  59. data/lib/generators/seams/billing/templates/README.md.tt +26 -0
  60. data/lib/generators/seams/billing/templates/app/controllers/invoices_controller.rb.tt +2 -0
  61. data/lib/generators/seams/billing/templates/app/controllers/subscriptions_controller.rb.tt +2 -0
  62. data/lib/generators/seams/billing/templates/app/services/invoices/sync_service.rb.tt +8 -7
  63. data/lib/generators/seams/billing/templates/app/services/stripe_service.rb.tt +9 -7
  64. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_handler_base.rb.tt +18 -3
  65. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/subscription_handler_base.rb.tt +7 -21
  66. data/lib/generators/seams/billing/templates/lib/engine.rb.tt +15 -0
  67. data/lib/generators/seams/billing/templates/lib/gateways/stripe.rb.tt +10 -10
  68. data/lib/generators/seams/billing/templates/lib/stripe/client.rb.tt +40 -14
  69. data/lib/generators/seams/billing/templates/lib/stripe/payload.rb.tt +90 -0
  70. data/lib/generators/seams/billing/templates/lib/stripe/webhook_signature.rb.tt +11 -1
  71. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/charge_refunded.json.tt +1 -0
  72. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/checkout_session_completed.json.tt +1 -0
  73. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_created.json.tt +15 -4
  74. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_deleted.json.tt +7 -1
  75. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_trial_will_end.json.tt +18 -1
  76. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_updated.json.tt +26 -5
  77. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_created.json.tt +16 -2
  78. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_finalized.json.tt +16 -2
  79. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_paid.json.tt +17 -2
  80. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_payment_failed.json.tt +16 -2
  81. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_voided.json.tt +16 -2
  82. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/payment_intent_payment_failed.json.tt +1 -0
  83. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/payment_intent_succeeded.json.tt +1 -0
  84. data/lib/generators/seams/billing/templates/spec/gateways/stripe_spec.rb.tt +47 -1
  85. data/lib/generators/seams/billing/templates/spec/runtime/webhook_handlers_spec.rb.tt +39 -1
  86. data/lib/generators/seams/design/design_generator.rb +647 -0
  87. data/lib/generators/seams/design/templates/README.md.tt +129 -0
  88. data/lib/generators/seams/design/templates/app/assets/tailwind/_tokens.css +322 -0
  89. data/lib/generators/seams/design/templates/app/assets/tailwind/themes/_quire.css +42 -0
  90. data/lib/generators/seams/design/templates/app/controllers/design/dashboard_controller.rb.tt +30 -0
  91. data/lib/generators/seams/design/templates/app/controllers/design/guide_controller.rb.tt +28 -0
  92. data/lib/generators/seams/design/templates/app/form_builders/design/form_builder.rb.tt +90 -0
  93. data/lib/generators/seams/design/templates/app/helpers/design/ui_helper.rb.tt +37 -0
  94. data/lib/generators/seams/design/templates/app/views/design/dashboard/index.html.erb.tt +38 -0
  95. data/lib/generators/seams/design/templates/app/views/design/guide/index.html.erb.tt +19 -0
  96. data/lib/generators/seams/design/templates/app/views/layouts/application.html.erb.tt +61 -0
  97. data/lib/generators/seams/design/templates/app/views/layouts/design/guide.html.erb.tt +21 -0
  98. data/lib/generators/seams/design/templates/app/views/ui/_banner.html.erb.tt +10 -0
  99. data/lib/generators/seams/design/templates/app/views/ui/_breadcrumb.html.erb.tt +8 -0
  100. data/lib/generators/seams/design/templates/app/views/ui/_build_row.html.erb.tt +14 -0
  101. data/lib/generators/seams/design/templates/app/views/ui/_button.html.erb.tt +4 -0
  102. data/lib/generators/seams/design/templates/app/views/ui/_card.html.erb.tt +2 -0
  103. data/lib/generators/seams/design/templates/app/views/ui/_chapter_row.html.erb.tt +11 -0
  104. data/lib/generators/seams/design/templates/app/views/ui/_checkbox.html.erb.tt +12 -0
  105. data/lib/generators/seams/design/templates/app/views/ui/_counter.html.erb.tt +2 -0
  106. data/lib/generators/seams/design/templates/app/views/ui/_data_table.html.erb.tt +25 -0
  107. data/lib/generators/seams/design/templates/app/views/ui/_dialog.html.erb.tt +14 -0
  108. data/lib/generators/seams/design/templates/app/views/ui/_diff.html.erb.tt +12 -0
  109. data/lib/generators/seams/design/templates/app/views/ui/_drawer.html.erb.tt +7 -0
  110. data/lib/generators/seams/design/templates/app/views/ui/_empty.html.erb.tt +5 -0
  111. data/lib/generators/seams/design/templates/app/views/ui/_field.html.erb.tt +14 -0
  112. data/lib/generators/seams/design/templates/app/views/ui/_icon.html.erb.tt +2 -0
  113. data/lib/generators/seams/design/templates/app/views/ui/_icon_sprite.html.erb.tt +22 -0
  114. data/lib/generators/seams/design/templates/app/views/ui/_input_group.html.erb.tt +11 -0
  115. data/lib/generators/seams/design/templates/app/views/ui/_kbd.html.erb.tt +4 -0
  116. data/lib/generators/seams/design/templates/app/views/ui/_menu.html.erb.tt +18 -0
  117. data/lib/generators/seams/design/templates/app/views/ui/_meter.html.erb.tt +15 -0
  118. data/lib/generators/seams/design/templates/app/views/ui/_note.html.erb.tt +2 -0
  119. data/lib/generators/seams/design/templates/app/views/ui/_outline.html.erb.tt +9 -0
  120. data/lib/generators/seams/design/templates/app/views/ui/_pagination.html.erb.tt +12 -0
  121. data/lib/generators/seams/design/templates/app/views/ui/_panel.html.erb.tt +2 -0
  122. data/lib/generators/seams/design/templates/app/views/ui/_popover.html.erb.tt +2 -0
  123. data/lib/generators/seams/design/templates/app/views/ui/_radio.html.erb.tt +5 -0
  124. data/lib/generators/seams/design/templates/app/views/ui/_savestate.html.erb.tt +5 -0
  125. data/lib/generators/seams/design/templates/app/views/ui/_segmented.html.erb.tt +7 -0
  126. data/lib/generators/seams/design/templates/app/views/ui/_stepper.html.erb.tt +12 -0
  127. data/lib/generators/seams/design/templates/app/views/ui/_switch.html.erb.tt +6 -0
  128. data/lib/generators/seams/design/templates/app/views/ui/_tag.html.erb.tt +2 -0
  129. data/lib/generators/seams/design/templates/app/views/ui/_toast.html.erb.tt +12 -0
  130. data/lib/generators/seams/design/templates/app/views/ui/_toolbar.html.erb.tt +15 -0
  131. data/lib/generators/seams/design/templates/app/views/ui/previews/_banner.html.erb.tt +8 -0
  132. data/lib/generators/seams/design/templates/app/views/ui/previews/_breadcrumb.html.erb.tt +5 -0
  133. data/lib/generators/seams/design/templates/app/views/ui/previews/_build_row.html.erb.tt +6 -0
  134. data/lib/generators/seams/design/templates/app/views/ui/previews/_button.html.erb.tt +9 -0
  135. data/lib/generators/seams/design/templates/app/views/ui/previews/_card.html.erb.tt +12 -0
  136. data/lib/generators/seams/design/templates/app/views/ui/previews/_chapter_row.html.erb.tt +6 -0
  137. data/lib/generators/seams/design/templates/app/views/ui/previews/_checkbox.html.erb.tt +6 -0
  138. data/lib/generators/seams/design/templates/app/views/ui/previews/_counter.html.erb.tt +6 -0
  139. data/lib/generators/seams/design/templates/app/views/ui/previews/_data_table.html.erb.tt +14 -0
  140. data/lib/generators/seams/design/templates/app/views/ui/previews/_dialog.html.erb.tt +8 -0
  141. data/lib/generators/seams/design/templates/app/views/ui/previews/_diff.html.erb.tt +10 -0
  142. data/lib/generators/seams/design/templates/app/views/ui/previews/_drawer.html.erb.tt +5 -0
  143. data/lib/generators/seams/design/templates/app/views/ui/previews/_empty.html.erb.tt +5 -0
  144. data/lib/generators/seams/design/templates/app/views/ui/previews/_field.html.erb.tt +5 -0
  145. data/lib/generators/seams/design/templates/app/views/ui/previews/_input_group.html.erb.tt +5 -0
  146. data/lib/generators/seams/design/templates/app/views/ui/previews/_kbd.html.erb.tt +5 -0
  147. data/lib/generators/seams/design/templates/app/views/ui/previews/_menu.html.erb.tt +9 -0
  148. data/lib/generators/seams/design/templates/app/views/ui/previews/_meter.html.erb.tt +6 -0
  149. data/lib/generators/seams/design/templates/app/views/ui/previews/_note.html.erb.tt +2 -0
  150. data/lib/generators/seams/design/templates/app/views/ui/previews/_outline.html.erb.tt +8 -0
  151. data/lib/generators/seams/design/templates/app/views/ui/previews/_pagination.html.erb.tt +5 -0
  152. data/lib/generators/seams/design/templates/app/views/ui/previews/_panel.html.erb.tt +5 -0
  153. data/lib/generators/seams/design/templates/app/views/ui/previews/_popover.html.erb.tt +2 -0
  154. data/lib/generators/seams/design/templates/app/views/ui/previews/_radio.html.erb.tt +6 -0
  155. data/lib/generators/seams/design/templates/app/views/ui/previews/_savestate.html.erb.tt +4 -0
  156. data/lib/generators/seams/design/templates/app/views/ui/previews/_segmented.html.erb.tt +15 -0
  157. data/lib/generators/seams/design/templates/app/views/ui/previews/_stepper.html.erb.tt +6 -0
  158. data/lib/generators/seams/design/templates/app/views/ui/previews/_switch.html.erb.tt +5 -0
  159. data/lib/generators/seams/design/templates/app/views/ui/previews/_tag.html.erb.tt +8 -0
  160. data/lib/generators/seams/design/templates/app/views/ui/previews/_toast.html.erb.tt +6 -0
  161. data/lib/generators/seams/design/templates/app/views/ui/previews/_toolbar.html.erb.tt +11 -0
  162. data/lib/generators/seams/design/templates/lib/design/components.rb.tt +22 -0
  163. data/lib/generators/seams/design/templates/lib/design.rb.tt +18 -0
  164. data/lib/generators/seams/design/templates/lib/engine.rb.tt +38 -0
  165. data/lib/generators/seams/design/templates/lib/generators/design/component/component_generator.rb.tt +52 -0
  166. data/lib/generators/seams/design/templates/lib/generators/design/component/templates/component.html.erb.tt +5 -0
  167. data/lib/generators/seams/design/templates/lib/generators/design/component/templates/preview.html.erb.tt +3 -0
  168. data/lib/generators/seams/design/templates/spec/runtime/design_boot_spec.rb.tt +79 -0
  169. data/lib/generators/seams/design/templates/spec/runtime/form_builder_spec.rb.tt +97 -0
  170. data/lib/generators/seams/design/templates/spec/runtime/guide_spec.rb.tt +40 -0
  171. data/lib/generators/seams/design/templates/spec/runtime/ui_components_spec.rb.tt +70 -0
  172. data/lib/generators/seams/engine/engine_generator.rb +6 -0
  173. data/lib/generators/seams/engine/templates/Gemfile.tt +1 -1
  174. data/lib/generators/seams/engine/templates/app/application_controller.rb.tt +6 -0
  175. data/lib/generators/seams/engine/templates/rubocop.yml.tt +9 -0
  176. data/lib/generators/seams/install/install_generator.rb +72 -6
  177. data/lib/generators/seams/install/templates/Dockerfile.tt +2 -2
  178. data/lib/generators/seams/install/templates/bin_seams.tt +7 -4
  179. data/lib/generators/seams/install/templates/ci.yml.tt +18 -8
  180. data/lib/generators/seams/install/templates/deploy.yml.tt +4 -2
  181. data/lib/generators/seams/install/templates/doc/ARCHITECTURE.md.tt +1 -1
  182. data/lib/generators/seams/install/templates/docker-entrypoint.tt +4 -2
  183. data/lib/generators/seams/install/templates/herb.yml.tt +10 -0
  184. data/lib/generators/seams/install/templates/lefthook.yml.tt +25 -0
  185. data/lib/generators/seams/install/templates/ruby-version.tt +1 -1
  186. data/lib/generators/seams/install/templates/seams.rake.tt +13 -1
  187. data/lib/generators/seams/install/templates/strong_migrations.rb.tt +26 -0
  188. data/lib/generators/seams/notifications/notifications_generator.rb +3 -2
  189. data/lib/generators/seams/notifications/templates/app/controllers/preferences_controller.rb.tt +5 -13
  190. data/lib/generators/seams/notifications/templates/lib/engine.rb.tt +12 -0
  191. data/lib/generators/seams/notifications/templates/lib/notifications.rb.tt +1 -0
  192. data/lib/generators/seams/notifications/templates/lib/preferences.rb.tt +36 -0
  193. data/lib/generators/seams/permissions/permissions_generator.rb +97 -0
  194. data/lib/generators/seams/permissions/templates/config/initializers/seams_permissions.rb.tt +35 -0
  195. data/lib/generators/seams/teams/templates/app/controllers/invitations_controller.rb.tt +27 -11
  196. data/lib/generators/seams/teams/templates/app/controllers/teams_controller.rb.tt +5 -13
  197. data/lib/generators/seams/teams/templates/lib/concerns/authorization.rb.tt +2 -4
  198. data/lib/generators/seams/teams/templates/lib/engine.rb.tt +13 -0
  199. data/lib/seams/cli/list.rb +15 -0
  200. data/lib/seams/cli/quality.rb +46 -5
  201. data/lib/seams/cli/resolve.rb +9 -5
  202. data/lib/seams/cli/test_changed.rb +5 -1
  203. data/lib/seams/cli.rb +20 -0
  204. data/lib/seams/configuration.rb +24 -1
  205. data/lib/seams/cops/no_cross_engine_dependency.rb +39 -7
  206. data/lib/seams/cops/no_cross_engine_model_access.rb +79 -1
  207. data/lib/seams/event_registry.rb +19 -0
  208. data/lib/seams/events/adapter.rb +9 -0
  209. data/lib/seams/events/adapters/active_support.rb +4 -0
  210. data/lib/seams/events/publisher.rb +29 -0
  211. data/lib/seams/events.rb +1 -0
  212. data/lib/seams/generators/dummy_app_writer.rb +21 -0
  213. data/lib/seams/generators/follow_up_generator.rb +1 -2
  214. data/lib/seams/generators/host_injector.rb +9 -2
  215. data/lib/seams/generators/splicer.rb +2 -2
  216. data/lib/seams/observability/adapter.rb +22 -0
  217. data/lib/seams/observability.rb +6 -0
  218. data/lib/seams/permission_registry.rb +66 -0
  219. data/lib/seams/permissions.rb +120 -0
  220. data/lib/seams/version.rb +1 -1
  221. data/lib/seams.rb +7 -0
  222. metadata +107 -3
@@ -77,6 +77,24 @@ module Seams
77
77
  engine_path("lib/accounts/concerns/authorization.rb")
78
78
  end
79
79
 
80
+ # Phase 4 — the one tenant-facing controller the engine ships:
81
+ # the memberships role picker. Scoped to Accounts::Current.account
82
+ # and guarded by membership.manage.accounts (see the controller).
83
+ def create_controllers
84
+ template_unless_ejected "app/controllers/memberships_controller.rb.tt",
85
+ engine_path("app/controllers/accounts/memberships_controller.rb")
86
+ end
87
+
88
+ # Phase 4 — bare-bones view so the role picker renders out of the
89
+ # box. Plain semantic ERB: NO design-engine (ui_*) helpers, so the
90
+ # screen works whether or not the opt-in design engine is
91
+ # installed. Hosts override by dropping a file at
92
+ # app/views/accounts/memberships/index.html.erb in their own tree.
93
+ def create_views
94
+ template_unless_ejected "app/views/memberships/index.html.erb.tt",
95
+ engine_path("app/views/accounts/memberships/index.html.erb")
96
+ end
97
+
80
98
  def create_migrations
81
99
  template "db/migrate/create_accounts.rb.tt",
82
100
  engine_path("db/migrate/#{timestamp(0)}_create_accounts.rb")
@@ -154,9 +172,19 @@ module Seams
154
172
  end
155
173
 
156
174
  def create_runtime_specs
157
- # Currently a single runtime boot spec covers events, schema,
158
- # create_with_owner, and Accounts::Current. Split into multiple
159
- # files in a later phase if the file grows past ~120 lines.
175
+ # The boot spec (written in create_dummy_app) covers events,
176
+ # schema, create_with_owner, and Accounts::Current. Phase 4 adds
177
+ # a request-style behavioural spec for the memberships role
178
+ # picker: the happy path plus each of the three escalation
179
+ # safeguards.
180
+ template "spec/runtime/memberships_flow_spec.rb.tt",
181
+ engine_path("spec/runtime/accounts_memberships_flow_spec.rb")
182
+ # Wave 11B / #37 — request-layer regression for the permission
183
+ # tiers the Accounts::Authorization concern enforces:
184
+ # deny-by-role, allow-by-role, the platform-staff bypass, and the
185
+ # cross-tenant guarantee that the ACTIVE membership decides.
186
+ template "spec/runtime/authorization_spec.rb.tt",
187
+ engine_path("spec/runtime/accounts_authorization_spec.rb")
160
188
  end
161
189
 
162
190
  def wire_into_host
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Accounts
4
+ # Tenant-facing screen where an account admin changes a member's
5
+ # role. Both actions are scoped to `Accounts::Current.account` and
6
+ # guarded by the `membership.manage.accounts` ability — the
7
+ # Authorization concern resolves that against the current
8
+ # membership's role and applies the platform-staff bypass.
9
+ #
10
+ # `#update` enforces three hard safeguards against privilege
11
+ # escalation (see the guard chain). Each one redirects back with an
12
+ # alert rather than silently no-opping, so an admin who hits a wall
13
+ # is told why.
14
+ class MembershipsController < ApplicationController
15
+ include Accounts::Authorization
16
+
17
+ # owner > admin > member. The screen never assigns `owner` (it is
18
+ # protected — see safeguard 1) nor `system` (an internal,
19
+ # human-less actor), so the only roles a picker may offer are
20
+ # `admin` and `member`.
21
+ ROLE_RANK = { "member" => 1, "admin" => 2, "owner" => 3 }.freeze
22
+ ASSIGNABLE_ROLES = %w[admin member].freeze
23
+
24
+ before_action -> { authorize_permission!("membership.manage.accounts") }
25
+ before_action :set_membership, only: %i[update]
26
+
27
+ helper_method :own_membership?, :assignable_roles
28
+
29
+ def index
30
+ @memberships = Accounts::Current.account.memberships.active.order(:created_at)
31
+ end
32
+
33
+ def update
34
+ violation = escalation_violation
35
+ return deny(violation) if violation
36
+
37
+ @membership.update!(role: requested_role)
38
+ redirect_to memberships_path, notice: "Role updated."
39
+ end
40
+
41
+ private
42
+
43
+ # The safeguard message that blocks this role change, or nil when
44
+ # the change is allowed. Ordered so each safeguard has a single,
45
+ # independently testable trigger:
46
+ #
47
+ # 2. a user cannot change their own role;
48
+ # 1a. an owner's role cannot be changed (no demotion);
49
+ # --. `system` / unknown roles are never assignable;
50
+ # 3. a user cannot assign a role senior to their own (owner >
51
+ # admin > member) — this is what stops an admin minting an
52
+ # owner;
53
+ # 1b. `owner` is never assignable, even for an acting owner who
54
+ # would otherwise clear the senior check.
55
+ def escalation_violation
56
+ return "You cannot change your own role." if own_membership?(@membership)
57
+ return "The owner's role cannot be changed through this screen." if @membership.owner?
58
+ return "That role cannot be assigned." unless ROLE_RANK.key?(requested_role)
59
+ return "You cannot assign a role senior to your own." unless actor_can_assign?(requested_role)
60
+ return "The owner role cannot be assigned here." unless ASSIGNABLE_ROLES.include?(requested_role)
61
+
62
+ nil
63
+ end
64
+
65
+ # Always scoped to the current account so an admin can never reach
66
+ # a membership in another tenant by guessing an id.
67
+ def set_membership
68
+ @membership = Accounts::Current.account.memberships.find(params[:id])
69
+ end
70
+
71
+ # Role extracted on its own (never via permit) so the params shape
72
+ # reads as "we coerce the role ourselves", the same Brakeman-safe
73
+ # convention the teams engine uses.
74
+ def requested_role
75
+ params.dig(:membership, :role).to_s
76
+ end
77
+
78
+ def own_membership?(membership)
79
+ actor = Accounts::Current.membership
80
+ actor.present? && actor.id == membership.id
81
+ end
82
+
83
+ # The acting role may assign a target role only when the target is
84
+ # not senior to the actor's own (owner > admin > member). An actor
85
+ # with no resolved membership (rank 0) can assign nothing.
86
+ def actor_can_assign?(role)
87
+ actor_rank = ROLE_RANK[current_permission_role].to_i
88
+ target_rank = ROLE_RANK[role].to_i
89
+ target_rank.positive? && target_rank <= actor_rank
90
+ end
91
+
92
+ def assignable_roles
93
+ ASSIGNABLE_ROLES
94
+ end
95
+
96
+ def deny(message)
97
+ redirect_to memberships_path, alert: message
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,52 @@
1
+ <%%# Members of the current account with a per-member role picker. %>
2
+ <%%# Owners and the acting member's own row are locked — the controller %>
3
+ <%%# enforces the same rules server-side; the view just doesn't offer a %>
4
+ <%%# control it knows will be refused. %>
5
+ <%%# %>
6
+ <%%# Plain, semantic ERB on purpose: the design engine is opt-in and may %>
7
+ <%%# not be installed, so this view must NOT call ui_* helpers (they would %>
8
+ <%%# raise when design is absent). Sensible class names let the design %>
9
+ <%%# system style it if present, with zero hard dependency. %>
10
+ <h1 class="accounts-memberships__title">Members</h1>
11
+
12
+ <%%= content_tag :p, flash[:notice], class: "accounts-memberships__notice" if flash[:notice] %>
13
+ <%%= content_tag :p, flash[:alert], class: "accounts-memberships__alert" if flash[:alert] %>
14
+
15
+ <table class="accounts-memberships">
16
+ <thead>
17
+ <tr>
18
+ <th scope="col">Member</th>
19
+ <th scope="col">Role</th>
20
+ <th scope="col">Change role</th>
21
+ </tr>
22
+ </thead>
23
+ <tbody>
24
+ <%% @memberships.each do |membership| %>
25
+ <tr class="accounts-memberships__row">
26
+ <td class="accounts-memberships__name"><%%= membership.name %></td>
27
+ <td class="accounts-memberships__role"><%%= membership.role.titleize %></td>
28
+ <td class="accounts-memberships__actions">
29
+ <%% if membership.owner? || own_membership?(membership) %>
30
+ <span class="accounts-memberships__locked">Locked</span>
31
+ <%% else %>
32
+ <%%= form_with url: membership_path(membership), method: :patch,
33
+ class: "accounts-memberships__form" do %>
34
+ <label class="accounts-memberships__label"
35
+ for="membership_role_<%%= membership.id %>">Role</label>
36
+ <select id="membership_role_<%%= membership.id %>"
37
+ name="membership[role]"
38
+ class="accounts-memberships__select">
39
+ <%% assignable_roles.each do |role| %>
40
+ <option value="<%%= role %>" <%%= "selected" if membership.role == role %>>
41
+ <%%= role.titleize %>
42
+ </option>
43
+ <%% end %>
44
+ </select>
45
+ <button type="submit" class="accounts-memberships__submit">Update</button>
46
+ <%% end %>
47
+ <%% end %>
48
+ </td>
49
+ </tr>
50
+ <%% end %>
51
+ </tbody>
52
+ </table>
@@ -1,8 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Intentionally empty. The Accounts engine ships no controllers in
4
- # Wave 9 — hosts wire their own account-creation flows; the engine
5
- # provides models + concerns. Future waves may add a thin controller
6
- # surface.
3
+ # Wave 11 (Phase 4): the accounts engine ships a single tenant-facing
4
+ # surface — the memberships role picker (index + update), scoped to
5
+ # Accounts::Current.account and guarded by membership.manage.accounts.
6
+ # Account-creation flows still live in the host.
7
7
  Accounts::Engine.routes.draw do
8
+ # Follow-up generators that add admin-only or token-only routes splice them here, ahead of the canonical memberships resource.
9
+ # seams:insertion-point accounts.routes.before_memberships
10
+ resources :memberships, only: %i[index update]
11
+
12
+ # Follow-up generators that add new top-level accounts routes splice them here.
13
+ # seams:insertion-point accounts.routes.after_memberships
8
14
  end
@@ -85,8 +85,27 @@ module Accounts
85
85
  # bypass the per-account scope (e.g. impersonation, cross-account
86
86
  # search). Distinct from `ensure_admin`.
87
87
  def ensure_staff
88
- identity = defined?(Auth::Current) ? Auth::Current.identity : nil
89
- head :forbidden unless identity&.staff?
88
+ head :forbidden unless staff_actor?
89
+ end
90
+
91
+ # Per-account permission guard. Resolves the current membership's
92
+ # role for `Accounts::Current.account` and asks the gem registry
93
+ # whether that role holds the given ability code (the
94
+ # `resource.action.engine` strings each engine registers). Platform
95
+ # staff (`Auth::Identity#staff?`) are let through — the request-layer
96
+ # bypass `Seams::Permissions.can?` deliberately leaves to callers.
97
+ # Denies with 403 otherwise.
98
+ #
99
+ # before_action -> { authorize_permission!("invoice.read.billing") }
100
+ #
101
+ # The role is read from the per-request membership, never a global,
102
+ # so the same Identity can hold different powers in different
103
+ # accounts.
104
+ def authorize_permission!(ability)
105
+ return if staff_actor?
106
+ return if Seams::Permissions.can?(role: current_permission_role, ability: ability)
107
+
108
+ head :forbidden
90
109
  end
91
110
 
92
111
  def redirect_existing_membership
@@ -101,5 +120,19 @@ module Accounts
101
120
  def authorization_redirect_path
102
121
  Accounts.configuration.after_account_create_url
103
122
  end
123
+
124
+ # Role of the membership resolved for the current account/identity,
125
+ # or nil when there is no current membership (which denies, since
126
+ # no role holds any ability).
127
+ def current_permission_role
128
+ Accounts::Current.membership&.role
129
+ end
130
+
131
+ # Platform-admin actor? (Identity#staff?). Shared by `ensure_staff`
132
+ # and the `authorize_permission!` bypass.
133
+ def staff_actor?
134
+ identity = defined?(Auth::Current) ? Auth::Current.identity : nil
135
+ identity&.staff? || false
136
+ end
104
137
  end
105
138
  end
@@ -18,6 +18,19 @@ module Accounts
18
18
  # seams:insertion-point accounts.engine.events
19
19
  end
20
20
 
21
+ initializer "accounts.register_abilities" do
22
+ # Ability codes this engine owns, registered exactly like events
23
+ # (resource.action.engine). `read` is the member-level view;
24
+ # `manage` is the admin-level mutate. Host grant maps reference
25
+ # these codes; see Seams::Permissions::DEFAULT_GRANTS.
26
+ Seams::PermissionRegistry.register("account.read.accounts", owned_by: "Accounts")
27
+ Seams::PermissionRegistry.register("account.manage.accounts", owned_by: "Accounts")
28
+ Seams::PermissionRegistry.register("membership.read.accounts", owned_by: "Accounts")
29
+ Seams::PermissionRegistry.register("membership.manage.accounts", owned_by: "Accounts")
30
+ # Follow-up generators that ship new accounts abilities register them here.
31
+ # seams:insertion-point accounts.engine.abilities
32
+ end
33
+
21
34
  initializer "accounts.append_migrations" do |app|
22
35
  unless app.root == root
23
36
  config.paths["db/migrate"].expanded.each do |expanded_path|
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rails_helper"
4
+
5
+ # Request-layer regression for Accounts::Authorization#authorize_permission!
6
+ # — the permission tiers the gem's role hierarchy resolves through at the
7
+ # controller boundary. The role-picker behaviour (the escalation
8
+ # safeguards) lives in accounts_memberships_flow_spec; this spec pins the
9
+ # three authorisation tiers the concern enforces BEFORE the action runs:
10
+ #
11
+ # 1. deny-by-role: a membership whose role lacks the ability -> 403.
12
+ # 2. allow-by-role: a membership whose role holds the ability -> through.
13
+ # 3. staff bypass: a platform-staff Identity is let through regardless of
14
+ # the membership role (the request-layer bypass the gem deliberately
15
+ # leaves to the caller, not Seams::Permissions.can?).
16
+ #
17
+ # Cross-tenant isolation: the decision is read from the ACTIVE membership
18
+ # (Accounts::Current.membership.role), never the Identity, so the same
19
+ # person can be admin in one account and member in another and get
20
+ # different powers per request. Flipping the active membership flips the
21
+ # decision — pinned below.
22
+ #
23
+ # The memberships controller guards every action with
24
+ # `authorize_permission!("membership.manage.accounts")`, so its index is a
25
+ # faithful probe of the gate. As in accounts_memberships_flow_spec, the
26
+ # per-request Current surface is stubbed with method stubs (they survive
27
+ # the executor reset that clears CurrentAttributes values around requests).
28
+ RSpec.describe "Accounts::Authorization permission tiers", type: :request do
29
+ def act_as(membership:, staff: false)
30
+ allow(Accounts::Current).to receive(:account).and_return(account)
31
+ allow(Accounts::Current).to receive(:membership).and_return(membership)
32
+ identity = staff ? Struct.new(:staff?).new(true) : nil
33
+ allow(Auth::Current).to receive(:identity).and_return(identity)
34
+ end
35
+
36
+ let(:owner_identity) do
37
+ Auth::Identity.create!(email: "owner-#{SecureRandom.hex(4)}@example.com", password: "verysecret")
38
+ end
39
+
40
+ let(:account) do
41
+ Accounts::Account.create_with_owner(
42
+ account: { name: "Acme #{SecureRandom.hex(2)}" },
43
+ owner: Struct.new(:identity, :name).new(owner_identity, "Olive Owner")
44
+ )
45
+ end
46
+
47
+ let(:admin_membership) { build_membership("admin", "Adam Admin") }
48
+ let(:member_membership) { build_membership("member", "Mia Member") }
49
+
50
+ def build_membership(role, name)
51
+ identity = Auth::Identity.create!(
52
+ email: "#{role}-#{SecureRandom.hex(4)}@example.com", password: "verysecret"
53
+ )
54
+ account.memberships.create!(role: role, name: name, identity_id: identity.id, active: true)
55
+ end
56
+
57
+ def request_guarded_action
58
+ get "/accounts/memberships"
59
+ end
60
+
61
+ describe "tier 1: deny-by-role" do
62
+ it "forbids a plain member (member does not hold membership.manage.accounts)" do
63
+ act_as(membership: member_membership)
64
+
65
+ request_guarded_action
66
+
67
+ expect(response).to have_http_status(:forbidden)
68
+ end
69
+ end
70
+
71
+ describe "tier 2: allow-by-role" do
72
+ it "lets an admin through (admin holds membership.manage.accounts)" do
73
+ act_as(membership: admin_membership)
74
+
75
+ request_guarded_action
76
+
77
+ expect(response).to have_http_status(:ok)
78
+ end
79
+ end
80
+
81
+ describe "tier 3: staff bypass" do
82
+ it "lets a platform-staff Identity through despite a member-role membership" do
83
+ act_as(membership: member_membership, staff: true)
84
+
85
+ request_guarded_action
86
+
87
+ expect(response).to have_http_status(:ok)
88
+ end
89
+ end
90
+
91
+ describe "cross-tenant isolation: the active membership decides, not the identity" do
92
+ it "flips the decision when the active membership role changes" do
93
+ act_as(membership: member_membership)
94
+ request_guarded_action
95
+ expect(response).to have_http_status(:forbidden)
96
+
97
+ act_as(membership: admin_membership)
98
+ request_guarded_action
99
+ expect(response).to have_http_status(:ok)
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rails_helper"
4
+
5
+ # Behavioural request specs for the tenant-facing memberships role
6
+ # picker: the happy path plus each of the three privilege-escalation
7
+ # safeguards the controller enforces.
8
+ #
9
+ # The Rack stack resets ActiveSupport::CurrentAttributes around every
10
+ # request, so attribute values set in the spec would be wiped before
11
+ # the controller ran. Instead we stub the per-request Current surface
12
+ # (the account + the acting membership) and the Auth identity the staff
13
+ # bypass reads — method stubs survive the executor reset that clears
14
+ # attribute values.
15
+ RSpec.describe "Accounts memberships role picker", type: :request do
16
+ def act_as(membership:, staff: false)
17
+ allow(Accounts::Current).to receive(:account).and_return(account)
18
+ allow(Accounts::Current).to receive(:membership).and_return(membership)
19
+ identity = staff ? Struct.new(:staff?).new(true) : nil
20
+ allow(Auth::Current).to receive(:identity).and_return(identity)
21
+ end
22
+
23
+ let(:owner_identity) do
24
+ Auth::Identity.create!(email: "owner-#{SecureRandom.hex(4)}@example.com", password: "verysecret")
25
+ end
26
+
27
+ let(:account) do
28
+ Accounts::Account.create_with_owner(
29
+ account: { name: "Acme #{SecureRandom.hex(2)}" },
30
+ owner: Struct.new(:identity, :name).new(owner_identity, "Olive Owner")
31
+ )
32
+ end
33
+
34
+ let(:owner_membership) { account.memberships.find_by(role: "owner") }
35
+ let(:admin_membership) { build_membership("admin", "Adam Admin") }
36
+ let(:member_membership) { build_membership("member", "Mia Member") }
37
+
38
+ def build_membership(role, name)
39
+ identity = Auth::Identity.create!(
40
+ email: "#{role}-#{SecureRandom.hex(4)}@example.com", password: "verysecret"
41
+ )
42
+ account.memberships.create!(role: role, name: name, identity_id: identity.id, active: true)
43
+ end
44
+
45
+ describe "index" do
46
+ it "lists the current account's active memberships" do
47
+ act_as(membership: admin_membership)
48
+ member_membership
49
+
50
+ get "/accounts/memberships"
51
+
52
+ expect(response).to have_http_status(:ok)
53
+ expect(response.body).to include("Mia Member")
54
+ end
55
+ end
56
+
57
+ describe "happy path" do
58
+ it "an admin promotes a member to admin" do
59
+ act_as(membership: admin_membership)
60
+ target = member_membership
61
+
62
+ patch "/accounts/memberships/#{target.id}", params: { membership: { role: "admin" } }
63
+
64
+ expect(response).to have_http_status(:found)
65
+ expect(flash[:notice]).to be_present
66
+ expect(target.reload.role).to eq("admin")
67
+ end
68
+
69
+ it "an admin demotes another admin to member" do
70
+ act_as(membership: admin_membership)
71
+ target = build_membership("admin", "Other Admin")
72
+
73
+ patch "/accounts/memberships/#{target.id}", params: { membership: { role: "member" } }
74
+
75
+ expect(response).to have_http_status(:found)
76
+ expect(target.reload.role).to eq("member")
77
+ end
78
+ end
79
+
80
+ describe "safeguard 1: the owner role cannot be changed" do
81
+ it "refuses to demote an owner" do
82
+ act_as(membership: admin_membership)
83
+
84
+ patch "/accounts/memberships/#{owner_membership.id}", params: { membership: { role: "member" } }
85
+
86
+ expect(flash[:alert]).to be_present
87
+ expect(owner_membership.reload.role).to eq("owner")
88
+ end
89
+
90
+ it "refuses to promote anyone to owner, even for an acting owner" do
91
+ act_as(membership: owner_membership)
92
+ target = member_membership
93
+
94
+ patch "/accounts/memberships/#{target.id}", params: { membership: { role: "owner" } }
95
+
96
+ expect(flash[:alert]).to be_present
97
+ expect(target.reload.role).to eq("member")
98
+ end
99
+ end
100
+
101
+ describe "safeguard 2: a user cannot change their own role" do
102
+ it "refuses to change the acting membership" do
103
+ act_as(membership: admin_membership)
104
+
105
+ patch "/accounts/memberships/#{admin_membership.id}", params: { membership: { role: "member" } }
106
+
107
+ expect(flash[:alert]).to match(/your own role/i)
108
+ expect(admin_membership.reload.role).to eq("admin")
109
+ end
110
+ end
111
+
112
+ describe "safeguard 3: a user cannot assign a role senior to their own" do
113
+ it "refuses an admin assigning owner (senior to admin)" do
114
+ act_as(membership: admin_membership)
115
+ target = member_membership
116
+
117
+ patch "/accounts/memberships/#{target.id}", params: { membership: { role: "owner" } }
118
+
119
+ expect(flash[:alert]).to match(/senior/i)
120
+ expect(target.reload.role).to eq("member")
121
+ end
122
+
123
+ it "never assigns the system role through the screen" do
124
+ act_as(membership: admin_membership)
125
+ target = member_membership
126
+
127
+ patch "/accounts/memberships/#{target.id}", params: { membership: { role: "system" } }
128
+
129
+ expect(flash[:alert]).to be_present
130
+ expect(target.reload.role).to eq("member")
131
+ end
132
+ end
133
+
134
+ describe "authorization" do
135
+ it "forbids a plain member from changing roles" do
136
+ act_as(membership: member_membership)
137
+ target = build_membership("member", "Another Member")
138
+
139
+ patch "/accounts/memberships/#{target.id}", params: { membership: { role: "admin" } }
140
+
141
+ expect(response).to have_http_status(:forbidden)
142
+ expect(target.reload.role).to eq("member")
143
+ end
144
+ end
145
+ end
@@ -51,7 +51,7 @@ module Seams
51
51
  # extra column.
52
52
  #
53
53
  # Run with: bin/rails generate seams:admin
54
- # rubocop:disable Metrics/ClassLength
54
+ # rubocop:disable-next Metrics/ClassLength
55
55
  class AdminGenerator < Rails::Generators::Base
56
56
  include Seams::Generators::HostInjector
57
57
  include Seams::Generators::EjectAware
@@ -69,7 +69,7 @@ module Seams
69
69
  #
70
70
  # The order here is the order routes + dashboards appear in
71
71
  # the generated files; Identity comes first so the engine root
72
- # (`root to: "admin/identities#index"`) lands on something
72
+ # (`root to: "/admin/identities#index"`) lands on something
73
73
  # meaningful.
74
74
  DASHBOARD_MODELS = [
75
75
  # [dashboard_basename, model_class, owning_engine]
@@ -87,8 +87,11 @@ module Seams
87
87
  ["lifetime_pass", "Billing::LifetimePass", "billing"]
88
88
  ].freeze
89
89
 
90
+ # The base engine would mount `Admin::Engine` and write an
91
+ # `Admin.configure` initializer, but this engine's constants live
92
+ # under `Seams::Admin`. wire_into_host mounts the real class.
90
93
  def create_base_engine
91
- EngineGenerator.start([ENGINE_NAME], destination_root: destination_root)
94
+ EngineGenerator.start([ENGINE_NAME, "--skip-host-wiring"], destination_root: destination_root)
92
95
  end
93
96
 
94
97
  def overwrite_engine_entry_point
@@ -186,6 +189,29 @@ module Seams
186
189
  end
187
190
  end
188
191
 
192
+ # Administrate partial overrides: navigation built from this
193
+ # engine's routes, and an index header whose New link does not
194
+ # depend on the dashboard class name. See each partial's header.
195
+ def create_view_overrides
196
+ %w[_navigation _index_header].each do |partial|
197
+ template_unless_ejected(
198
+ "app/views/seams/admin/application/#{partial}.html.erb.tt",
199
+ engine_path("app/views/seams/admin/application/#{partial}.html.erb")
200
+ )
201
+ end
202
+ end
203
+
204
+ # Association fields that name their dashboard explicitly; see
205
+ # templates/app/fields/admin/fields/dashboard_option.rb.tt.
206
+ def create_fields
207
+ %w[dashboard_option belongs_to has_many].each do |field|
208
+ template_unless_ejected(
209
+ "app/fields/admin/fields/#{field}.rb.tt",
210
+ engine_path("app/fields/admin/fields/#{field}.rb")
211
+ )
212
+ end
213
+ end
214
+
189
215
  # Phase 2: emit one dashboard + one controller per entry in
190
216
  # DASHBOARD_MODELS. `template_unless_ejected` so a host that
191
217
  # ejects an individual dashboard (e.g. to restyle the Identity
@@ -221,6 +247,7 @@ module Seams
221
247
  write_auth_current_stub
222
248
  write_accounts_current_stub
223
249
  write_dummy_model_stubs
250
+ write_dummy_ability_registrations
224
251
  amend_dummy_application_rb
225
252
  rewrite_dummy_routes_for_namespaced_engine
226
253
  end
@@ -424,6 +451,27 @@ module Seams
424
451
  # `class Foo < ApplicationRecord; self.table_name = "..."; end`
425
452
  # — no associations, no validations, no callbacks. The dummy
426
453
  # schema (see #dummy_schema) ships the matching tables.
454
+ # The tenant policies resolve through Seams::Permissions.can?, which
455
+ # raises for an ability no engine registered. In a host the owning
456
+ # engines register them at boot; the dummy app only stubs those
457
+ # engines' models, so register their default abilities here.
458
+ def write_dummy_ability_registrations
459
+ path = File.join(destination_root, "engines", ENGINE_NAME,
460
+ "spec/dummy/config/initializers/seams_abilities.rb")
461
+ FileUtils.mkdir_p(File.dirname(path))
462
+ File.write(path, <<~RUBY)
463
+ # frozen_string_literal: true
464
+
465
+ # Stand-in for the ability registrations the auth, accounts,
466
+ # teams, billing, and notifications engines make at boot.
467
+ Seams::Permissions::DEFAULT_GRANTS.values.flatten.uniq.each do |ability|
468
+ next if Seams::PermissionRegistry.registered?(ability)
469
+
470
+ Seams::PermissionRegistry.register(ability, owned_by: ability.split(".").last.capitalize)
471
+ end
472
+ RUBY
473
+ end
474
+
427
475
  def write_dummy_model_stubs
428
476
  DUMMY_MODEL_STUBS.each do |relative_path, body|
429
477
  full = File.join(destination_root, "engines", ENGINE_NAME,
@@ -847,6 +895,5 @@ module Seams
847
895
  SCHEMA
848
896
  end
849
897
  end
850
- # rubocop:enable Metrics/ClassLength
851
898
  end
852
899
  end