spree_cm_commissioner 2.13.3 → 2.13.4

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 (283) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +0 -1
  3. data/Gemfile.lock +1 -1
  4. data/app/assets/config/spree_cm_commissioner_manifest.js +0 -1
  5. data/app/assets/javascripts/spree_cm_commissioner/backend.js +0 -8
  6. data/app/assets/stylesheets/spree_cm_commissioner/backend/commissioner_admin.css.scss +0 -1
  7. data/app/controllers/concerns/spree_cm_commissioner/content_cachable.rb +10 -8
  8. data/app/controllers/spree/admin/base_controller_decorator.rb +0 -12
  9. data/app/controllers/spree/admin/oauth_application_api_keys_controller.rb +118 -0
  10. data/app/controllers/spree/admin/oauth_applications_controller.rb +10 -0
  11. data/app/controllers/spree/api/v2/storefront/dynamic_field_options_controller.rb +12 -0
  12. data/app/controllers/spree/api/v2/storefront/trip_search_controller.rb +0 -5
  13. data/app/controllers/spree/api/v2/tenant/trip_search_controller.rb +0 -5
  14. data/app/helpers/spree/{billing → admin}/navigation_helper_decorator.rb +2 -9
  15. data/app/interactors/spree_cm_commissioner/user_acleda_bank_web_app_authenticator.rb +38 -0
  16. data/app/interactors/spree_cm_commissioner/user_wing_bank_web_app_authenticator.rb +39 -0
  17. data/app/models/concerns/spree_cm_commissioner/oauth_application/mini_app.rb +72 -0
  18. data/app/models/spree_cm_commissioner/dynamic_field.rb +12 -1
  19. data/app/models/spree_cm_commissioner/dynamic_field_option.rb +11 -0
  20. data/app/models/spree_cm_commissioner/guest.rb +8 -0
  21. data/app/models/spree_cm_commissioner/mini_app_provider/acleda_bank.rb +28 -0
  22. data/app/models/spree_cm_commissioner/mini_app_provider/base.rb +219 -0
  23. data/app/models/spree_cm_commissioner/mini_app_provider/vattanac_bank.rb +6 -0
  24. data/app/models/spree_cm_commissioner/mini_app_provider/wing_bank.rb +30 -0
  25. data/app/models/spree_cm_commissioner/oauth_application_decorator.rb +7 -0
  26. data/app/models/spree_cm_commissioner/product_dynamic_field.rb +9 -0
  27. data/app/models/spree_cm_commissioner/user_identity_provider.rb +1 -1
  28. data/app/services/spree_cm_commissioner/mini_app_provider/api_key_history.rb +128 -0
  29. data/app/services/spree_cm_commissioner/mini_app_provider/ip_rules.rb +26 -0
  30. data/app/services/spree_cm_commissioner/mini_app_provider/session_gate.rb +79 -0
  31. data/app/services/spree_cm_commissioner/mini_app_provider/session_throttle.rb +66 -0
  32. data/app/services/spree_cm_commissioner/payment_method_type_mapper.rb +3 -1
  33. data/app/services/spree_cm_commissioner/rate_limiter/result.rb +20 -0
  34. data/app/services/spree_cm_commissioner/rate_limiter.rb +62 -0
  35. data/app/services/spree_cm_commissioner/seeds/user_usernames.rb +3 -1
  36. data/app/services/spree_cm_commissioner/ticket_transfers/eligibility_checker.rb +5 -6
  37. data/app/services/spree_cm_commissioner/ticket_transfers/preview_rule.rb +3 -5
  38. data/app/services/spree_cm_commissioner/user_authenticator.rb +12 -2
  39. data/app/services/spree_cm_commissioner/wing_mini_app/config.rb +44 -0
  40. data/app/services/spree_cm_commissioner/wing_mini_app/find_or_create_user.rb +89 -0
  41. data/app/services/spree_cm_commissioner/wing_mini_app/profile_verifier.rb +65 -0
  42. data/app/views/spree/admin/guests/_table.html.erb +4 -0
  43. data/app/views/spree/admin/oauth_applications/_api_key_minted_modal.html.erb +71 -0
  44. data/app/views/spree/admin/oauth_applications/_api_key_modal.html.erb +68 -0
  45. data/app/views/spree/admin/oauth_applications/_api_key_restore_list.html.erb +40 -0
  46. data/app/views/spree/admin/oauth_applications/_api_key_restore_modal.html.erb +72 -0
  47. data/app/views/spree/admin/oauth_applications/_api_key_revoke_modal.html.erb +54 -0
  48. data/app/views/spree/admin/oauth_applications/_form.html.erb +152 -0
  49. data/app/views/spree/admin/oauth_applications/edit.html.erb +36 -0
  50. data/app/views/spree/admin/shared/_ticket_transfer_details.html.erb +8 -2
  51. data/config/locales/en.yml +12 -0
  52. data/config/routes.rb +15 -113
  53. data/db/migrate/20260928070109_add_provider_and_api_key_to_spree_oauth_applications.rb +11 -0
  54. data/lib/spree_cm_commissioner/test_helper/factories/vendor_factory.rb +16 -2
  55. data/lib/spree_cm_commissioner/version.rb +1 -1
  56. data/lib/spree_cm_commissioner.rb +8 -1
  57. metadata +28 -229
  58. data/app/assets/javascripts/spree_cm_commissioner/address_hide_form.js +0 -17
  59. data/app/assets/javascripts/spree_cm_commissioner/location_checkbox_manager.js +0 -45
  60. data/app/assets/javascripts/spree_cm_commissioner/report_hide_filter.js +0 -37
  61. data/app/assets/javascripts/spree_cm_commissioner/role_form.js +0 -51
  62. data/app/assets/javascripts/spree_cm_commissioner/selected_field_filter_origin_destination.js +0 -37
  63. data/app/assets/javascripts/spree_cm_commissioner/trip_stop_selection.js +0 -41
  64. data/app/assets/javascripts/spree_cm_commissioner/update_popup_confirmation.js +0 -42
  65. data/app/assets/javascripts/spree_cm_commissioner/vehicle_seat.js +0 -291
  66. data/app/assets/stylesheets/spree_cm_commissioner/backend/invoice.scss +0 -92
  67. data/app/assets/stylesheets/spree_cm_commissioner/backend/vehicle_seat.scss +0 -28
  68. data/app/controllers/concerns/spree/billing/order_parents_concern.rb +0 -31
  69. data/app/controllers/concerns/spree/billing/payment_creatable.rb +0 -64
  70. data/app/controllers/concerns/spree/billing/payment_fireable.rb +0 -35
  71. data/app/controllers/concerns/spree_cm_commissioner/billing/role_authorization.rb +0 -53
  72. data/app/controllers/spree/billing/addresses_controller.rb +0 -32
  73. data/app/controllers/spree/billing/adjustments_controller.rb +0 -72
  74. data/app/controllers/spree/billing/base_controller.rb +0 -82
  75. data/app/controllers/spree/billing/businesses_controller.rb +0 -135
  76. data/app/controllers/spree/billing/customers_controller.rb +0 -107
  77. data/app/controllers/spree/billing/errors_controller.rb +0 -14
  78. data/app/controllers/spree/billing/invoice_controller.rb +0 -36
  79. data/app/controllers/spree/billing/option_types_controller.rb +0 -32
  80. data/app/controllers/spree/billing/orders_controller.rb +0 -90
  81. data/app/controllers/spree/billing/payment_qrcodes_controller.rb +0 -14
  82. data/app/controllers/spree/billing/payments_controller.rb +0 -148
  83. data/app/controllers/spree/billing/places_controller.rb +0 -35
  84. data/app/controllers/spree/billing/products_controller.rb +0 -56
  85. data/app/controllers/spree/billing/refunds_controller.rb +0 -9
  86. data/app/controllers/spree/billing/reports_controller.rb +0 -202
  87. data/app/controllers/spree/billing/roles_controller.rb +0 -107
  88. data/app/controllers/spree/billing/store_credits_controller.rb +0 -117
  89. data/app/controllers/spree/billing/subscriptions_controller.rb +0 -49
  90. data/app/controllers/spree/billing/users_controller.rb +0 -19
  91. data/app/controllers/spree/billing/variants_controller.rb +0 -36
  92. data/app/controllers/spree/billing/vendors_controller.rb +0 -83
  93. data/app/controllers/spree/transit/amenities_controller.rb +0 -79
  94. data/app/controllers/spree/transit/amenity_values_controller.rb +0 -16
  95. data/app/controllers/spree/transit/base_controller.rb +0 -53
  96. data/app/controllers/spree/transit/branches_controller.rb +0 -35
  97. data/app/controllers/spree/transit/locations_controller.rb +0 -38
  98. data/app/controllers/spree/transit/places_controller.rb +0 -151
  99. data/app/controllers/spree/transit/reservations_controller.rb +0 -15
  100. data/app/controllers/spree/transit/routes_controller.rb +0 -66
  101. data/app/controllers/spree/transit/service_calendars_controller.rb +0 -109
  102. data/app/controllers/spree/transit/stops_controller.rb +0 -32
  103. data/app/controllers/spree/transit/trip_stops_controller.rb +0 -35
  104. data/app/controllers/spree/transit/trips_controller.rb +0 -90
  105. data/app/controllers/spree/transit/vectors/amenity_values_controller.rb +0 -40
  106. data/app/controllers/spree/transit/vectors/icons_controller.rb +0 -26
  107. data/app/controllers/spree/transit/vehicle_photos_controller.rb +0 -52
  108. data/app/controllers/spree/transit/vehicle_seats_controller.rb +0 -29
  109. data/app/controllers/spree/transit/vehicle_types_controller.rb +0 -81
  110. data/app/controllers/spree/transit/vehicles_controller.rb +0 -54
  111. data/app/controllers/spree/transit/vendors_controller.rb +0 -83
  112. data/app/helpers/spree/billing/sortable_tree_helper.rb +0 -32
  113. data/app/helpers/spree/transit/navigation_helper_decorator.rb +0 -14
  114. data/app/helpers/spree/transit/sortable_tree_helper.rb +0 -32
  115. data/app/helpers/spree_cm_commissioner/billing/monthly_orders_helper.rb +0 -66
  116. data/app/helpers/spree_cm_commissioner/billing/qrcodes_helper.rb +0 -11
  117. data/app/helpers/spree_cm_commissioner/transit/service_calendars_helper.rb +0 -17
  118. data/app/views/spree/billing/addresses/_form.html.erb +0 -38
  119. data/app/views/spree/billing/addresses/index.html.erb +0 -19
  120. data/app/views/spree/billing/adjustments/_adjustment.html.erb +0 -34
  121. data/app/views/spree/billing/adjustments/_adjustments_table.html.erb +0 -32
  122. data/app/views/spree/billing/adjustments/_form.html.erb +0 -12
  123. data/app/views/spree/billing/adjustments/edit.html.erb +0 -13
  124. data/app/views/spree/billing/adjustments/index.html.erb +0 -32
  125. data/app/views/spree/billing/adjustments/new.html.erb +0 -9
  126. data/app/views/spree/billing/businesses/_assets_form.html.erb +0 -8
  127. data/app/views/spree/billing/businesses/_form.html.erb +0 -88
  128. data/app/views/spree/billing/businesses/edit.html.erb +0 -21
  129. data/app/views/spree/billing/businesses/index.html.erb +0 -32
  130. data/app/views/spree/billing/businesses/new.html.erb +0 -16
  131. data/app/views/spree/billing/customers/_form.html.erb +0 -44
  132. data/app/views/spree/billing/customers/edit.html.erb +0 -23
  133. data/app/views/spree/billing/customers/index.html.erb +0 -160
  134. data/app/views/spree/billing/customers/new.html.erb +0 -15
  135. data/app/views/spree/billing/errors/forbidden.html.erb +0 -7
  136. data/app/views/spree/billing/invoice/show.html.erb +0 -21
  137. data/app/views/spree/billing/option_types/_form.html.erb +0 -29
  138. data/app/views/spree/billing/option_types/_option_value_fields.html.erb +0 -13
  139. data/app/views/spree/billing/option_types/edit.html.erb +0 -56
  140. data/app/views/spree/billing/option_types/index.html.erb +0 -50
  141. data/app/views/spree/billing/option_types/new.html.erb +0 -13
  142. data/app/views/spree/billing/orders/_form.html.erb +0 -54
  143. data/app/views/spree/billing/orders/_line_item.html.erb +0 -57
  144. data/app/views/spree/billing/orders/_search.html.erb +0 -62
  145. data/app/views/spree/billing/orders/edit.html.erb +0 -16
  146. data/app/views/spree/billing/orders/index.html.erb +0 -84
  147. data/app/views/spree/billing/orders/show.html.erb +0 -1
  148. data/app/views/spree/billing/payments/_list.html.erb +0 -49
  149. data/app/views/spree/billing/payments/_state_changes.html.erb +0 -33
  150. data/app/views/spree/billing/payments/index.html.erb +0 -35
  151. data/app/views/spree/billing/payments/new.html.erb +0 -24
  152. data/app/views/spree/billing/payments/show.html.erb +0 -15
  153. data/app/views/spree/billing/places/_form.html.erb +0 -16
  154. data/app/views/spree/billing/places/edit.html.erb +0 -12
  155. data/app/views/spree/billing/places/index.html.erb +0 -72
  156. data/app/views/spree/billing/places/new.html.erb +0 -15
  157. data/app/views/spree/billing/products/_add_stock_form.html.erb +0 -40
  158. data/app/views/spree/billing/products/_filters.html.erb +0 -31
  159. data/app/views/spree/billing/products/_form.html.erb +0 -123
  160. data/app/views/spree/billing/products/edit.html.erb +0 -8
  161. data/app/views/spree/billing/products/index.html.erb +0 -72
  162. data/app/views/spree/billing/products/new.html.erb +0 -85
  163. data/app/views/spree/billing/products/stock.html.erb +0 -77
  164. data/app/views/spree/billing/reports/_date_range_picker.html.erb +0 -78
  165. data/app/views/spree/billing/reports/_overview.html.erb +0 -48
  166. data/app/views/spree/billing/reports/active_subscribers.html.erb +0 -97
  167. data/app/views/spree/billing/reports/balance_due.html.erb +0 -188
  168. data/app/views/spree/billing/reports/export.html.erb +0 -70
  169. data/app/views/spree/billing/reports/failed_orders.html.erb +0 -58
  170. data/app/views/spree/billing/reports/index.html.erb +0 -62
  171. data/app/views/spree/billing/reports/overdue.html.erb +0 -61
  172. data/app/views/spree/billing/reports/paid.html.erb +0 -60
  173. data/app/views/spree/billing/roles/_form.html.erb +0 -52
  174. data/app/views/spree/billing/roles/edit.html.erb +0 -13
  175. data/app/views/spree/billing/roles/index.html.erb +0 -44
  176. data/app/views/spree/billing/roles/new.html.erb +0 -13
  177. data/app/views/spree/billing/shared/_customer_credit_form.html.erb +0 -33
  178. data/app/views/spree/billing/shared/_customer_promotion_form.html.erb +0 -29
  179. data/app/views/spree/billing/shared/_customer_promotion_table.html.erb +0 -39
  180. data/app/views/spree/billing/shared/_customer_tabs.html.erb +0 -46
  181. data/app/views/spree/billing/shared/_header.html.erb +0 -21
  182. data/app/views/spree/billing/shared/_invoice.html.erb +0 -280
  183. data/app/views/spree/billing/shared/_language_switcher.html.erb +0 -5
  184. data/app/views/spree/billing/shared/_main_menu.html.erb +0 -73
  185. data/app/views/spree/billing/shared/_order_tabs.html.erb +0 -41
  186. data/app/views/spree/billing/shared/_pop_up_confirmation.html.erb +0 -103
  187. data/app/views/spree/billing/shared/_product_tabs.html.erb +0 -24
  188. data/app/views/spree/billing/shared/_role_tabs.html.erb +0 -13
  189. data/app/views/spree/billing/shared/_subscription_tabs.html.erb +0 -20
  190. data/app/views/spree/billing/shared/_user_tabs.html.erb +0 -13
  191. data/app/views/spree/billing/shared/_vendor_logo.html.erb +0 -1
  192. data/app/views/spree/billing/shared/_vendor_switcher.html.erb +0 -57
  193. data/app/views/spree/billing/shared/sortable_tree/_taxonomy.html.erb +0 -29
  194. data/app/views/spree/billing/shared/sub_menu/_product.html.erb +0 -8
  195. data/app/views/spree/billing/shared/sub_menu/_report.html.erb +0 -23
  196. data/app/views/spree/billing/shared/sub_menu/_setting.html.erb +0 -11
  197. data/app/views/spree/billing/store_credits/_form.html.erb +0 -11
  198. data/app/views/spree/billing/store_credits/index.html.erb +0 -44
  199. data/app/views/spree/billing/store_credits/new.html.erb +0 -17
  200. data/app/views/spree/billing/subscriptions/_form.html.erb +0 -27
  201. data/app/views/spree/billing/subscriptions/_subscribed_variants_table.html.erb +0 -24
  202. data/app/views/spree/billing/subscriptions/edit.html.erb +0 -12
  203. data/app/views/spree/billing/subscriptions/index.html.erb +0 -86
  204. data/app/views/spree/billing/subscriptions/new.html.erb +0 -21
  205. data/app/views/spree/billing/users/_form.html.erb +0 -64
  206. data/app/views/spree/billing/users/edit.html.erb +0 -25
  207. data/app/views/spree/billing/users/index.html.erb +0 -116
  208. data/app/views/spree/billing/users/new.html.erb +0 -17
  209. data/app/views/spree/billing/variants/_form.html.erb +0 -28
  210. data/app/views/spree/billing/variants/edit.html.erb +0 -15
  211. data/app/views/spree/billing/variants/index.html.erb +0 -66
  212. data/app/views/spree/billing/variants/new.html.erb +0 -15
  213. data/app/views/spree/billing/vendors/_form.html.erb +0 -75
  214. data/app/views/spree/billing/vendors/edit.html.erb +0 -9
  215. data/app/views/spree/layouts/billing.html.erb +0 -74
  216. data/app/views/spree/layouts/transit.html.erb +0 -74
  217. data/app/views/spree/transit/amenities/_form.html.erb +0 -50
  218. data/app/views/spree/transit/amenities/_option_value_fields.html.erb +0 -15
  219. data/app/views/spree/transit/amenities/edit.html.erb +0 -53
  220. data/app/views/spree/transit/branches/_filter_form.erb +0 -23
  221. data/app/views/spree/transit/branches/_form.html.erb +0 -36
  222. data/app/views/spree/transit/branches/edit.html.erb +0 -15
  223. data/app/views/spree/transit/branches/index.html.erb +0 -43
  224. data/app/views/spree/transit/branches/new.html.erb +0 -19
  225. data/app/views/spree/transit/locations/_filter_form.erb +0 -30
  226. data/app/views/spree/transit/locations/_form.html.erb +0 -19
  227. data/app/views/spree/transit/locations/edit.html.erb +0 -15
  228. data/app/views/spree/transit/locations/index.html.erb +0 -49
  229. data/app/views/spree/transit/locations/new.html.erb +0 -17
  230. data/app/views/spree/transit/places/_assets_form.html.erb +0 -8
  231. data/app/views/spree/transit/places/_form.html.erb +0 -128
  232. data/app/views/spree/transit/places/edit.html.erb +0 -21
  233. data/app/views/spree/transit/places/index.html.erb +0 -32
  234. data/app/views/spree/transit/places/new.html.erb +0 -16
  235. data/app/views/spree/transit/reservations/index.html.erb +0 -5
  236. data/app/views/spree/transit/routes/_filter_form.erb +0 -24
  237. data/app/views/spree/transit/routes/_form.html.erb +0 -74
  238. data/app/views/spree/transit/routes/edit.html.erb +0 -13
  239. data/app/views/spree/transit/routes/index.html.erb +0 -79
  240. data/app/views/spree/transit/routes/new.html.erb +0 -18
  241. data/app/views/spree/transit/service_calendars/_exception_rules.html.erb +0 -44
  242. data/app/views/spree/transit/service_calendars/_filters.html.erb +0 -17
  243. data/app/views/spree/transit/service_calendars/_form.html.erb +0 -107
  244. data/app/views/spree/transit/service_calendars/index.html.erb +0 -77
  245. data/app/views/spree/transit/service_calendars/new.html.erb +0 -13
  246. data/app/views/spree/transit/shared/_amenity_tabs.html.erb +0 -21
  247. data/app/views/spree/transit/shared/_header.html.erb +0 -20
  248. data/app/views/spree/transit/shared/_main_menu.html.erb +0 -34
  249. data/app/views/spree/transit/shared/_route_tabs.html.erb +0 -25
  250. data/app/views/spree/transit/shared/_vehicle_tabs.html.erb +0 -15
  251. data/app/views/spree/transit/shared/_vendor_switcher.html.erb +0 -40
  252. data/app/views/spree/transit/shared/sortable_tree/_taxonomy.html.erb +0 -29
  253. data/app/views/spree/transit/shared/sub_menu/_setting.html.erb +0 -3
  254. data/app/views/spree/transit/stops/_filters.html.erb +0 -33
  255. data/app/views/spree/transit/stops/_form.html.erb +0 -68
  256. data/app/views/spree/transit/stops/edit.html.erb +0 -16
  257. data/app/views/spree/transit/stops/index.html.erb +0 -58
  258. data/app/views/spree/transit/stops/new.html.erb +0 -16
  259. data/app/views/spree/transit/trip_stops/index.html.erb +0 -38
  260. data/app/views/spree/transit/trips/_form.html.erb +0 -82
  261. data/app/views/spree/transit/trips/edit.html.erb +0 -15
  262. data/app/views/spree/transit/trips/new.html.erb +0 -15
  263. data/app/views/spree/transit/vectors/amenity_values/index.html.erb +0 -111
  264. data/app/views/spree/transit/vehicle_photos/_form.html.erb +0 -22
  265. data/app/views/spree/transit/vehicle_photos/edit.html.erb +0 -9
  266. data/app/views/spree/transit/vehicle_photos/index.html.erb +0 -61
  267. data/app/views/spree/transit/vehicle_photos/new.html.erb +0 -9
  268. data/app/views/spree/transit/vehicle_seats/_form.html.erb +0 -48
  269. data/app/views/spree/transit/vehicle_seats/_seats.html.erb +0 -63
  270. data/app/views/spree/transit/vehicle_types/_filter_form.html.erb +0 -29
  271. data/app/views/spree/transit/vehicle_types/_form.html.erb +0 -65
  272. data/app/views/spree/transit/vehicle_types/_seat_view.erb +0 -83
  273. data/app/views/spree/transit/vehicle_types/edit.html.erb +0 -93
  274. data/app/views/spree/transit/vehicle_types/index.html.erb +0 -94
  275. data/app/views/spree/transit/vehicle_types/new.html.erb +0 -14
  276. data/app/views/spree/transit/vehicles/_filter_form.html.erb +0 -23
  277. data/app/views/spree/transit/vehicles/_form.html.erb +0 -19
  278. data/app/views/spree/transit/vehicles/edit.html.erb +0 -19
  279. data/app/views/spree/transit/vehicles/index.html.erb +0 -57
  280. data/app/views/spree/transit/vehicles/new.html.erb +0 -19
  281. data/app/views/spree/transit/vendors/_form.html.erb +0 -34
  282. data/app/views/spree/transit/vendors/_tabs.html.erb +0 -16
  283. data/app/views/spree/transit/vendors/edit.html.erb +0 -10
