funicular 0.3.0 → 0.5.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +486 -1
  3. data/demo/local_notes.html +207 -0
  4. data/demo/test_chartjs.html +9 -9
  5. data/demo/test_component.html +8 -8
  6. data/demo/test_error_boundary.html +44 -41
  7. data/demo/test_router.html +48 -48
  8. data/demo/tic-tac-toe.html +25 -25
  9. data/docs/architecture.md +227 -12
  10. data/docs/local_database.md +1035 -0
  11. data/lib/funicular/assets/funicular.rb +14 -0
  12. data/lib/funicular/configuration.rb +65 -0
  13. data/lib/funicular/epoch_header.rb +69 -0
  14. data/lib/funicular/epoch_stamping.rb +66 -0
  15. data/lib/funicular/helpers/picoruby_helper.rb +96 -1
  16. data/lib/funicular/railtie.rb +30 -0
  17. data/lib/funicular/schema.rb +45 -12
  18. data/lib/funicular/session_epoch.rb +110 -0
  19. data/lib/funicular/ssr/runtime.rb +58 -12
  20. data/lib/funicular/ssr.rb +25 -0
  21. data/lib/funicular/testing/node_runner.mjs +19 -0
  22. data/lib/funicular/testing.rb +47 -0
  23. data/lib/funicular/vendor/mrbc/VERSION +1 -1
  24. data/lib/funicular/vendor/mrbc/mrbc.js +655 -574
  25. data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
  26. data/lib/funicular/vendor/picoruby/VERSION +1 -1
  27. data/lib/funicular/vendor/picoruby/debug/picoruby.js +800 -530
  28. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  29. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -2
  30. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  31. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
  32. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -6909
  33. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  34. data/lib/funicular/version.rb +1 -1
  35. data/lib/funicular.rb +1 -0
  36. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +37 -38
  37. data/lib/tasks/funicular.rake +10 -2
  38. data/minitest/callback_error_visibility_test.rb +48 -0
  39. data/minitest/configuration_test.rb +78 -0
  40. data/minitest/dsl_test.rb +264 -0
  41. data/minitest/epoch_header_test.rb +149 -0
  42. data/minitest/epoch_stamping_test.rb +225 -0
  43. data/minitest/fixtures/funicular_app/components/greeting_component.rb +5 -5
  44. data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
  45. data/minitest/form_for_test.rb +2 -2
  46. data/minitest/hydration_test.rb +2 -2
  47. data/minitest/navigation_guard_test.rb +65 -0
  48. data/minitest/picoruby_helper_test.rb +236 -0
  49. data/minitest/schema_test.rb +47 -0
  50. data/minitest/session_epoch_test.rb +122 -0
  51. data/minitest/sig_tags_test.rb +30 -0
  52. data/minitest/ssr_database_test.rb +78 -0
  53. data/minitest/ssr_reload_test.rb +106 -0
  54. data/minitest/ssr_test.rb +41 -0
  55. data/minitest/testing_ensure_compiled_test.rb +52 -0
  56. data/minitest/validations_test.rb +35 -5
  57. data/minitest/view_context_test.rb +15 -15
  58. data/mrbgem.rake +2 -0
  59. data/mrblib/0_tags.rb +62 -0
  60. data/mrblib/cable.rb +1 -1
  61. data/mrblib/component.rb +226 -24
  62. data/mrblib/db.rb +3116 -0
  63. data/mrblib/error_boundary.rb +25 -19
  64. data/mrblib/file_upload.rb +17 -7
  65. data/mrblib/form_builder.rb +10 -10
  66. data/mrblib/funicular.rb +136 -17
  67. data/mrblib/http.rb +84 -107
  68. data/mrblib/model.rb +1178 -23
  69. data/mrblib/relation.rb +342 -0
  70. data/mrblib/router.rb +45 -4
  71. data/mrblib/styles.rb +122 -12
  72. data/mrblib/view_context.rb +3 -32
  73. data/sig/component.rbs +25 -4
  74. data/sig/db.rbs +328 -0
  75. data/sig/error_boundary.rbs +4 -4
  76. data/sig/funicular.rbs +5 -0
  77. data/sig/http.rbs +8 -21
  78. data/sig/model.rbs +101 -7
  79. data/sig/relation.rbs +44 -0
  80. data/sig/router.rbs +1 -0
  81. data/sig/styles.rbs +19 -5
  82. data/sig/tags.rbs +54 -0
  83. data/sig/view_context.rbs +47 -34
  84. metadata +23 -2
  85. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
