mxrb 0.1.2 → 0.1.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 (191) hide show
  1. checksums.yaml +4 -4
  2. data/bin/mxrb +458 -10
  3. data/docs/de-DE/README.md +1 -0
  4. data/docs/de-DE/oql-sql.md +42 -0
  5. data/docs/de-DE/project-structure.md +201 -2
  6. data/docs/de-DE/runtime-ruby.md +104 -0
  7. data/docs/de-DE/runtime-schema-migrations.md +44 -0
  8. data/docs/de-DE/validation-matrix.md +42 -4
  9. data/docs/en-US/README.md +1 -0
  10. data/docs/en-US/oql-sql.md +41 -0
  11. data/docs/en-US/project-structure.md +192 -2
  12. data/docs/en-US/runtime-ruby.md +100 -0
  13. data/docs/en-US/runtime-schema-migrations.md +41 -0
  14. data/docs/en-US/validation-matrix.md +39 -4
  15. data/docs/pt-BR/README.md +1 -0
  16. data/docs/pt-BR/entity-dsl.md +8 -0
  17. data/docs/pt-BR/oql-sql.md +41 -0
  18. data/docs/pt-BR/project-structure.md +197 -2
  19. data/docs/pt-BR/runtime-ruby.md +108 -0
  20. data/docs/pt-BR/runtime-schema-migrations.md +40 -0
  21. data/docs/pt-BR/validation-matrix.md +42 -5
  22. data/docs/pt-BR/writing.md +28 -0
  23. data/lib/mxrb/cli/help.rb +426 -0
  24. data/lib/mxrb/cli/release.rb +137 -0
  25. data/lib/mxrb/compiler/compatibility_analyzer.rb +1 -1
  26. data/lib/mxrb/compiler/data_grid_bundle_compiler.rb +8 -1
  27. data/lib/mxrb/compiler/domain_document_compiler.rb +3 -0
  28. data/lib/mxrb/compiler/gallery_bundle_compiler.rb +11 -1
  29. data/lib/mxrb/compiler/generic_widget_bundle_compiler.rb +76 -19
  30. data/lib/mxrb/compiler/legacy_custom_widget_compiler.rb +159 -0
  31. data/lib/mxrb/compiler/legacy_data_grid_compiler.rb +43 -7
  32. data/lib/mxrb/compiler/legacy_page_builder.rb +1248 -23
  33. data/lib/mxrb/compiler/packager.rb +27 -13
  34. data/lib/mxrb/compiler/page_bundle_compiler.rb +13 -4
  35. data/lib/mxrb/compiler/portable_packager.rb +16 -11
  36. data/lib/mxrb/compiler/web_bundle_builder.rb +5 -1
  37. data/lib/mxrb/compiler/web_shell_materializer.rb +42 -2
  38. data/lib/mxrb/compiler/widget_package_extractor.rb +3 -0
  39. data/lib/mxrb/domain_diagram/lifecycle.rb +272 -0
  40. data/lib/mxrb/domain_diagram.rb +409 -0
  41. data/lib/mxrb/dsl/builder.rb +62 -11
  42. data/lib/mxrb/environment.rb +226 -0
  43. data/lib/mxrb/exporter.rb +265 -45
  44. data/lib/mxrb/http/server.rb +107 -0
  45. data/lib/mxrb/initializer.rb +80 -9
  46. data/lib/mxrb/integrity/validator.rb +32 -7
  47. data/lib/mxrb/io/bson_codec.rb +42 -1
  48. data/lib/mxrb/io/mpr_file.rb +146 -3
  49. data/lib/mxrb/model/attribute.rb +3 -0
  50. data/lib/mxrb/model/entity.rb +44 -2
  51. data/lib/mxrb/model/microflow.rb +3 -1
  52. data/lib/mxrb/model/module.rb +71 -0
  53. data/lib/mxrb/model/page.rb +126 -15
  54. data/lib/mxrb/model/project.rb +3 -0
  55. data/lib/mxrb/modeler/catalog.rb +178 -0
  56. data/lib/mxrb/official_marketplace/module_package_importer.rb +23 -12
  57. data/lib/mxrb/official_marketplace/widget_package_installer.rb +15 -11
  58. data/lib/mxrb/official_marketplace.rb +9 -3
  59. data/lib/mxrb/oql/reverse_translator.rb +394 -0
  60. data/lib/mxrb/oql/server.rb +4 -8
  61. data/lib/mxrb/oql.rb +32 -4
  62. data/lib/mxrb/progress.rb +242 -0
  63. data/lib/mxrb/project_lifecycle.rb +5 -2
  64. data/lib/mxrb/ruby_app/exporter.rb +1749 -0
  65. data/lib/mxrb/ruby_app/preset.rb +506 -0
  66. data/lib/mxrb/ruby_app/session_manager.rb +122 -0
  67. data/lib/mxrb/ruby_app.rb +1361 -0
  68. data/lib/mxrb/runtime/access_control.rb +408 -0
  69. data/lib/mxrb/runtime/native.rb +1015 -93
  70. data/lib/mxrb/runtime/scheduler.rb +396 -0
  71. data/lib/mxrb/runtime/schema_migrator.rb +598 -0
  72. data/lib/mxrb/runtime/shared_store.rb +335 -0
  73. data/lib/mxrb/runtime/sqlite_store.rb +729 -0
  74. data/lib/mxrb/scaffold/generator.rb +11 -3
  75. data/lib/mxrb/team_server.rb +10 -7
  76. data/lib/mxrb/uml/activity_diagram.rb +118 -0
  77. data/lib/mxrb/uml/class_diagram.rb +118 -0
  78. data/lib/mxrb/uml/sequence_diagram.rb +112 -0
  79. data/lib/mxrb/uml/server.rb +146 -0
  80. data/lib/mxrb/uml/support.rb +35 -0
  81. data/lib/mxrb/version.rb +1 -1
  82. data/lib/mxrb/web_ui/assets/abnfDiagram-N423BO3Z-C4JD3cEC.js +1 -0
  83. data/lib/mxrb/web_ui/assets/arc-3Z53kgFp.js +1 -0
  84. data/lib/mxrb/web_ui/assets/architecture-TIHT7OUA-BStBTLea.js +1 -0
  85. data/lib/mxrb/web_ui/assets/architectureDiagram-T3A2C74G-DBwqnM4J.js +36 -0
  86. data/lib/mxrb/web_ui/assets/array-BifhSqXX.js +1 -0
  87. data/lib/mxrb/web_ui/assets/blockDiagram-VBNYF7ZC-DLtuHt-j.js +132 -0
  88. data/lib/mxrb/web_ui/assets/c4Diagram-5PPSVZJV-ConSk_pe.js +10 -0
  89. data/lib/mxrb/web_ui/assets/channel-BhPvD7Dt.js +1 -0
  90. data/lib/mxrb/web_ui/assets/chunk-2GRJ4B5K-D2HpWsf_.js +1 -0
  91. data/lib/mxrb/web_ui/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  92. data/lib/mxrb/web_ui/assets/chunk-4I5QYGJK-CiIdNwaF.js +1 -0
  93. data/lib/mxrb/web_ui/assets/chunk-5RXB4S5H-Bu_AaEkE.js +231 -0
  94. data/lib/mxrb/web_ui/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  95. data/lib/mxrb/web_ui/assets/chunk-6Q2QTUOP-wQkNmt_X.js +88 -0
  96. data/lib/mxrb/web_ui/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
  97. data/lib/mxrb/web_ui/assets/chunk-GF5L2VYU-G8MmiBzU.js +206 -0
  98. data/lib/mxrb/web_ui/assets/chunk-I66GZJ75-CulcrLPh.js +127 -0
  99. data/lib/mxrb/web_ui/assets/chunk-JQJVKLGR-ukjzcdlL.js +156 -0
  100. data/lib/mxrb/web_ui/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  101. data/lib/mxrb/web_ui/assets/chunk-KBJHAD2P-Wm35lYov.js +1 -0
  102. data/lib/mxrb/web_ui/assets/chunk-KEIR6QF5-BICK3FdT.js +161 -0
  103. data/lib/mxrb/web_ui/assets/chunk-NSK5VX7P-D2nYrCY4.js +2 -0
  104. data/lib/mxrb/web_ui/assets/chunk-QR6OTTB3-BrdIzg4Q.js +62 -0
  105. data/lib/mxrb/web_ui/assets/chunk-RYQCIY6F-CCfZMcW6.js +1 -0
  106. data/lib/mxrb/web_ui/assets/chunk-UBXNYLIW-BPEVYsFK.js +1 -0
  107. data/lib/mxrb/web_ui/assets/chunk-W5SLKNZC-2E6OPhA4.js +1 -0
  108. data/lib/mxrb/web_ui/assets/chunk-WRU74C26-CBIvOyWp.js +70 -0
  109. data/lib/mxrb/web_ui/assets/chunk-XXDRQBXY-pH58XAyl.js +1 -0
  110. data/lib/mxrb/web_ui/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  111. data/lib/mxrb/web_ui/assets/classDiagram-JCYQIIEL-xiqo4D89.js +1 -0
  112. data/lib/mxrb/web_ui/assets/classDiagram-v2-OCEON4UE-xiqo4D89.js +1 -0
  113. data/lib/mxrb/web_ui/assets/cose-bilkent-JH36ORCC-t_flPpii.js +1 -0
  114. data/lib/mxrb/web_ui/assets/cynefin-VYW2F7L2-BIqktTEv.js +1 -0
  115. data/lib/mxrb/web_ui/assets/cynefinDiagram-MW4NZA55-C2d--lut.js +62 -0
  116. data/lib/mxrb/web_ui/assets/cytoscape.esm-B-NFISlW.js +321 -0
  117. data/lib/mxrb/web_ui/assets/dagre-Buvkdvvj.js +1 -0
  118. data/lib/mxrb/web_ui/assets/dagre-VZM6K2ZE-CSz2_tb5.js +4 -0
  119. data/lib/mxrb/web_ui/assets/defaultLocale-BFoDCU3G.js +1 -0
  120. data/lib/mxrb/web_ui/assets/diagram-7IWD3JNH-DCt7u7V_.js +30 -0
  121. data/lib/mxrb/web_ui/assets/diagram-B4RE2ZJO-DqZBqkMu.js +3 -0
  122. data/lib/mxrb/web_ui/assets/diagram-LBJQPF4R-ClIYbIsI.js +24 -0
  123. data/lib/mxrb/web_ui/assets/diagram-Q27KOJAE-DFxhvthJ.js +24 -0
  124. data/lib/mxrb/web_ui/assets/diagram-UB23O5K3-BSUTXptv.js +41 -0
  125. data/lib/mxrb/web_ui/assets/dist-D2qOEbeJ.js +1 -0
  126. data/lib/mxrb/web_ui/assets/domain-mGOZ1KCG.css +1 -0
  127. data/lib/mxrb/web_ui/assets/domain-qsjXhy4v.js +1 -0
  128. data/lib/mxrb/web_ui/assets/ebnfDiagram-BXEA7PRR-B5reICEi.js +1 -0
  129. data/lib/mxrb/web_ui/assets/erDiagram-JOGREHBK-Co5Mxn2p.js +85 -0
  130. data/lib/mxrb/web_ui/assets/eventmodeling-45OFAUF4-C1ylhSks.js +1 -0
  131. data/lib/mxrb/web_ui/assets/flowDiagram-UKHOOZJN-CHXaJIaS.js +1 -0
  132. data/lib/mxrb/web_ui/assets/ganttDiagram-PKOTCBZU-DPPPaWaz.js +292 -0
  133. data/lib/mxrb/web_ui/assets/gitGraph-TEB2WS4Q-qS77ywA9.js +1 -0
  134. data/lib/mxrb/web_ui/assets/gitGraphDiagram-DS77QQ5N-DpAv6-BT.js +106 -0
  135. data/lib/mxrb/web_ui/assets/graphlib-DS17s2tU.js +1 -0
  136. data/lib/mxrb/web_ui/assets/info-DKCQHKI2-C9VD1Y4e.js +1 -0
  137. data/lib/mxrb/web_ui/assets/infoDiagram-6WML65LV-Bk1kbcCy.js +2 -0
  138. data/lib/mxrb/web_ui/assets/init-C-OQMol4.js +1 -0
  139. data/lib/mxrb/web_ui/assets/ishikawaDiagram-WSZJBQD7-0gcISBFR.js +70 -0
  140. data/lib/mxrb/web_ui/assets/journeyDiagram-NVQOT4AX-Dt0RQnlh.js +139 -0
  141. data/lib/mxrb/web_ui/assets/jsx-runtime-DV0a5kSb.js +9 -0
  142. data/lib/mxrb/web_ui/assets/kanban-definition-27J2QSJJ-DIPQM7w9.js +89 -0
  143. data/lib/mxrb/web_ui/assets/katex-CXMH3UgJ.js +257 -0
  144. data/lib/mxrb/web_ui/assets/line-C0rYD-tL.js +1 -0
  145. data/lib/mxrb/web_ui/assets/linear-BPPxWorj.js +1 -0
  146. data/lib/mxrb/web_ui/assets/map-BaFkSB1l.js +1 -0
  147. data/lib/mxrb/web_ui/assets/mermaid-parser.core-B_UPzTxa.js +7 -0
  148. data/lib/mxrb/web_ui/assets/mindmap-definition-FAOFIHXS-CdJRSMuk.js +96 -0
  149. data/lib/mxrb/web_ui/assets/modeler-BNqHthwk.css +1 -0
  150. data/lib/mxrb/web_ui/assets/modeler-CQmqflbg.js +1 -0
  151. data/lib/mxrb/web_ui/assets/ordinal-BDEzSJ7C.js +1 -0
  152. data/lib/mxrb/web_ui/assets/packet-7NZHBO7P-CwQ4NSBs.js +1 -0
  153. data/lib/mxrb/web_ui/assets/path-fybaL0A-.js +1 -0
  154. data/lib/mxrb/web_ui/assets/pegDiagram-VL7TDLO6-D40S7ppx.js +1 -0
  155. data/lib/mxrb/web_ui/assets/pie-RZYD4A2V-CJroHMKh.js +1 -0
  156. data/lib/mxrb/web_ui/assets/pieDiagram-7S7Q4E2Y-QB54__BV.js +39 -0
  157. data/lib/mxrb/web_ui/assets/quadrantDiagram-CIZ2JOQS-DBhDS6ag.js +7 -0
  158. data/lib/mxrb/web_ui/assets/radar-I7S5WNFK-DdIUwd2d.js +1 -0
  159. data/lib/mxrb/web_ui/assets/railroad-3IZDKUUU-B1Lw6Xyq.js +1 -0
  160. data/lib/mxrb/web_ui/assets/railroad-abnf-AHOZXSZD-CQgQoeak.js +1 -0
  161. data/lib/mxrb/web_ui/assets/railroad-ebnf-EBAXGLYW-D7qEQ2x8.js +1 -0
  162. data/lib/mxrb/web_ui/assets/railroad-peg-LSFZ7HO6-C005Vxbb.js +1 -0
  163. data/lib/mxrb/web_ui/assets/railroadDiagram-AXF67PYL-BBP2qk7M.js +1 -0
  164. data/lib/mxrb/web_ui/assets/requirementDiagram-LRYGKXZP-D2cLsAoI.js +84 -0
  165. data/lib/mxrb/web_ui/assets/rough.esm-Dy-Kn_BL.js +1 -0
  166. data/lib/mxrb/web_ui/assets/sankeyDiagram-W5VNT64P-Bpi3FiP2.js +40 -0
  167. data/lib/mxrb/web_ui/assets/sequenceDiagram-SI44F4Z6-DCRcDlVe.js +162 -0
  168. data/lib/mxrb/web_ui/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
  169. data/lib/mxrb/web_ui/assets/src-CvxfUak2.js +1 -0
  170. data/lib/mxrb/web_ui/assets/stateDiagram-OKZ733FA-C2GCEoxl.js +1 -0
  171. data/lib/mxrb/web_ui/assets/stateDiagram-v2-UEYNNEHI-D2gWgedh.js +1 -0
  172. data/lib/mxrb/web_ui/assets/swimlanes-SLNWSIFB-CRI9irEW.js +2 -0
  173. data/lib/mxrb/web_ui/assets/swimlanesDiagram-ULZ7WXOC-C5Wb_-hZ.js +8 -0
  174. data/lib/mxrb/web_ui/assets/theme-_mxc7BdY.css +1 -0
  175. data/lib/mxrb/web_ui/assets/timeline-definition-Z64GVDOM-B1-mNg-h.js +120 -0
  176. data/lib/mxrb/web_ui/assets/treeView-QDETBFTQ-7CTvIwKq.js +1 -0
  177. data/lib/mxrb/web_ui/assets/treemap-6X3UGDF4-CtL22pq5.js +1 -0
  178. data/lib/mxrb/web_ui/assets/uml-C3KAFGTq.js +51 -0
  179. data/lib/mxrb/web_ui/assets/uml-Dn-qd01X.css +1 -0
  180. data/lib/mxrb/web_ui/assets/vennDiagram-T6HMQDX7-BzVnUEAy.js +34 -0
  181. data/lib/mxrb/web_ui/assets/wardley-OPB4EBWU-JNi-QNag.js +1 -0
  182. data/lib/mxrb/web_ui/assets/wardleyDiagram-T6FBY63Y-BUwmAQ-B.js +78 -0
  183. data/lib/mxrb/web_ui/assets/xychartDiagram-ELKLHX3M-DEQS8sKp.js +7 -0
  184. data/lib/mxrb/web_ui/domain.html +15 -0
  185. data/lib/mxrb/web_ui/modeler.html +16 -0
  186. data/lib/mxrb/web_ui/uml.html +31 -0
  187. data/lib/mxrb/web_ui.rb +89 -0
  188. data/lib/mxrb/widget_certification.rb +208 -0
  189. data/lib/mxrb/writer.rb +278 -41
  190. data/lib/mxrb.rb +38 -8
  191. metadata +157 -5