@@ -0,0 +1,219 @@
1
+ require 'ipaddr'
2
+
3
+ module SpreeCmCommissioner
4
+ module MiniAppProvider
5
+ # One class per mini app, the way a payment gateway is one class per provider: the
6
+ # class declares what that mini app is configured with, and the application's
7
+ # `provider` names it the way a payment method's `type` names its gateway.
8
+ #
9
+ # Preferences are stored in the application's `settings`, under a key of the
10
+ # mini app's own, so two mini apps can never collide and one mini app's whole
11
+ # configuration can be read or dropped as a single value.
12
+ class Base
13
+ include Spree::Preferences::Preferable
14
+
15
+ # Every mini app is throttled the same way, by MiniAppProvider::SessionThrottle; a
16
+ # mini app shows them by naming this in #preference_groups. Set one to 0 to turn that
17
+ # cap off.
18
+ RATE_LIMITS = %i[max_sessions_per_ip max_sessions_per_phone max_new_users].freeze
19
+
20
+ preference :max_sessions_per_ip, :integer, default: 600
21
+ preference :max_sessions_per_phone, :integer, default: 10
22
+ preference :max_new_users, :integer, default: 100
23
+
24
+ # Which addresses a mini app's own servers may call from, for the ones that
25
+ # call us server to server — see MiniAppProvider::IpRules for what the lists
26
+ # accept. A mini app shows them by naming this in #preference_groups.
27
+ IP_RULES = %i[allowed_ips blocked_ips].freeze
28
+
29
+ preference :allowed_ips, :string, default: ''
30
+ preference :blocked_ips, :string, default: ''
31
+
32
+ # Who put the current key in place, when, and whether they typed it or we minted it.
33
+ # Replacing the key overwrites all three; superseded keys survive in the application's
34
+ # PaperTrail versions.
35
+ preference :api_key_created_by, :string, default: ''
36
+ preference :api_key_created_at, :string, default: ''
37
+ preference :api_key_source, :string, default: ''
38
+
39
+ # SecureRandom.hex renders one byte as two characters. Every key is minted here, so any other
40
+ # length is a truncated paste — it would look right in the field and fail every call after.
41
+ API_KEY_BYTES = 32
42
+ API_KEY_LENGTH = 64
43
+
44
+ class << self
45
+ # Registered mini apps, the equivalent of Spree::PaymentMethod.providers.
46
+ def providers
47
+ [AcledaBank, WingBank, VattanacBank]
48
+ end
49
+
50
+ # Matches UserIdentityProvider#identity_type, so the mini app that authenticates a
51
+ # customer and the identity row it writes name the same one. Those enum values
52
+ # end in `_bank`, which is why these class names do — not every mini app has to
53
+ # be one.
54
+ def provider
55
+ name.demodulize.underscore
56
+ end
57
+
58
+ def for(application)
59
+ klass = providers.find { |provider_class| provider_class.provider == application.provider.to_s }
60
+
61
+ klass&.new(application)
62
+ end
63
+
64
+ # Preferences grouped for the admin form. A mini app with none is configured from
65
+ # the environment rather than from a row.
66
+ def preference_groups
67
+ {}
68
+ end
69
+
70
+ # The preference this mini app cannot be used without. Clearing it revokes the
71
+ # mini app, and one with none is not run from an application at all.
72
+ def credential
73
+ nil
74
+ end
75
+
76
+ # Whether this mini app names itself with an API key on the way in. Only one we take
77
+ # server-to-server calls from has a request of its own to put a header on — a mini app
78
+ # whose WebView the partner merely opens arrives as the customer's browser, and the admin
79
+ # is offered no key it would have nowhere to send.
80
+ def supports_api_key?
81
+ false
82
+ end
83
+ end
84
+
85
+ attr_reader :application
86
+
87
+ delegate :id, :uid, to: :application
88
+ delegate :provider, :preference_groups, :credential, :supports_api_key?, to: :class
89
+
90
+ def initialize(application)
91
+ @application = application
92
+ end
93
+
94
+ # `ip` is Rails' request.remote_ip, so this is only as trustworthy as the proxy
95
+ # configuration in front of it.
96
+ #
97
+ # An address we cannot read is denied whenever an allow list is set: a mini app that named
98
+ # its addresses is asking us to refuse anything it cannot vouch for. Blocked wins over
99
+ # allowed.
100
+ def ip_allowed?(ip)
101
+ address = to_address(ip)
102
+
103
+ return false if address.nil? && allowed_ip_rules.any?
104
+ return true if address.nil?
105
+ return false if blocked_ip_rules.any? { |range| range.include?(address) }
106
+
107
+ allowed_ip_rules.empty? || allowed_ip_rules.any? { |range| range.include?(address) }
108
+ end
109
+
110
+ # Which address lists do not parse, for the validation that refuses to save them.
111
+ def invalid_ip_rules
112
+ IP_RULES.reject { |name| IpRules.valid?(public_send("preferred_#{name}")) }
113
+ end
114
+
115
+ # The header a mini app names itself with, and what an inbound call is looked up by — hence a
116
+ # column on the application rather than a key in `settings`, where nothing could index it or
117
+ # keep it unique. A handle, not a credential: #credential is what proves the call.
118
+ delegate :api_key, to: :application
119
+
120
+ # Mints a key, or takes `value` as the key when one is given — the way back from a Generate
121
+ # pressed by mistake, which no amount of history can undo on its own: the versions say which
122
+ # keys existed, never which one the partner is holding. Writes in memory like any other
123
+ # setter, so persisting is the caller's job.
124
+ def replace_api_key!(value = nil, actor: nil, source: nil)
125
+ typed = value.to_s.strip
126
+
127
+ application.api_key = typed.presence || SecureRandom.hex(API_KEY_BYTES)
128
+ self.preferred_api_key_source = source || 'generated'
129
+ self.preferred_api_key_created_by = actor&.id.to_s
130
+ self.preferred_api_key_created_at = Time.current.iso8601
131
+
132
+ api_key
133
+ end
134
+
135
+ # Leaves the mini app with no way in until a new key is generated — the answer to a leak,
136
+ # where replacing the key would hand the partner a working one they never asked for. The stamp
137
+ # goes with it: provenance for a key that is gone reads as though one were still in place. The
138
+ # revoked key survives in the application's PaperTrail versions.
139
+ def revoke_api_key!
140
+ application.api_key = nil
141
+ self.preferred_api_key_source = ''
142
+ self.preferred_api_key_created_by = ''
143
+ self.preferred_api_key_created_at = ''
144
+
145
+ nil
146
+ end
147
+
148
+ # What the form shows in place of the key, which it never renders after the one dialog that
149
+ # follows minting. The last four characters are what the partner can read back over the phone, and
150
+ # four of sixty-four hex characters give away nothing that matters.
151
+ def masked_api_key
152
+ return nil if api_key.blank?
153
+
154
+ "#{'•' * 20}#{api_key.last(4)}"
155
+ end
156
+
157
+ # The caption the admin form renders under the key — one sentence, so one method rather
158
+ # than three readers the view has to reassemble. Each half degrades on its own: the admin
159
+ # who generated the key may be gone, and `settings` is hand-editable JSON, so the stamp may
160
+ # not parse. Neither may cost us the fact that someone did put this key in place.
161
+ def api_key_provenance
162
+ return nil if api_key.blank?
163
+
164
+ action = preferred_api_key_source == 'restored' ? 'Restored' : 'Generated'
165
+
166
+ "#{action} #{api_key_created_at_phrase} by #{api_key_author}"
167
+ end
168
+
169
+ # Preferable reads and writes this in place, so it must be one hash for the life of
170
+ # the instance; #preferences_will_change! is what puts it back on the record. The
171
+ # defaults are merged in so the admin form can render every field of a mini app
172
+ # that has never been saved, the way Spree::Base seeds a payment method's column.
173
+ def preferences
174
+ @preferences ||= default_preferences.with_indifferent_access.merge(stored_preferences)
175
+ end
176
+
177
+ def preferences_will_change!
178
+ application.settings = application.settings.merge(provider => preferences.to_h)
179
+ end
180
+
181
+ private
182
+
183
+ def allowed_ip_rules
184
+ @allowed_ip_rules ||= IpRules.parse(preferred_allowed_ips)
185
+ end
186
+
187
+ def blocked_ip_rules
188
+ @blocked_ip_rules ||= IpRules.parse(preferred_blocked_ips)
189
+ end
190
+
191
+ def to_address(ip)
192
+ IPAddr.new(ip.to_s)
193
+ rescue IPAddr::Error
194
+ nil
195
+ end
196
+
197
+ def api_key_created_at_phrase
198
+ time = Time.zone.parse(preferred_api_key_created_at.to_s)
199
+
200
+ time ? time.strftime('%d %b %Y %H:%M') : 'at an unknown time'
201
+ rescue ArgumentError
202
+ 'at an unknown time'
203
+ end
204
+
205
+ # Falls back to the stored id once that admin is gone, rather than losing the fact that
206
+ # someone did generate it.
207
+ def api_key_author
208
+ stored_id = preferred_api_key_created_by
209
+ return 'unknown' if stored_id.blank?
210
+
211
+ Spree.user_class.find_by(id: stored_id)&.email || stored_id
212
+ end
213
+
214
+ def stored_preferences
215
+ (application.settings[provider] || {}).with_indifferent_access
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,6 @@
1
+ module SpreeCmCommissioner
2
+ module MiniAppProvider
3
+ class VattanacBank < Base
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,30 @@
1
+ module SpreeCmCommissioner
2
+ module MiniAppProvider
3
+ class WingBank < Base
4
+ # The mini app pair Wing issues. `app_id` is echoed back in every getProfile
5
+ # response and `secret_api_key` salts the hash that signs it — see
6
+ # SpreeCmCommissioner::WingMiniApp::ProfileVerifier for both.
7
+ preference :app_id, :string, default: ''
8
+ preference :secret_api_key, :string, default: ''
9
+
10
+ # Blank accepts level_1a or above — the lowest level whose hash formula includes
11
+ # `phone`, which is how a Wing customer is matched to an existing account. Set it
12
+ # to pin verification to exactly one level: level_0, level_1, level_1a, level_1b,
13
+ # level_2 or level_3.
14
+ preference :data_level, :string, default: ''
15
+
16
+ def self.credential
17
+ :secret_api_key
18
+ end
19
+
20
+ def self.preference_groups
21
+ {
22
+ credentials: %i[app_id secret_api_key],
23
+ verification: %i[data_level],
24
+ ip_rules: IP_RULES,
25
+ rate_limits: RATE_LIMITS
26
+ }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -2,6 +2,13 @@ module SpreeCmCommissioner
2
2
  module OauthApplicationDecorator