@@ -19,3 +19,17 @@ Rails.autoloaders.main.ignore(Rails.root.join("app/funicular"))
19
19
  # config.production_source = :cdn
20
20
  # # config.cdn_version = "4.0.0" # defaults to the version vendored in the gem
21
21
  # end
22
+
23
+ # Local database (SQLite in the browser, docs/local_database.md):
24
+ # disabled by default. Opting in also requires declaring how browser
25
+ # storage is namespaced -- a user_key resolver for apps with
26
+ # authentication, or anonymous_only for apps without users.
27
+ #
28
+ # Funicular.configure do |config|
29
+ # config.local_database = true
30
+ # config.user_key = ->(controller) {
31
+ # controller.request.session[:user_id]&.to_s
32
+ # }
33
+ # # or, for apps that genuinely have no users:
34
+ # # config.anonymous_only = true
35
+ # end
@@ -26,6 +26,7 @@ module Funicular
26
26
  SOURCES = %i[local_debug local_dist cdn].freeze
27
27
 
28
28
  attr_reader :development_source, :test_source, :production_source
29
+ attr_reader :application_id, :user_key, :anonymous_only, :local_database
29
30
  attr_writer :cdn_version
30
31
 
31
32
  def initialize
@@ -33,6 +34,70 @@ module Funicular
33
34
  @test_source = :local_debug
34
35
  @production_source = :local_dist
35
36
  @cdn_version = nil
37
+ @application_id = "funicular"
38
+ @user_key = nil
39
+ @anonymous_only = false
40
+ @local_database = false
41
+ end
42
+
43
+ # The SQLite/IndexedDB subsystem is deliberately opt-in. A plain
44
+ # Funicular application remains REST-only and pays none of its boot,
45
+ # storage, locking, or session-epoch costs.
46
+ def local_database=(value)
47
+ @local_database = value ? true : false
48
+ end
49
+
50
+ # Called after Rails initializers and again when the include tag is
51
+ # rendered. The client boot separately validates the emitted contract.
52
+ # Validation cannot run from local_database= because initializer
53
+ # assignment order is free.
54
+ def validate_local_database!
55
+ return true unless @local_database
56
+ return true if @user_key || @anonymous_only
57
+
58
+ raise ArgumentError,
59
+ "Funicular local_database requires config.user_key or " \
60
+ "config.anonymous_only = true"
61
+ end
62
+
63
+ # The application's namespace id (docs: local_database.md, data
64
+ # isolation). Give each Funicular app sharing an origin a distinct
65
+ # one; it keys the client's snapshot/lock namespace AND the epoch
66
+ # entry in the Rails session.
67
+ def application_id=(value)
68
+ id = value.to_s
69
+ if id.empty?
70
+ raise ArgumentError, "Funicular application_id cannot be empty"
71
+ end
72
+ @application_id = id
73
+ end
74
+
75
+ # A callable receiving the controller and returning a stable,
76
+ # non-reusable identifier for the signed-in user (nil when signed
77
+ # out). Mandatory whenever the local database is enabled, unless
78
+ # anonymous_only says the app genuinely has no users.
79
+ def user_key=(value)
80
+ unless value.respond_to?(:call)
81
+ raise ArgumentError,
82
+ "Funicular user_key must be callable (a lambda receiving the controller)"
83
+ end
84
+ if @anonymous_only
85
+ raise ArgumentError,
86
+ "Funicular user_key and anonymous_only are mutually exclusive; declare one or the other"
87
+ end
88
+ @user_key = value
89
+ end
90
+
91
+ # The explicit opt-out for apps without authentication: every
92
+ # visitor shares the anonymous namespace ON PURPOSE. Mutually
93
+ # exclusive with user_key -- the framework never picks silently.
94
+ def anonymous_only=(value)
95
+ flag = value ? true : false
96
+ if flag && @user_key
97
+ raise ArgumentError,
98
+ "Funicular user_key and anonymous_only are mutually exclusive; declare one or the other"
99
+ end
100
+ @anonymous_only = flag
36
101
  end
37
102
 