@@ -0,0 +1,506 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+
6
+ module Mxrb
7
+ module RubyApp
8
+ # Adds an opt-in conventional Ruby framework around the generic MXRB Rack
9
+ # backend. Presets never alter the default Ruby mode and never own Mendix
10
+ # domain metadata; the Ruby application manifest remains authoritative.
11
+ # rubocop:disable Metrics
12
+ class Preset
13
+ NAMES = %i[flymetothemoon onrails].freeze
14
+ MARKER_PATH = File.join('config', 'mxrb_stack.rb')
15
+
16
+ def self.apply!(root, name)
17
+ new(root, name).apply!
18
+ end
19
+
20
+ def self.detect(root)
21
+ path = File.join(root, MARKER_PATH)
22
+ return unless File.file?(path)
23
+
24
+ File.read(path)[/MXRB_RUBY_STACK\s*=\s*['"](flymetothemoon|onrails)['"]/, 1]
25
+ end
26
+
27
+ def self.manifest(name)
28
+ selected = name.to_s
29
+ {
30
+ 'preset' => selected, 'rack' => true, 'server' => 'puma',
31
+ 'web_framework' => selected == 'onrails' ? 'rails' : 'sinatra',
32
+ 'orm' => 'active_record', 'test' => 'rspec', 'tasks' => 'rake'
33
+ }
34
+ end
35
+
36
+ def initialize(root, name)
37
+ @root = File.expand_path(root)
38
+ @name = name.to_sym
39
+ raise ArgumentError, "Ruby stack must be #{NAMES.join(' or ')}" unless NAMES.include?(@name)
40
+
41
+ @manifest = Manifest.load(@root)
42
+ @namespace = ruby_constant(@manifest.data.dig('project', 'name'))
43
+ end
44
+
45
+ def apply!
46
+ ensure_gems
47
+ write(MARKER_PATH, marker_source, replace_baseline: true)
48
+ name == :onrails ? write_rails : write_flymetothemoon
49
+ update_manifest
50
+ append_readme
51
+ root
52
+ end
53
+
54
+ private
55
+
56
+ attr_reader :root, :name, :manifest, :namespace
57
+
58
+ def ensure_gems
59
+ path = File.join(root, 'Gemfile')
60
+ source = File.file?(path) ? File.read(path).rstrip : "source 'https://rubygems.org'"
61
+ gems.each do |declaration|
62
+ gem_name = declaration[/gem ['"]([^'"]+)/, 1]
63
+ source += "\n#{declaration}" unless source.match?(/gem ['"]#{Regexp.escape(gem_name)}['"]/)
64
+ end
65
+ File.binwrite(path, "#{source}\n")
66
+ end
67
+
68
+ def gems
69
+ common = [
70
+ "gem 'rack', '~> 3.1'", "gem 'puma', '~> 8.0'",
71
+ "gem 'activerecord', '~> 8.0'", "gem 'sqlite3', '~> 2.0'",
72
+ "gem 'rake', '~> 13.2'", "gem 'rspec', '~> 3.13'",
73
+ "gem 'rack-test', '~> 2.2'"
74
+ ]
75
+ if name == :onrails
76
+ common + ["gem 'rails', '~> 8.0'", "gem 'rspec-rails', '~> 7.1'"]
77
+ else
78
+ common + ["gem 'sinatra', '~> 4.1'"]
79
+ end
80
+ end
81
+
82
+ def write_flymetothemoon
83
+ write('config.ru', fly_config_ru)
84
+ write('config/environment.rb', fly_environment)
85
+ write('config/puma.rb', puma_config)
86
+ write('config/database.yml', database_yml)
87
+ write('app/models/application_record.rb', application_record)
88
+ write('app/web/application.rb', sinatra_application)
89
+ write('db/migrate/.keep', '')
90
+ write('Rakefile', fly_rakefile)
91
+ write('.rspec', "--require spec_helper\n--format documentation\n")
92
+ write('spec/spec_helper.rb', fly_spec_helper)
93
+ write('spec/requests/health_spec.rb', fly_health_spec)
94
+ end
95
+
96
+ def write_rails
97
+ write('config/boot.rb', rails_boot)
98
+ write('config/application.rb', rails_application, replace_baseline: true)
99
+ write('config/environment.rb', rails_environment)
100
+ write('config/environments/development.rb', rails_development)
101
+ write('config/environments/test.rb', rails_test)
102
+ write('config/routes.rb', rails_routes)
103
+ write('config/puma.rb', puma_config)
104
+ write('config/database.yml', database_yml)
105
+ write('config.ru', rails_config_ru)
106
+ write('app/models/application_record.rb', application_record)
107
+ write('app/controllers/application_controller.rb', rails_application_controller)
108
+ write('app/controllers/health_controller.rb', rails_health_controller)
109
+ write('db/migrate/.keep', '')
110
+ write('Rakefile', rails_rakefile)
111
+ write('bin/rails', rails_bin)
112
+ FileUtils.chmod(0o755, File.join(root, 'bin', 'rails'))
113
+ write('.rspec', "--require spec_helper\n--format documentation\n")
114
+ write('spec/spec_helper.rb', rails_spec_helper)
115
+ write('spec/rails_helper.rb', rails_helper)
116
+ write('spec/requests/health_spec.rb', rails_health_spec)
117
+ end
118
+
119
+ def update_manifest
120
+ path = File.join(root, MANIFEST_PATH)
121
+ data = JSON.parse(File.read(path))
122
+ data['ruby_stack'] = self.class.manifest(name)
123
+ File.binwrite(path, JSON.pretty_generate(data) << "\n")
124
+ end
125
+
126
+ def append_readme
127
+ path = File.join(root, 'README.md')
128
+ text = File.file?(path) ? File.read(path) : ''
129
+ heading = name == :onrails ? 'Rails preset' : 'Fly Me to the Moon preset'
130
+ return if text.include?("## #{heading}")
131
+
132
+ text += <<~MARKDOWN
133
+
134
+ ## #{heading}
135
+
136
+ This opt-in stack runs the MXRB backend through Rack and Puma while
137
+ React + Vite remains the browser client. ActiveRecord owns only Ruby
138
+ tables declared through its migrations; Mendix entities remain owned
139
+ by the MXRB manifest so round-trips never have two schema authorities.
140
+
141
+ ```sh
142
+ bundle install
143
+ bundle exec rake db:migrate
144
+ bundle exec rspec
145
+ bundle exec mxrb run .
146
+ ```
147
+ MARKDOWN
148
+ File.binwrite(path, text)
149
+ end
150
+
151
+ def write(relative, contents, replace_baseline: false)
152
+ path = File.join(root, relative)
153
+ return if File.file?(path) && !(replace_baseline && baseline_file?(path))
154
+
155
+ FileUtils.mkdir_p(File.dirname(path))
156
+ File.binwrite(path, contents)
157
+ end
158
+
159
+ def baseline_file?(path)
160
+ return true if path.end_with?(MARKER_PATH)
161
+
162
+ File.read(path).include?('MXRB_APPLICATION_ROOT')
163
+ rescue Errno::ENOENT
164
+ true
165
+ end
166
+
167
+ def ruby_constant(value)
168
+ result = value.to_s.split(/[^A-Za-z0-9]+/).reject(&:empty?).map do |part|
169
+ part[0].upcase + part[1..].to_s
170
+ end.join
171
+ result = "Application#{result}" if result.empty? || result.match?(/\A\d/)
172
+ result
173
+ end
174
+
175
+ def marker_source
176
+ <<~RUBY
177
+ # frozen_string_literal: true
178
+
179
+ MXRB_RUBY_STACK = '#{name}' unless defined?(MXRB_RUBY_STACK)
180
+ RUBY
181
+ end
182
+
183
+ def puma_config
184
+ <<~RUBY
185
+ # frozen_string_literal: true
186
+
187
+ bind "tcp://\#{ENV.fetch('HOST', '127.0.0.1')}:\#{ENV.fetch('MXRB_SERVER_PORT', '9292')}"
188
+ threads_count = Integer(ENV.fetch('RAILS_MAX_THREADS', '5'))
189
+ threads threads_count, threads_count
190
+ workers 0
191
+ environment ENV.fetch('RACK_ENV', ENV.fetch('RAILS_ENV', 'development'))
192
+ RUBY
193
+ end
194
+
195
+ def database_yml
196
+ <<~YAML
197
+ default: &default
198
+ adapter: sqlite3
199
+ pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
200
+ timeout: 5000
201
+
202
+ development:
203
+ <<: *default
204
+ database: db/development.sqlite3
205
+
206
+ test:
207
+ <<: *default
208
+ database: db/test.sqlite3
209
+
210
+ production:
211
+ <<: *default
212
+ database: <%= ENV.fetch("DATABASE_PATH", "db/production.sqlite3") %>
213
+ YAML
214
+ end
215
+
216
+ def application_record
217
+ <<~RUBY
218
+ # frozen_string_literal: true
219
+
220
+ if defined?(ActiveRecord::Base) && !defined?(ApplicationRecord)
221
+ class ApplicationRecord < ActiveRecord::Base
222
+ primary_abstract_class
223
+ end
224
+ end
225
+ RUBY
226
+ end
227
+
228
+ def fly_environment
229
+ <<~RUBY
230
+ # frozen_string_literal: true
231
+
232
+ require 'bundler/setup'
233
+ require 'yaml'
234
+ require 'erb'
235
+ require 'active_record'
236
+ require 'mxrb'
237
+
238
+ root = File.expand_path('..', __dir__)
239
+ environment = ENV.fetch('RACK_ENV', 'development')
240
+ settings = YAML.safe_load(ERB.new(File.read(File.join(root, 'config/database.yml'))).result,
241
+ aliases: true).fetch(environment)
242
+ ActiveRecord::Base.establish_connection(settings)
243
+ require File.join(root, 'app/models/application_record')
244
+ RUBY
245
+ end
246
+
247
+ def sinatra_application
248
+ <<~RUBY
249
+ # frozen_string_literal: true
250
+
251
+ require 'json'
252
+ require 'sinatra/base'
253
+
254
+ module #{namespace}
255
+ module Web
256
+ class Application < Sinatra::Base
257
+ set :show_exceptions, false
258
+
259
+ get '/health' do
260
+ content_type :json
261
+ JSON.generate(ok: true, framework: 'sinatra')
262
+ end
263
+ end
264
+ end
265
+ end
266
+ RUBY
267
+ end
268
+
269
+ def fly_config_ru
270
+ <<~RUBY
271
+ # frozen_string_literal: true
272
+
273
+ require_relative 'config/environment'
274
+ require_relative 'app/web/application'
275
+
276
+ map '/ruby' do
277
+ run #{namespace}::Web::Application
278
+ end
279
+ run Mxrb::RubyApp::RackAdapter.new(__dir__)
280
+ RUBY
281
+ end
282
+
283
+ def fly_rakefile
284
+ <<~RUBY
285
+ # frozen_string_literal: true
286
+
287
+ require_relative 'config/environment'
288
+ require 'rake'
289
+ require 'active_record/tasks/database_tasks'
290
+ require 'rspec/core/rake_task'
291
+
292
+ namespace :db do
293
+ desc 'Run ActiveRecord migrations for Ruby-owned tables'
294
+ task :migrate do
295
+ ActiveRecord::MigrationContext.new('db/migrate').migrate
296
+ end
297
+ end
298
+
299
+ RSpec::Core::RakeTask.new(:spec)
300
+ task default: :spec
301
+ RUBY
302
+ end
303
+
304
+ def fly_spec_helper
305
+ <<~RUBY
306
+ # frozen_string_literal: true
307
+
308
+ ENV['RACK_ENV'] = 'test'
309
+ require_relative '../config/environment'
310
+ require 'rack/test'
311
+ require 'rspec'
312
+
313
+ RSpec.configure do |config|
314
+ config.include Rack::Test::Methods
315
+ end
316
+ RUBY
317
+ end
318
+
319
+ def fly_health_spec
320
+ <<~RUBY
321
+ # frozen_string_literal: true
322
+
323
+ require 'spec_helper'
324
+ require_relative '../../app/web/application'
325
+
326
+ RSpec.describe 'Ruby web health' do
327
+ def app = #{namespace}::Web::Application
328
+
329
+ it 'answers through Sinatra' do
330
+ get '/health'
331
+ expect(last_response).to be_ok
332
+ end
333
+ end
334
+ RUBY
335
+ end
336
+
337
+ def rails_boot
338
+ <<~RUBY
339
+ # frozen_string_literal: true
340
+
341
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
342
+ require 'bundler/setup'
343
+ RUBY
344
+ end
345
+
346
+ def rails_application
347
+ <<~RUBY
348
+ # frozen_string_literal: true
349
+
350
+ require_relative 'boot'
351
+ require 'rails/all'
352
+ require 'mxrb'
353
+ Bundler.require(*Rails.groups)
354
+
355
+ module #{namespace}
356
+ class Application < Rails::Application
357
+ config.load_defaults 8.0
358
+ config.autoload_lib(ignore: %w[assets tasks]) if config.respond_to?(:autoload_lib)
359
+ end
360
+ end
361
+ RUBY
362
+ end
363
+
364
+ def rails_environment
365
+ <<~RUBY
366
+ # frozen_string_literal: true
367
+
368
+ require_relative 'application'
369
+ Rails.application.initialize!
370
+ RUBY
371
+ end
372
+
373
+ def rails_development
374
+ <<~RUBY
375
+ # frozen_string_literal: true
376
+
377
+ Rails.application.configure do
378
+ config.enable_reloading = true
379
+ config.eager_load = false
380
+ config.consider_all_requests_local = true
381
+ config.secret_key_base = ENV.fetch('SECRET_KEY_BASE', 'development-only-mxrb-secret')
382
+ end
383
+ RUBY
384
+ end
385
+
386
+ def rails_test
387
+ <<~RUBY
388
+ # frozen_string_literal: true
389
+
390
+ Rails.application.configure do
391
+ config.enable_reloading = false
392
+ config.eager_load = false
393
+ config.consider_all_requests_local = true
394
+ config.secret_key_base = 'test-only-mxrb-secret'
395
+ end
396
+ RUBY
397
+ end
398
+
399
+ def rails_routes
400
+ <<~RUBY
401
+ # frozen_string_literal: true
402
+
403
+ Rails.application.routes.draw do
404
+ get '/ruby/health', to: 'health#show'
405
+ mxrb_backend = Mxrb::RubyApp::RackAdapter.new(Rails.root.to_s)
406
+ match '/api', to: mxrb_backend, via: :all
407
+ match '/api/*path', to: mxrb_backend, via: :all
408
+ end
409
+ RUBY
410
+ end
411
+
412
+ def rails_config_ru
413
+ <<~RUBY
414
+ # frozen_string_literal: true
415
+
416
+ require_relative 'config/environment'
417
+ map '/api' do
418
+ run Mxrb::RubyApp::RackAdapter.new(Rails.root.to_s)
419
+ end
420
+ run Rails.application
421
+ RUBY
422
+ end
423
+
424
+ def rails_application_controller
425
+ <<~RUBY
426
+ # frozen_string_literal: true
427
+
428
+ class ApplicationController < ActionController::Base
429
+ end
430
+ RUBY
431
+ end
432
+
433
+ def rails_health_controller
434
+ <<~RUBY
435
+ # frozen_string_literal: true
436
+
437
+ class HealthController < ApplicationController
438
+ def show
439
+ render json: { ok: true, framework: 'rails' }
440
+ end
441
+ end
442
+ RUBY
443
+ end
444
+
445
+ def rails_rakefile
446
+ <<~RUBY
447
+ # frozen_string_literal: true
448
+
449
+ require_relative 'config/application'
450
+ Rails.application.load_tasks
451
+ RUBY
452
+ end
453
+
454
+ def rails_bin
455
+ <<~RUBY
456
+ #!/usr/bin/env ruby
457
+ # frozen_string_literal: true
458
+
459
+ APP_PATH = File.expand_path('../config/application', __dir__)
460
+ require_relative '../config/boot'
461
+ require 'rails/commands'
462
+ RUBY
463
+ end
464
+
465
+ def rails_spec_helper
466
+ <<~RUBY
467
+ # frozen_string_literal: true
468
+
469
+ require 'rspec'
470
+ RUBY
471
+ end
472
+
473
+ def rails_helper
474
+ <<~RUBY
475
+ # frozen_string_literal: true
476
+
477
+ ENV['RAILS_ENV'] ||= 'test'
478
+ require_relative '../config/environment'
479
+ require 'rspec/rails'
480
+ RUBY
481
+ end
482
+
483
+ def rails_health_spec
484
+ <<~RUBY
485
+ # frozen_string_literal: true
486
+
487
+ require 'rails_helper'
488
+
489
+ RSpec.describe 'Ruby web health', type: :request do
490
+ it 'answers through Rails' do
491
+ get '/ruby/health'
492
+ expect(response).to have_http_status(:ok)
493
+ end
494
+
495
+ it 'mounts the generic MXRB backend under /api' do
496
+ get '/api/health'
497
+ expect(response).to have_http_status(:ok)
498
+ expect(response.parsed_body).to include('ok' => true)
499
+ end
500
+ end
501
+ RUBY
502
+ end
503
+ end
504
+ # rubocop:enable Metrics
505
+ end
506
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+ require 'securerandom'
6
+ require 'time'
7
+ require_relative '../runtime/shared_store'
8
+
9
+ module Mxrb
10
+ module RubyApp
11
+ class AuthenticationError < StandardError; end
12
+
13
+ # Environment-backed identities and short-lived opaque sessions for the
14
+ # standalone Ruby server. Production deployments can put the same adapter
15
+ # behind their own Rack authentication and provide static bearer tokens.
16
+ class SessionManager
17
+ attr_reader :ttl
18
+
19
+ # rubocop:disable Metrics/ParameterLists
20
+ def initialize(access_control, users: ENV['MXRB_USERS_JSON'],
21
+ tokens: ENV['MXRB_AUTH_TOKENS'], ttl: ENV.fetch('MXRB_SESSION_TTL', '3600'),
22
+ clock: -> { Time.now.utc }, store: nil)
23
+ @access_control = access_control
24
+ @users = parse_map(users, 'MXRB_USERS_JSON')
25
+ @static_tokens = parse_map(tokens, 'MXRB_AUTH_TOKENS')
26
+ @ttl = Integer(ttl)
27
+ raise ArgumentError, 'session TTL must be positive' unless @ttl.positive?
28
+
29
+ @clock = clock
30
+ @store = store || Runtime::MemorySharedStore.new
31
+ end
32
+ # rubocop:enable Metrics/ParameterLists
33
+
34
+ def anonymous
35
+ @access_control.context
36
+ end
37
+
38
+ def authenticate(header)
39
+ token = bearer_token(header)
40
+ return anonymous unless token
41
+
42
+ static = @static_tokens[token]
43
+ return context_for(static) if static
44
+
45
+ session = @store.read_session(token, now: @clock.call)
46
+ raise AuthenticationError, 'invalid or expired bearer token' unless session
47
+
48
+ context_for(session.identity)
49
+ end
50
+
51
+ def login(username, password)
52
+ identity = authenticated_identity(username, password)
53
+
54
+ token = SecureRandom.urlsafe_base64(32)
55
+ expires_at = @clock.call + ttl
56
+ profile = identity.slice('roles', 'user_roles', 'module_roles', 'attributes')
57
+ .merge('user' => username.to_s)
58
+ context = context_for(profile)
59
+ @store.write_session(token:, identity: profile, expires_at:)
60
+ { token:, expires_at: expires_at.iso8601, user: context.user, roles: context.user_roles }
61
+ end
62
+
63
+ def logout(header)
64
+ token = bearer_token(header)
65
+ token && @store.delete_session(token)
66
+ end
67
+
68
+ private
69
+
70
+ def authenticated_identity(username, password)
71
+ identity = @users[username.to_s]
72
+ unless identity && password_matches?(identity, password)
73
+ raise AuthenticationError, 'invalid username or password'
74
+ end
75
+
76
+ identity
77
+ end
78
+
79
+ def context_for(raw)
80
+ value = raw.is_a?(Hash) ? raw : {}
81
+ @access_control.context(
82
+ user: value['user'], user_roles: value['roles'] || value['user_roles'],
83
+ module_roles: value['module_roles'], attributes: value['attributes'] || {}
84
+ )
85
+ end
86
+
87
+ def bearer_token(header)
88
+ match = /\ABearer\s+(.+)\z/i.match(header.to_s.strip)
89
+ match && match[1]
90
+ end
91
+
92
+ def password_matches?(identity, supplied)
93
+ expected = identity['password_digest'].to_s
94
+ expected = "plain$#{identity['password']}" if expected.empty? && identity.key?('password')
95
+ scheme, value = expected.split('$', 2)
96
+ actual = case scheme
97
+ when 'sha256' then Digest::SHA256.hexdigest(supplied.to_s)
98
+ when 'plain' then supplied.to_s
99
+ else return false
100
+ end
101
+ secure_compare(actual, value.to_s)
102
+ end
103
+
104
+ def secure_compare(left, right)
105
+ return false unless left.bytesize == right.bytesize
106
+
107
+ left.bytes.zip(right.bytes).reduce(0) { |difference, pair| difference | (pair[0] ^ pair[1]) }.zero?
108
+ end
109
+
110
+ def parse_map(raw, name)
111
+ return {} if raw.nil? || raw.to_s.strip.empty?
112
+
113
+ parsed = JSON.parse(raw)
114
+ raise ArgumentError, "#{name} must contain a JSON object" unless parsed.is_a?(Hash)
115
+
116
+ parsed
117
+ rescue JSON::ParserError => e
118
+ raise ArgumentError, "invalid #{name}: #{e.message}"
119
+ end
120
+ end
121
+ end
122
+ end