3
3
  def self.prepended(base)
4
4
  base.belongs_to :tenant, class_name: 'SpreeCmCommissioner::Tenant'
5
+ base.include SpreeCmCommissioner::OauthApplication::MiniApp
6
+
7
+ # The only record of a mini app's past API keys. MiniAppProvider::Base#replace_api_key!
8
+ # overwrites the key and its stamp in place, so without this a rotation leaves nothing behind to
9
+ # answer what the old key was, who replaced it, or when. `versions.object` carries the whole
10
+ # previous row, the mini app's `settings` with it.
11
+ base.has_paper_trail
5
12
 
6
13
  # treat a key inside a JSONB column like a normal Rails attribute,
7
14
  # with getter/setter methods
@@ -6,9 +6,18 @@ module SpreeCmCommissioner
6
6
  validates :dynamic_field_id, uniqueness: { scope: :product_id }
7
7
 
8
8
  after_commit :enqueue_data_fill_stage_maintenance, on: %i[create]
9
+ after_commit :enqueue_product_cache_invalidation
9
10
 
10
11
  private
11
12
 
13
+ # Purges the CDN-cached storefront dynamic_fields response for this product. Also covers
14
+ # DynamicField destroy, which cascades here via dependent: :destroy.
15
+ def enqueue_product_cache_invalidation
16
+ SpreeCmCommissioner::MaintenanceTasks::CacheInvalidation
17
+ .pending
18
+ .create_or_find_by(maintainable_type: 'Spree::Product', maintainable_id: product_id)
19
+ end
20
+
12
21
  # Only newly-added fields need active recompute: adding a field can make an