38
103
  def development_source=(value)
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session_epoch"
4
+ require_relative "epoch_stamping"
5
+
6
+ module Funicular
7
+ # With the local database enabled, this Rack middleware writes the
8
+ # X-Funicular-Epoch header onto EVERY response -- the exception pages
9
+ # ActionDispatch renders included. Disabled responses are returned untouched.
10
+ # A controller-level after_action cannot guarantee that: it never
11
+ # runs when the action raises, and a header-less 500 reads as an
12
+ # epoch mismatch client-side, terminating a healthy page over a mere
13
+ # server error. The Railtie inserts this above
14
+ # ActionDispatch::ShowExceptions, so the freshly-built exception
15
+ # response passes through here too.
16
+ #
17
+ # The controller concern (EpochStamping) owns the ROTATION -- the
18
+ # user_key lambda needs its controller -- and leaves the epoch in the
19
+ # request env; this middleware only writes the header. When the
20
+ # request died before the concern ran, the stored session epoch
21
+ # (read without rotating) is the best truthful answer; with no
22
+ # session and no env value the header is simply absent, exactly as
23
+ # before the request.
24
+ class EpochHeader
25
+ # Every spelling of the header this middleware owns.
26
+ OWNED_HEADERS = [EpochStamping::HEADER, "X-Funicular-Epoch"].freeze
27
+
28
+ def initialize(app)
29
+ @app = app
30
+ end
31
+
32
+ def call(env)
33
+ status, headers, body = @app.call(env)
34
+ return [status, headers, body] unless Funicular.configuration.local_database
35
+ # This header is the framework's to write, so an inner layer's
36
+ # value is dropped FIRST and unconditionally: a stale one
37
+ # (stamped before a login/logout rotated the epoch) would let an
38
+ # old page read the post-transition response as a match --
39
+ # exactly the boundary decision 13 exists to keep closed. Leaving
40
+ # it in place when no authoritative epoch is available would be
41
+ # the same hole with none of the excuses. Both spellings go:
42
+ # plain header hashes are case-sensitive, so a legacy-cased
43
+ # duplicate would otherwise ride out alongside our lowercase
44
+ # (Rack 3) name.
45
+ OWNED_HEADERS.each { |name| headers.delete(name) }
46
+ epoch = env[EpochStamping::ENV_KEY] || stored_epoch(env)
47
+ headers[EpochStamping::HEADER] = epoch if epoch
48
+ [status, headers, body]
49
+ end
50
+
51
+ private
52
+
53
+ def stored_epoch(env)
54
+ session = env["rack.session"]
55
+ return nil unless session
56
+ epochs = session[SessionEpoch::SESSION_KEY]
57
+ return nil unless epochs.is_a?(Hash)
58
+ entry = epochs[Funicular.configuration.application_id]
59
+ return nil unless entry.is_a?(Hash)
60
+ epoch = entry["epoch"].to_s
61
+ epoch.empty? ? nil : epoch
62
+ rescue StandardError
63
+ # Best effort on the exception path: masking the original error
64
+ # with a session-read failure would be worse than a missing
65
+ # header.
66
+ nil
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session_epoch"
4
+
5
+ module Funicular
6
+ # Controller mixin installed by the Railtie: when the local database is
7
+ # enabled, it rotates the session epoch where the user_key lambda has its
8
+ # controller, and leaves the value in the request env for the EpochHeader
9
+ # middleware to write out (docs decision 13).
10
+ #
11
+ # The header itself is deliberately NOT set here: a controller-set
12
+ # header dies with the controller's response when the action raises,
13
+ # and the exception page ActionDispatch renders would go out
14
+ # header-less -- which the client must treat as an epoch mismatch,
15
+ # terminating a healthy page over a mere 500.
16
+ #
17
+ # The epoch is stamped TWICE around the action. Before it, so a
18
+ # mid-action exception still sends a truthful value; and again in
19
+ # the ensure, with the POST-action identity, because the very
20
+ # actions that log a user in or out change the identity INSIDE the
21
+ # action -- their own response must already carry the rotated epoch,
22
+ # not leave the rotation to the next request (an old page would
23
+ # apply the login response as a match).
24
+ module EpochStamping
25
+ # Lowercase per the Rack 3 spec (response header names MUST be
26
+ # lowercase; Rack::Lint enforces it). HTTP header names are
27
+ # case-insensitive on the wire, so the client contract is
28
+ # unchanged.
29
+ HEADER = "x-funicular-epoch"
30
+ ENV_KEY = "funicular.epoch"
31
+
32
+ def self.included(base)
33
+ base.around_action :stamp_funicular_epoch if base.respond_to?(:around_action)
34
+ end
35
+
36
+ private
37
+
38
+ # request.session, never the controller's session accessor: an
39
+ # application action named "session" SHADOWS the accessor, and
40
+ # calling it from here would invoke the action itself (rendering
41
+ # twice). The ensure re-reads request.session instead of reusing
42
+ # the pre-action object because reset_session (a logout) replaces
43
+ # it mid-action.
44
+ def stamp_funicular_epoch
45
+ local_database = Funicular.configuration.local_database
46
+ return yield unless local_database
47
+ sess = request.session
48
+ unless Funicular::SessionEpoch.session_available?(sess)
49
+ # No session middleware (API-only Rails): there is no cookie
50
+ # identity for the epoch to protect, so the feature stays off
51
+ # instead of breaking every action with a disabled-session
52
+ # error.
53
+ return yield
54
+ end
55
+ request.env[ENV_KEY] = Funicular::SessionEpoch.stamp!(sess, self)
56
+ yield
57
+ ensure
58
+ if local_database
59
+ sess = request.session
60
+ if Funicular::SessionEpoch.session_available?(sess)
61
+ request.env[ENV_KEY] = Funicular::SessionEpoch.stamp!(sess, self)
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "funicular/session_epoch"
4
+
3
5
  module Funicular
