funicular 0.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +429 -1
- data/demo/local_notes.html +207 -0
- data/docs/architecture.md +181 -3
- data/docs/local_database.md +1035 -0
- data/lib/funicular/assets/funicular.rb +14 -0
- data/lib/funicular/configuration.rb +65 -0
- data/lib/funicular/epoch_header.rb +69 -0
- data/lib/funicular/epoch_stamping.rb +66 -0
- data/lib/funicular/helpers/picoruby_helper.rb +96 -1
- data/lib/funicular/railtie.rb +30 -0
- data/lib/funicular/schema.rb +45 -12
- data/lib/funicular/session_epoch.rb +110 -0
- data/lib/funicular/ssr/runtime.rb +57 -12
- data/lib/funicular/ssr.rb +25 -0
- data/lib/funicular/testing/node_runner.mjs +19 -0
- data/lib/funicular/testing.rb +47 -0
- data/lib/funicular/vendor/mrbc/VERSION +1 -1
- data/lib/funicular/vendor/mrbc/mrbc.js +69 -115
- data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -1
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +170 -120
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -7201
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +1 -0
- data/lib/tasks/funicular.rake +10 -2
- data/minitest/callback_error_visibility_test.rb +48 -0
- data/minitest/configuration_test.rb +78 -0
- data/minitest/dsl_test.rb +27 -0
- data/minitest/epoch_header_test.rb +149 -0
- data/minitest/epoch_stamping_test.rb +225 -0
- data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
- data/minitest/navigation_guard_test.rb +65 -0
- data/minitest/picoruby_helper_test.rb +236 -0
- data/minitest/schema_test.rb +47 -0
- data/minitest/session_epoch_test.rb +122 -0
- data/minitest/ssr_database_test.rb +78 -0
- data/minitest/ssr_reload_test.rb +106 -0
- data/minitest/ssr_test.rb +41 -0
- data/minitest/testing_ensure_compiled_test.rb +52 -0
- data/minitest/validations_test.rb +35 -5
- data/mrbgem.rake +2 -0
- data/mrblib/cable.rb +1 -1
- data/mrblib/component.rb +113 -1
- data/mrblib/db.rb +3116 -0
- data/mrblib/file_upload.rb +17 -7
- data/mrblib/funicular.rb +136 -17
- data/mrblib/http.rb +84 -107
- data/mrblib/model.rb +1178 -23
- data/mrblib/relation.rb +342 -0
- data/mrblib/router.rb +45 -4
- data/mrblib/styles.rb +20 -0
- data/sig/component.rbs +7 -0
- data/sig/db.rbs +328 -0
- data/sig/funicular.rbs +5 -0
- data/sig/http.rbs +8 -21
- data/sig/model.rbs +101 -7
- data/sig/relation.rbs +44 -0
- data/sig/router.rbs +1 -0
- data/sig/styles.rbs +1 -0
- metadata +19 -2
- 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
|
-
|
|
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
|
data/lib/funicular/railtie.rb
CHANGED
|
@@ -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
|
data/lib/funicular/schema.rb
CHANGED
|
@@ -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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|