13
22
  # already-complete guest newly incomplete, which needs a persisted-column fix and a
14
23
  # catch-up notification. Removing a field can only shrink the required set, which
@@ -1,6 +1,6 @@
1
1
  module SpreeCmCommissioner
2
2
  class UserIdentityProvider < Base
3
- enum identity_type: { :google => 0, :apple => 1, :facebook => 2, :telegram => 3, :vattanac_bank => 4 }
3
+ enum identity_type: { :google => 0, :apple => 1, :facebook => 2, :telegram => 3, :vattanac_bank => 4, :acleda_bank => 5, :wing_bank => 6 }
4
4
 
5
5
  belongs_to :user, class_name: Spree.user_class.to_s, optional: false
6
6
  has_one :tenant, through: :user
@@ -0,0 +1,128 @@
1
+ module SpreeCmCommissioner
2
+ module MiniAppProvider
3
+ # The keys an application used to hold, newest first, read out of its PaperTrail versions.
4
+ #
5
+ # A version's `object` is the row *before* that change, so its `api_key` is a key that was current
6
+ # until then and its `settings` carry that key's own stamp.
7
+ class ApiKeyHistory
8
+ Entry = Struct.new(:version_id, :key, :created_at, :created_by, :source, :revoked, keyword_init: true)
9
+
10
+ # How many versions to read, not how many keys come back: most versions record a settings change
11
+ # and carry the key unchanged, so a window of 50 is what it takes to reach the last few keys. It
12
+ # has to be bounded at all because this runs on every render of the edit page and versions are
13
+ # never pruned.
14
+ LIMIT = 50
15
+
16
+ def initialize(application)
17
+ @application = application
18
+ end
19
+
20
+ # Deduplicated, because saving without touching the key writes the same one into another version,
21
+ # and the key in use now is left out — restoring it would do nothing.
22
+ def entries
23
+ seen = [current_ciphertext].compact
24
+
25
+ versions.filter_map do |version|
26
+ ciphertext = ciphertext_in(version)
27
+ next if ciphertext.blank? || seen.include?(ciphertext)
28
+
29
+ seen << ciphertext
30
+ entry_for(version, ciphertext)
31
+ end
32
+ end
33
+
34
+ # The plaintext a version recorded, or nil if that version is gone or no longer decryptable — an
35
+ # encryption key rotation leaves the ciphertext unreadable, and a caller must not take nil for a
36
+ # key worth saving.
37
+ def key_for(version_id)
38
+ version = versions.find { |candidate| candidate.id.to_s == version_id.to_s }
39
+ return nil if version.nil?
40
+
41
+ decrypt(ciphertext_in(version))
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :application
47
+
48
+ def versions
49
+ @versions ||= application.versions.reorder(id: :desc).limit(LIMIT).to_a
50
+ end
51
+
52
+ def entry_for(version, ciphertext)
53
+ return nil if decrypt(ciphertext).blank?
54
+
55
+ stamp = stamp_in(version)
56
+
57
+ Entry.new(
58
+ version_id: version.id,
59
+ key: decrypt(ciphertext),
60
+ created_at: parse_time(stamp['api_key_created_at']),
61
+ created_by: author(stamp['api_key_created_by']),
62
+ source: stamp['api_key_source'],
63
+ revoked: revoked?(version, ciphertext)
64
+ )
65
+ end
66
+
67
+ # The key this one gave way to. nil there means it was cleared rather than replaced — a revoke,
68
+ # which is what a leak is answered with, so the list says so rather than offering it silently.
69
+ def revoked?(version, ciphertext)
70
+ index = versions.index(version)
71
+ successor = index.zero? ? current_ciphertext : ciphertext_in(versions[index - 1])
72
+
73
+ successor.blank? && ciphertext.present?
74
+ end
75
+
76
+ def attributes_in(version)
77
+ @attributes_in ||= {}
78
+ @attributes_in[version.id] ||= YAML.unsafe_load(version.object.to_s) || {}
79
+ rescue Psych::Exception, ArgumentError
80
+ {}
81
+ end
82
+
83
+ def ciphertext_in(version)
84
+ attributes_in(version)['api_key']
85
+ end
86
+
87
+ def stamp_in(version)
88
+ settings = attributes_in(version)['settings']
89
+ settings = JSON.parse(settings) if settings.is_a?(String)
90
+
91
+ (settings || {})[application.provider.to_s] || {}
92
+ rescue JSON::ParserError
93
+ {}
94
+ end
95
+
96
+ # Raw SQL, because reading the attribute would hand back the decrypted key and there would be
97
+ # nothing left to compare the versions' ciphertext against.
98
+ def current_ciphertext
99
+ @current_ciphertext ||= application.class.connection.select_value(
100
+ application.class.where(id: application.id).select(:api_key).to_sql
101
+ )
102
+ end
103
+
104
+ def decrypt(ciphertext)
105
+ return nil if ciphertext.blank?
106
+
107
+ @decrypted ||= {}
108
+ @decrypted[ciphertext] ||= application.class.type_for_attribute(:api_key).deserialize(ciphertext)
109
+ rescue StandardError
110
+ nil
111
+ end
112
+
113
+ # `settings` is hand-editable JSON, so the stamp may not parse. A row with no date still names a
114
+ # key worth restoring.
115
+ def parse_time(value)
116
+ Time.zone.parse(value.to_s)
117
+ rescue ArgumentError
118
+ nil
119
+ end
120
+
121
+ def author(id)
122
+ return nil if id.blank?
123
+
124
+ Spree.user_class.find_by(id: id)&.email || id
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,26 @@
1
+ require 'ipaddr'
2
+
3
+ module SpreeCmCommissioner
4
+ module MiniAppProvider
5
+ # Reads a mini app's address list — a free-form set of addresses or CIDR ranges,
6
+ # `1.2.3.4`, `203.0.113.0/24`, IPv6 alike, separated by commas, spaces or newlines.
7
+ # MiniAppProvider::Base#ip_allowed? is what matches an address against them.
8
+ class IpRules
9
+ def self.parse(value)
10
+ value.to_s.split(/[,\s]+/).filter_map do |entry|
11
+ IPAddr.new(entry)
12
+ rescue IPAddr::Error
13
+ nil
14
+ end
15
+ end
16
+
17
+ # True when every entry is a usable address or range, so a typo is caught on save
18
+ # rather than by locking the mini app out on its next request.
19
+ def self.valid?(value)
20
+ entries = value.to_s.split(/[,\s]+/)
21
+
22
+ entries.size == parse(value).size
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,79 @@
1
+ module SpreeCmCommissioner
2
+ module MiniAppProvider
3
+ # Who is calling, from where, and how often — the three questions every mini app session
4
+ # asks, answered once so each new mini app inherits them.
5
+ #
6
+ # It answers them, it does not sequence them: SessionThrottle has to run after the mini
7
+ # app's own signature check, so each service keeps its own order. With no application row
8
+ # nothing is configured, so everything is allowed — the signature is what guards the
9
+ # endpoint, not this.
10
+ class SessionGate
11
+ def initialize(provider:, api_key: nil, ip: nil)
12
+ @provider = provider.to_s
13
+ @api_key = api_key
14
+ @ip = ip
15
+ end
16
+
17
+ # A mini app we take server-to-server calls from must always name itself, so only a key
18
+ # that finds its row passes. One whose WebView the partner merely opens has no request to
19
+ # put a header on — the customer's browser arrives — so it sends no key and passes on
20
+ # provider alone, even before anyone has configured a row for it.
21
+ def api_key_accepted?
22
+ return mini_app.present? if key_required?
23
+
24
+ true
25
+ end
26
+
27
+ def address_allowed?
28
+ mini_app.nil? || ip.blank? || mini_app.ip_allowed?(ip)
29
+ end
30
+
31
+ # `phone` must be the normalized intel_phone_number — see SessionThrottle.
32
+ def session_exceeded?(phone: nil)
33
+ throttle(phone)&.session_exceeded? || false
34
+ end
35
+
36
+ def new_user_exceeded?(phone: nil)
37
+ throttle(phone)&.new_user_exceeded? || false
38
+ end
39
+
40
+ # The configured mini app, or nil when none is set up. Carries the credential each
41
+ # mini app checks its own callers with.
42
+ #
43
+ # .mini_app_for takes the key as a required keyword so it can never answer as though a
44
+ # missing one had been checked, which is why the caller picks the lookup rather than
45
+ # passing a key that may be nil.
46
+ def mini_app
47
+ return @mini_app if defined?(@mini_app)
48
+
49
+ @mini_app = if api_key.present?
50
+ Spree::OauthApplication.mini_app_for(provider, api_key: api_key)
51
+ else
52
+ Spree::OauthApplication.keyless_mini_app_for(provider)
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :provider, :api_key, :ip
59
+
60
+ # Asked of the registry rather than of a row: a call with no key may have no row to ask,
61
+ # and that is exactly the case this has to answer for.
62
+ #
63
+ # Fully qualified — a bare `Base` resolves up the nesting to SpreeCmCommissioner::Base.
64
+ def key_required?
65
+ provider_class = SpreeCmCommissioner::MiniAppProvider::Base.providers.find do |klass|
66
+ klass.provider == provider
67
+ end
68
+
69
+ provider_class&.supports_api_key? || false
70
+ end
71
+
72
+ def throttle(phone)
73
+ return nil if mini_app.nil?
74
+
75
+ SpreeCmCommissioner::MiniAppProvider::SessionThrottle.new(mini_app: mini_app, ip: ip, phone: phone)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,66 @@
1
+ module SpreeCmCommissioner
2
+ module MiniAppProvider
3
+ # Runs *after* the signature is verified, so a trip here means correctly signed traffic is
4
+ # flooding us — the signal that a mini app's key leaked — not unsigned internet noise.
5
+ # Rack::Attack sits in front of Rails and cannot tell those apart, hence not middleware.
6
+ #
7
+ # #new_user_exceeded? is the cap that survives IP rotation: minting accounts is the damage
8
+ # a leaked key actually does, and returning customers never consume that quota.
9
+ class SessionThrottle
10
+ WINDOW = 60 # seconds
11
+
12
+ # `phone` must be the normalized intel_phone_number, not what the caller sent: keying on
13
+ # the raw string multiplies an allowance by alternating 012345678 and +85512345678.
14
+ def initialize(mini_app:, ip:, phone:)
15
+ @mini_app = mini_app
16
+ @ip = ip
17
+ @phone = phone
18
+ end
19
+
20
+ def session_exceeded?
21
+ (ip.present? && exceeded?(scope: 'ip', key: "ip:#{ip}", limit: mini_app.preferred_max_sessions_per_ip)) ||
22
+ (phone.present? && exceeded?(scope: 'phone', key: "phone:#{phone}", limit: mini_app.preferred_max_sessions_per_phone))
23
+ end
24
+
25
+ def new_user_exceeded?
26
+ exceeded?(scope: 'new_user', key: 'new_users', limit: mini_app.preferred_max_new_users)
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :mini_app, :ip, :phone
32
+
33
+ def exceeded?(scope:, key:, limit:)
34
+ namespaced_key = "throttle:mini_app:#{mini_app.provider}:#{mini_app.id}:#{key}"
35
+
36
+ result = SpreeCmCommissioner::RequestGuard.with_request_guard do
37
+ SpreeCmCommissioner::RateLimiter.call(key: namespaced_key, limit: limit, window: WINDOW)
38
+ end
39
+
40
+ # nil means the guard skipped the block for an exempt (load-test) request.
41
+ return false if result.nil? || !result.exceeded?
42
+
43
+ log_throttled(scope, namespaced_key, result)
44
+ true
45
+ end
46
+
47
+ def log_throttled(scope, key, result)
48
+ CmAppLogger.error(
49
+ label: 'Mini app session throttled',
50
+ data: {
51
+ scope: scope,
52
+ key: key,
53
+ count: result.count,
54
+ limit: result.limit,
55
+ window: WINDOW,
56
+ provider: mini_app.provider,
57
+ mini_app_id: mini_app.id,
58
+ client_id: mini_app.uid,
59
+ ip: ip,
60
+ phone: phone
61
+ }
62
+ )
63
+ end
64
+ end
65
+ end
66
+ end
@@ -1,7 +1,9 @@
1
1
  module SpreeCmCommissioner
2
2
  class PaymentMethodTypeMapper
3
3
  TYPES = {
4
- 'vattanac_mini_app' => 'Spree::Gateway::VattanacMiniApp'
4
+ 'vattanac' => 'Spree::Gateway::VattanacMiniApp',
5
+ 'acleda' => 'Spree::Gateway::AcledaMiniApp',
6
+ 'wing' => 'Spree::Gateway::WingMiniApp'
5
7
  }.freeze
6
8
 
7
9
  def self.call(type)
@@ -0,0 +1,20 @@
1
+ module SpreeCmCommissioner
2
+ class RateLimiter
3
+ # Outcome of one increment: hits seen in the current window, the cap that
4
+ # applied, and whether this call went over it. A plain class rather than a
5
+ # Struct so `count` stays a hit count and not Enumerable#count.
6
+ class Result
7
+ attr_reader :count, :limit
8
+
9
+ def initialize(count:, limit:, exceeded:)
10
+ @count = count
11
+ @limit = limit
12
+ @exceeded = exceeded
13
+ end
14
+
15
+ def exceeded?
16
+ @exceeded
17
+ end
18
+ end
19
+ end
20
+ end