4
6
  module Helpers
5
7
  # View helpers exposed to ActionView through Funicular::Railtie.
@@ -16,6 +18,15 @@ module Funicular
16
18
  local_dist: "dist"
17
19
  }.freeze
18
20
 
21
+ LOCAL_DATABASE_METADATA_KEYS = %i[
22
+ funicular_local_database
23
+ funicular_application_id
24
+ funicular_user_key
25
+ funicular_user_key_configured
26
+ funicular_anonymous_only
27
+ funicular_epoch
28
+ ].freeze
29
+
19
30
  # Minimal CSS the gem ships for class names it emits itself (e.g.
20
31
  # FormBuilder error states). Read once; see assets/funicular.css.
21
32
  BASE_CSS_PATH = File.expand_path("../assets/funicular.css", __dir__)
@@ -37,10 +48,42 @@ module Funicular
37
48
  # such as form error states render without host-CSS setup); pass
38
49
  # base_styles: false to skip it. Any extra options become HTML attributes
39
50
  # on the <script> tag.
51
+ #
52
+ # When opted in, the tag also carries the local-database page metadata as
53
+ # data-funicular-* attributes (docs: local_database.md, data
54
+ # isolation): the application id, the user-key contract, and the
55
+ # session epoch. With the feature disabled these attributes are omitted.
40
56
  def picoruby_include_tag(source: nil, base_styles: true, **options)
41
57
  resolved_source = source ? source.to_sym : Funicular.configuration.source_for(Rails.env)
42
58
  src = picoruby_src_for(resolved_source)
43
- script = tag.script("", src: src, **options)
59
+ # Caller extras survive, but the framework's contract values
60
+ # always win: a data: override of the epoch or the user key
61
+ # would make the page disagree with its own responses (instant
62
+ # terminal) or boot the wrong namespace. Keys are normalized so
63
+ # a string or dashed spelling cannot smuggle in a duplicate of
64
+ # the same HTML attribute either.
65
+ data = {}
66
+ caller_data = (options.delete(:data) || {}).to_a
67
+ i = 0
68
+ caller_data_size = caller_data.size
69
+ while i < caller_data_size
70
+ key, value = caller_data[i]
71
+ normalized = key.to_s.tr("-", "_").to_sym
72
+ unless LOCAL_DATABASE_METADATA_KEYS.include?(normalized)
73
+ data[normalized] = value
74
+ end
75
+ i += 1
76
+ end
77
+ data.merge!(funicular_page_metadata)
78
+ # The same contract values can arrive as TOP-LEVEL options too
79
+ # ("data-funicular-epoch" => ..., or data_funicular_epoch:).
80
+ # Left in options they reach the tag builder unfiltered and
81
+ # emit a second copy of an attribute the metadata owns -- or,
82
+ # with the feature disabled, the only copy.
83
+ options.keys.each do |key|
84
+ options.delete(key) if reserved_metadata_option?(key)
85
+ end
86
+ script = tag.script("", src: src, data: data, **options)
44
87
  return script unless base_styles
45
88
 
46
89
  style = tag.style(PicorubyHelper.base_css.html_safe, "data-funicular-base": "")
@@ -96,6 +139,58 @@ module Funicular
96
139
 
97
140
  private
98
141
 
142
+ # True for every top-level spelling of a key the page metadata
143
+ # owns: "data-funicular-epoch", :"data-funicular-epoch", and the
144
+ # underscored data_funicular_epoch the tag builder dasherizes.
145
+ # Other data- attributes are the caller's business and survive.
146
+ def reserved_metadata_option?(key)
147
+ normalized = key.to_s.tr("-", "_")
148
+ return false unless normalized.start_with?("data_")
149
+
150
+ LOCAL_DATABASE_METADATA_KEYS.include?(
151
+ normalized.delete_prefix("data_").to_sym)
152
+ end
153
+
154
+ # The namespace + epoch metadata the client boot reads
155
+ # (DB.read_page_metadata): attribute values are HTML-escaped by
156
+ # the tag helper, so hostile application ids or user keys cannot
157
+ # break out of the attribute. The user-key attribute is OMITTED
158
+ # for signed-out visitors (the client boots the anonymous
159
+ # namespace), and the epoch is stamped through the same session
160
+ # entry the response header uses -- the page and its responses
161
+ # can never disagree at render time.
162
+ def funicular_page_metadata
163
+ config = Funicular.configuration
164
+ return {} unless config.local_database
165
+ config.validate_local_database!
166
+ ctrl = respond_to?(:controller) ? controller : nil
167
+ meta = {
168
+ funicular_local_database: "true",
169
+ funicular_application_id: config.application_id,
170
+ }
171
+ meta[:funicular_anonymous_only] = "true" if config.anonymous_only
172
+ # ONE resolver evaluation feeds both the page attribute and the
173
+ # epoch identity below: two evaluations could disagree
174
+ # mid-transition and stamp one user's epoch onto another
175
+ # user's page namespace.
176
+ key = nil
177
+ if config.user_key
178
+ meta[:funicular_user_key_configured] = "true"
179
+ key = Funicular::SessionEpoch.user_key(ctrl)
180
+ meta[:funicular_user_key] = key if key
181
+ end
182
+ # The view's session helper delegates to the controller, where
183
+ # an action named "session" shadows the accessor -- go through
184
+ # request.session, which cannot be shadowed.
185
+ req = respond_to?(:request) ? request : nil
186
+ sess = req && req.session
187
+ if sess && Funicular::SessionEpoch.session_available?(sess)
188
+ meta[:funicular_epoch] = Funicular::SessionEpoch.stamp_identity!(
189
+ sess, Funicular::SessionEpoch.identity_for(key))
190
+ end
191
+ meta
192
+ end
193
+
99
194
  def picoruby_src_for(source)
100
195
  if source == :cdn
101
196
  version = Funicular.configuration.cdn_version
@@ -9,6 +9,11 @@ module Funicular
9
9
  initializer "funicular.middleware" do |app|
10
10
  if Rails.env.development?
11
11
  app.middleware.use Funicular::Middleware
12
+ # The middleware recompiles the client bundle on change; SSR
13
+ # must reload the same sources, or server-rendered markup goes
14
+ # stale until a restart while hydration is already fresh.
15
+ require "funicular/ssr/runtime"
16
+ Funicular::SSR::Runtime.auto_reload = true
12
17
  end
13
18
  end
14
19
 
@@ -19,6 +24,31 @@ module Funicular
19
24
  end
20
25
  end
21
26
 
27
+ initializer "funicular.epoch" do |app|
28
+ # When the local database is enabled, every response carries
29
+ # X-Funicular-Epoch (docs decision 13):
30
+ # the controller concern rotates the epoch, the middleware writes
31
+ # the header. The middleware sits ABOVE the exception renderer so
32
+ # even a 500 page carries it -- a header-less error response
33
+ # would terminal the client over a mere server error.
34
+ require "funicular/epoch_header"
35
+ if defined?(ActionDispatch::ShowExceptions)
36
+ app.middleware.insert_before ActionDispatch::ShowExceptions,
37
+ Funicular::EpochHeader
38
+ else
39
+ app.middleware.use Funicular::EpochHeader
40
+ end
41
+ # :action_controller fires for both Base and API.
42
+ ActiveSupport.on_load(:action_controller) do
43
+ require "funicular/epoch_stamping"
44
+ include Funicular::EpochStamping
45
+ end
46
+ end
47
+
48
+ config.after_initialize do
49
+ Funicular.configuration.validate_local_database!
50
+ end
51
+
22
52
  rake_tasks do
23
53
  load "tasks/funicular.rake"
24
54
  end
@@ -34,12 +34,24 @@ module Funicular
34
34
  def self.build(model_class, attributes:, endpoints: {}, except: {})
35
35
  merged = {}
36
36
  attributes.each do |name, definition|
37
+ # Readonly attributes are server-managed: the client never
38
+ # submits them, so validating their (nil) client-side value
39
+ # against e.g. a presence validator would reject every create.
40
+ # Skip validator introspection for them entirely.
41
+ if readonly?(definition)
42
+ merged[name] = definition
43
+ next
44
+ end
37
45
  rules = rules_for(model_class, name, except_kinds(except, name))
38
46
  merged[name] = rules.empty? ? definition : definition.merge(validations: rules)
39
47
  end
40
48
  { attributes: merged, endpoints: endpoints }
41
49
  end
42
50
 
51
+ def self.readonly?(definition)
52
+ definition.is_a?(Hash) && !!(definition[:readonly] || definition["readonly"])
53
+ end
54
+
43
55
  # Returns { "attr" => { "presence" => true, "length" => { "maximum" => 30 } } }
44
56
  # for the given attribute names only. Useful when emitting validations as a
45
57
  # separate block rather than inline (see #build for the inline form).
@@ -79,18 +91,34 @@ module Funicular
79
91
  end
80
92
 
81
93
  def self.serialize(kind, options)
82
- case kind
83
- when :presence, :absence, :acceptance, :confirmation
84
- true
85
- when :length
86
- serialize_length(options)
87
- when :numericality
88
- serialize_numericality(options)
89
- when :inclusion, :exclusion
90
- serialize_set(options)
91
- when :format
92
- RegexpTranslator.translate(options[:with])
93
- end
94
+ serialized =
95
+ case kind
96
+ when :presence, :absence, :acceptance, :confirmation
97
+ true
98
+ when :length
99
+ serialize_length(options)
100
+ when :numericality
101
+ serialize_numericality(options)
102
+ when :inclusion, :exclusion
103
+ serialize_set(options)
104
+ when :format
105
+ RegexpTranslator.translate(options[:with])
106
+ end
107
+ attach_shared_options(serialized, options)
108
+ end
109
+
110
+ # allow_nil / allow_blank change what the client may skip; without them
111
+ # a format validator on an optional attribute rejects the blank value
112
+ # that the server happily accepts. Kinds that serialize to a bare true
113
+ # (presence, or numericality without constraints) upgrade to a Hash so
114
+ # the flags survive for them too.
115
+ def self.attach_shared_options(serialized, options)
116
+ return serialized unless options[:allow_nil] || options[:allow_blank]
117
+ serialized = {} if serialized == true
118
+ return serialized unless serialized.is_a?(Hash)
119
+ serialized["allow_nil"] = true if options[:allow_nil]
120
+ serialized["allow_blank"] = true if options[:allow_blank]
121
+ serialized
94
122
  end
95
123
 
96
124
  def self.serialize_length(options)
@@ -149,6 +177,11 @@ module Funicular
149
177
  end
150
178
 
151
179
  js_source = source.gsub('\\A', '^').gsub('\\z', '$').gsub('\\Z', '$')
180
+ # Ruby regexp literals escape "#" to suppress interpolation and the
181
+ # escape survives in Regexp#source, but "\#" is an invalid identity
182
+ # escape for a JS RegExp under the u flag. "#" never needs escaping
183
+ # in JS source (x mode is already rejected above), so unescape it.
184
+ js_source = js_source.gsub('\\#', '#')
152
185
 
153
186
  flags = +''
154
187
  flags << 'i' if (regexp.options & Regexp::IGNORECASE) != 0
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module Funicular
7
+ # The server half of the session epoch (docs: local_database.md, "The
8
+ # session epoch"). When the local database is enabled, the epoch is an
9
+ # opaque value that changes on every authentication transition; every
10
+ # response carries it in the X-Funicular-Epoch header, and the client goes
11
+ # terminal on a mismatch
12
+ # so a tab can never keep applying data that now belongs to somebody
13
+ # else's cookie session.
14
+ #
15
+ # State lives in the Rails session PER application_id, so several
16
+ # Funicular apps sharing one cookie session cannot rotate each other's
17
+ # epochs:
18
+ #
19
+ # session["funicular_epochs"][app_id] = {
20
+ # "identity" => <canonical identity JSON>,
21
+ # "epoch" => <opaque hex>,
22
+ # }
23
+ #
24
+ # String keys throughout: cookie sessions round-trip through JSON.
25
+ module SessionEpoch
26
+ SESSION_KEY = "funicular_epochs"
27
+
28
+ class << self
29
+ # A Rails API-only app usually runs WITHOUT a session middleware:
30
+ # request.session is a disabled stub whose every read and write
31
+ # raises. Loading Funicular must not break such an app, so both
32
+ # the controller concern and the include-tag helper check here
33
+ # and leave the epoch feature OFF -- with no cookie session there
34
+ # is no shared identity for the epoch to protect in the first
35
+ # place. Plain hashes (tests, exotic stores) count as enabled.
36
+ def session_available?(session)
37
+ return false if session.nil?
38
+ return true unless session.respond_to?(:enabled?)
39
+ session.enabled?
40
+ end
41
+
42
+ # The signed-in user's storage key, resolved through the
43
+ # configured lambda; nil when signed out or when no user_key is
44
+ # configured (anonymous_only apps included). An empty resolved
45
+ # key is a broken source and fails loud -- it would fold every
46
+ # user into one namespace.
47
+ def user_key(controller)
48
+ resolver = Funicular.configuration.user_key
49
+ return nil unless resolver
50
+ value = resolver.call(controller)
51
+ return nil if value.nil?
52
+ key = value.to_s
53
+ if key.empty?
54
+ raise Funicular::Error,
55
+ "Funicular user_key resolved to an empty string; " \
56
+ "return nil for signed-out visitors instead"
57
+ end
58
+ key
59
+ end
60
+
61
+ # The identity for an ALREADY-resolved user key (nil = signed
62
+ # out), encoded exactly like the client's namespace tuple (docs
63
+ # decision 12): ["v1", app, "anonymous"] or
64
+ # ["v1", app, "user", key] as canonical JSON. STRUCTURE separates
65
+ # the fields, so no delimiter or "anonymous"-as-a-user-key
66
+ # collisions. Callers that also DISPLAY the key (the include-tag
67
+ # helper) resolve it once and pass that same value here -- two
68
+ # resolver evaluations could disagree mid-transition and stamp
69
+ # one user's epoch onto another user's page namespace.
70
+ def identity_for(user_key)
71
+ app_id = Funicular.configuration.application_id
72
+ if user_key.nil?
73
+ JSON.generate(["v1", app_id, "anonymous"])
74
+ else
75
+ JSON.generate(["v1", app_id, "user", user_key])
76
+ end
77
+ end
78
+
79
+ # The current identity, resolving the user key through the
80
+ # configured lambda.
81
+ def identity(controller)
82
+ identity_for(user_key(controller))
83
+ end
84
+
85
+ # Returns the application's current epoch, rotating it first
86
+ # whenever the stored identity differs from the computed one --
87
+ # login, logout, and a direct user switch are all just "the
88
+ # identity changed". Writes the session entry on rotation.
89
+ def stamp!(session, controller)
90
+ stamp_identity!(session, identity(controller))
91
+ end
92
+
93
+ # The rotation core against a pre-computed identity.
94
+ def stamp_identity!(session, current)
95
+ stored = session[SESSION_KEY]
96
+ epochs = stored.is_a?(Hash) ? stored : {}
97
+ app_id = Funicular.configuration.application_id
98
+ entry = epochs[app_id]
99
+ if entry.is_a?(Hash) && entry["identity"] == current
100
+ epoch = entry["epoch"].to_s
101
+ return epoch unless epoch.empty?
102
+ end
103
+ epoch = SecureRandom.hex(16)
104
+ epochs[app_id] = { "identity" => current, "epoch" => epoch }
105
+ session[SESSION_KEY] = epochs
106
+ epoch
107
+ end
108
+ end
109
+ end
110
+ end