karst 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "population_approvals"
4
+
5
+ module Karst
6
+ module Access
7
+ # Adds explicitly selected candidates from one current discovery pass to
8
+ # the local approval record. Submitted values are treated only as keys:
9
+ # the Entry objects written below always come from discovery itself.
10
+ class PopulationApproval
11
+ Result = Value.define(:record, :error) do
12
+ def saved?
13
+ error.nil? && record.error.nil?
14
+ end
15
+ end
16
+
17
+ SEPARATOR = "::"
18
+
19
+ def initialize(discovery:, principal_sources:, submitted:, record: PopulationApprovals.load)
20
+ @discovery = discovery
21
+ @principal_sources = principal_sources
22
+ @submitted = Array(submitted).map(&:to_s)
23
+ @record = record
24
+ end
25
+
26
+ def call
27
+ selected = selected_candidates
28
+ return rejected("Select at least one current candidate population.") if @submitted.empty?
29
+ return rejected("A submitted population is no longer available for this principal source.") unless selected
30
+
31
+ entries = @record.entries + selected.map do |candidate|
32
+ PopulationApprovals::Entry.new(model_name: candidate.model_name,
33
+ method_name: candidate.method_name.to_s)
34
+ end
35
+ record = PopulationApprovals.replace(entries)
36
+ Result.new(record: record, error: record.error)
37
+ end
38
+
39
+ private
40
+
41
+ def selected_candidates
42
+ candidates = applicable_candidates
43
+ by_key = candidates.to_h { |candidate| [candidate_key(candidate), candidate] }
44
+ return unless @submitted.uniq.size == @submitted.size
45
+ return unless @submitted.all? { |key| by_key.key?(key) }
46
+
47
+ @submitted.map { |key| by_key.fetch(key) }
48
+ end
49
+
50
+ def applicable_candidates
51
+ names = @principal_sources.keys.map(&:to_s)
52
+ @discovery.candidates.select do |candidate|
53
+ candidate.principal_source && names.include?(candidate.principal_source.to_s) &&
54
+ !@record.approved?(candidate.model_name, candidate.method_name)
55
+ end
56
+ end
57
+
58
+ def candidate_key(candidate)
59
+ [candidate.principal_source, candidate.model_name, candidate.method_name].join(SEPARATOR)
60
+ end
61
+
62
+ def rejected(message)
63
+ Result.new(record: @record, error: message)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "population_approvals"
4
+
5
+ module Karst
6
+ module Access
7
+ # Removes one exact, already-stored approval while preserving every other
8
+ # entry. Submitted values can never add or alter an approval.
9
+ class PopulationRevocation
10
+ Result = Value.define(:record, :revoked)
11
+
12
+ SEPARATOR = "::"
13
+
14
+ def initialize(submitted:, record: PopulationApprovals.load)
15
+ @submitted = submitted.to_s
16
+ @record = record
17
+ end
18
+
19
+ def call
20
+ entry = @record.entries.find { |candidate| key(candidate) == @submitted }
21
+ return Result.new(record: @record, revoked: false) unless entry
22
+
23
+ record = PopulationApprovals.replace(@record.entries - [entry])
24
+ Result.new(record: record, revoked: record.error.nil?)
25
+ end
26
+
27
+ private
28
+
29
+ def key(entry)
30
+ [entry.model_name, entry.method_name].join(SEPARATOR)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -15,8 +15,8 @@ module Karst
15
15
  # Process-level settings that control Karst's implemented behavior.
16
16
  #
17
17
  # An ordinary application sets none of this. A single-model Devise app is
18
- # inferred outright, candidate populations are approved locally at
19
- # /karst/populations rather than written here, and every limit below
18
+ # inferred outright, candidate populations are approved inline at /karst
19
+ # rather than written here, and every limit below
20
20
  # already has a bounded default. `enabled` is not normal configuration
21
21
  # either -- it is an off switch for exceptional cases (a shared
22
22
  # development environment, a CI job that boots Rails but must never run
@@ -83,7 +83,7 @@ module Karst
83
83
  "actually runs, and no longer keeps a process-wide sql.active_record buffer",
84
84
  principal_dimensions: "sampling states are derived from the schema automatically; there is " \
85
85
  "nothing to declare, and rare users are reached through candidate " \
86
- "populations approved at /karst/populations",
86
+ "populations approved after a failed /karst analysis",
87
87
  artifact_source: "artifact scenarios were removed; Karst analyzes routes, not record sweeps",
88
88
  access_scenario: "artifact scenarios were removed; Karst analyzes routes, not record sweeps"
89
89
  }.freeze
@@ -110,8 +110,8 @@ module Karst
110
110
  # auditors: -> { User.auditors }
111
111
  # }
112
112
  #
113
- # This is the committed-to-source form of what /karst/populations already
114
- # captures locally, and is needed only where machine-local approval state
113
+ # This is the committed-to-source form of what /karst captures locally,
114
+ # and is needed only where machine-local approval state
115
115
  # is deliberately not consulted (CI) or where populations should be
116
116
  # reviewable code. Karst never infers that a population grants access or
117
117
  # produces any UI state; it only tries records from it (see
@@ -9,11 +9,10 @@ rescue LoadError
9
9
  end
10
10
 
11
11
  module Karst
12
- # Request-local correlation storage used by the page badge and the RSpec
13
- # scenario observer to carry evidence from a notification callback (fired
14
- # nested inside a Rack call or an RSpec example) back out to the code that
12
+ # Request-local correlation storage used by the page badge to carry evidence
13
+ # from a notification callback fired inside a Rack call back out to the code that
15
14
  # reads it, without a global mutable variable that would let concurrent
16
- # requests or examples cross-contaminate each other.
15
+ # requests cross-contaminate each other.
17
16
  #
18
17
  # Modern Rails already solves exactly this with
19
18
  # ActiveSupport::IsolatedExecutionState, so Karst simply delegates to it
@@ -23,9 +22,9 @@ module Karst
23
22
  # fiber-local and would silently miss context under a Fiber scheduler.
24
23
  # This mirrors ActiveSupport::IsolatedExecutionState's own default :thread
25
24
  # isolation level: storage is shared by every Fiber running on one OS
26
- # thread, not isolated per Fiber. Karst's own usage (one badge or spec
27
- # correlation captured and read back within a single synchronous
28
- # request/example) never spans multiple concurrently-scheduled Fibers, so
25
+ # thread, not isolated per Fiber. Karst's own usage (one badge correlation
26
+ # captured and read back within a single synchronous request) never spans
27
+ # multiple concurrently-scheduled Fibers, so
29
28
  # this fallback has no observable effect on Karst's supported behavior. It
30
29
  # is documented here so a future caller does not assume Fiber isolation
31
30
  # this fallback cannot provide.
data/lib/karst/railtie.rb CHANGED
@@ -3,9 +3,10 @@
3
3
  require "rails/railtie"
4
4
 
5
5
  module Karst
6
- # Inserts Karst's development-only web surface and rake tasks. Karst
7
- # installs no notification subscription, no eager-loaded state, and nothing
8
- # that runs on an ordinary application request outside development.
6
+ # Inserts Karst's development-only web surface. Karst installs no
7
+ # notification subscription, no eager-loaded state, no rake tasks (its CLI
8
+ # ships as Rails::Command classes under lib/rails/commands instead), and
9
+ # nothing that runs on an ordinary application request outside development.
9
10
  class Railtie < Rails::Railtie
10
11
  # Must run before the middleware stack is built (a later Finisher
11
12
  # initializer), not in config.after_initialize, which runs after the stack
@@ -20,10 +21,6 @@ module Karst
20
21
 
21
22
  Rails.logger&.info("Karst: evidence at /karst")
22
23
  end
23
-
24
- rake_tasks do
25
- load File.expand_path("../tasks/karst.rake", __dir__)
26
- end
27
24
  end
28
25
 
29
26
  private_constant :Railtie
data/lib/karst/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Karst
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -3,20 +3,13 @@
3
3
  require "cgi"
4
4
  require "rack/utils"
5
5
 
6
- begin
7
- require_relative "../spec/catalog"
8
- rescue LoadError
9
- # Degrades to a countless badge rather than preventing the host response
10
- # from being served, mirroring Panel's own posture toward a missing Catalog.
11
- end
12
-
13
6
  module Karst
14
7
  module Web
15
8
  # Rewrites an eligible host HTML response to add a small, page-local link
16
- # into Karst's route-scoped scenario catalog. Every check here exists to
17
- # make injection safe to skip: Badge never guesses, never raises into the
18
- # host application, and never touches a response it cannot confidently
19
- # rewrite -- an untouched response is always the safe fallback.
9
+ # to Karst's route access panel. Every check here exists to make injection
10
+ # safe to skip: Badge never guesses, never raises into the host application,
11
+ # and never touches a response it cannot confidently rewrite -- an untouched
12
+ # response is always the safe fallback.
20
13
  #
21
14
  # Route identity (controller/action/http_method) comes from
22
15
  # Middleware, itself derived from a real process_action.action_controller
@@ -109,9 +102,8 @@ module Karst
109
102
 
110
103
  def markup(headers:, context:)
111
104
  href = escape("/karst?#{query_for(context)}")
112
- label = escape(label_for(context))
113
105
  style = inline_style_allowed?(headers) ? " style=\"#{BADGE_STYLE}\"" : ""
114
- "<a href=\"#{href}\"#{style}>#{label}</a>"
106
+ "<a href=\"#{href}\"#{style}>Karst</a>"
115
107
  end
116
108
 
117
109
  def query_for(context)
@@ -121,24 +113,6 @@ module Karst
121
113
  )
122
114
  end
123
115
 
124
- def label_for(context)
125
- catalog = load_catalog
126
- return "Karst" unless catalog && catalog.status == :ready
127
-
128
- count = catalog.scenarios_for(
129
- controller: context.controller, action: context.action, http_method: context.http_method
130
- ).size
131
- "Karst · #{count}"
132
- end
133
-
134
- def load_catalog
135
- return nil unless defined?(Karst::Spec::Catalog)
136
-
137
- Karst::Spec::Catalog.load
138
- rescue StandardError
139
- nil
140
- end
141
-
142
116
  # A CSP the host response itself declares is the only signal Badge
143
117
  # trusts: Karst never rewrites that header, only reads it, and falls
144
118
  # back to an unstyled link rather than risk a silently-dropped style
@@ -1,16 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
4
3
  require "uri"
5
- require "active_support/security_utils"
4
+ require_relative "csrf"
6
5
 
7
6
  module Karst
8
7
  module Web
9
- # State-changing browser identity operations and their same-session CSRF
10
- # token. Rails controller CSRF is unavailable because /karst is served at
11
- # the Rack boundary, before Action Controller dispatch.
8
+ # State-changing browser identity operations. Synchronizer-token behavior
9
+ # belongs to Web::Csrf and is shared with other Rack-boundary forms.
12
10
  class BrowserIdentity
13
- TOKEN_KEY = "karst.csrf_token"
14
11
  ACTIVE_KEY = "karst.browser_identity_active"
15
12
 
16
13
  # The exact Devise/Warden scope the currently assumed identity was
@@ -20,12 +17,13 @@ module Karst
20
17
  # several selected sources produced the principal being cleared.
21
18
  SCOPE_KEY = "karst.browser_identity_scope"
22
19
 
23
- def initialize(request)
20
+ def initialize(request, csrf: Csrf.new(request))
24
21
  @request = request
22
+ @csrf = csrf
25
23
  end
26
24
 
27
25
  def token
28
- session[TOKEN_KEY] ||= SecureRandom.hex(32)
26
+ @csrf.token
29
27
  end
30
28
 
31
29
  def active?
@@ -68,14 +66,13 @@ module Karst
68
66
  end
69
67
 
70
68
  def verify_token!(submitted)
71
- expected = session[TOKEN_KEY]
72
- valid = expected && submitted && expected.bytesize == submitted.bytesize &&
73
- ActiveSupport::SecurityUtils.secure_compare(expected, submitted)
74
- raise Identity::Unavailable, "invalid Karst CSRF token" unless valid
69
+ @csrf.verify!(submitted)
70
+ rescue Csrf::InvalidToken => e
71
+ raise Identity::Unavailable, e.message
75
72
  end
76
73
 
77
74
  def rotate_token!
78
- session[TOKEN_KEY] = SecureRandom.hex(32)
75
+ @csrf.rotate!
79
76
  end
80
77
 
81
78
  # A blank path is not a caller error: the panel renders this same
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "active_support/security_utils"
5
+
6
+ module Karst
7
+ module Web
8
+ # Synchronizer-token protection for state-changing forms served directly
9
+ # from Karst's Rack middleware, where Action Controller CSRF is unavailable.
10
+ class Csrf
11
+ TOKEN_KEY = "karst.csrf_token"
12
+
13
+ class InvalidToken < StandardError; end
14
+
15
+ def initialize(request)
16
+ @request = request
17
+ end
18
+
19
+ def token
20
+ session[TOKEN_KEY] ||= SecureRandom.hex(32)
21
+ end
22
+
23
+ def verify!(submitted)
24
+ expected = session[TOKEN_KEY]
25
+ valid = expected && submitted && expected.bytesize == submitted.bytesize &&
26
+ ActiveSupport::SecurityUtils.secure_compare(expected, submitted)
27
+ raise InvalidToken, "invalid Karst CSRF token" unless valid
28
+ end
29
+
30
+ def rotate!
31
+ session[TOKEN_KEY] = SecureRandom.hex(32)
32
+ end
33
+
34
+ private
35
+
36
+ def session
37
+ @request.session
38
+ rescue StandardError
39
+ raise InvalidToken, "a writable Rack session is required"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -5,6 +5,7 @@ require_relative "panel"
5
5
  require_relative "populations_panel"
6
6
  require_relative "badge"
7
7
  require_relative "browser_identity"
8
+ require_relative "csrf"
8
9
  require_relative "route_lookup"
9
10
  require_relative "../execution_context"
10
11
  require "rack/utils"
@@ -18,9 +19,9 @@ require_relative "../access/candidate_population"
18
19
  require_relative "../access/population_discovery"
19
20
  require_relative "../access/population_approvals"
20
21
  require_relative "../access/approved_populations"
21
- require_relative "../access/population_preview"
22
- require_relative "../access/population_config_snippet"
23
22
  require_relative "../access/principal_source_selection"
23
+ require_relative "../access/population_approval"
24
+ require_relative "../access/population_revocation"
24
25
  require_relative "../identity/devise_support"
25
26
 
26
27
  module Karst
@@ -52,9 +53,6 @@ module Karst
52
53
  POPULATIONS_PATH = "/karst/populations"
53
54
  private_constant :POPULATIONS_PATH
54
55
 
55
- CANDIDATE_SEPARATOR = "::"
56
- private_constant :CANDIDATE_SEPARATOR
57
-
58
56
  CONTEXT_KEY = :karst_web_request_context
59
57
  private_constant :CONTEXT_KEY
60
58
 
@@ -85,15 +83,21 @@ module Karst
85
83
  selection, denied = principal_source_selection_result(env, params)
86
84
  return denied if denied
87
85
 
88
- browser_identity = BrowserIdentity.new(Rack::Request.new(env))
86
+ request = Rack::Request.new(env)
87
+ csrf = Csrf.new(request)
88
+ browser_identity = BrowserIdentity.new(request, csrf: csrf)
89
+ approval, denied = inline_population_approval_result(env, params, csrf)
90
+ return denied if denied
91
+
89
92
  identity_response = mutate_browser_identity(env, params, browser_identity)
90
93
  return identity_response if identity_response
91
94
 
92
- result = analyze(env, params)
95
+ result = analyze(env, params, approval: approval)
96
+ candidates = inline_population_candidates(result)
93
97
  Panel.render(params: params, access_result: result, route_lookup_limitation: lookup&.limitation,
94
- csrf_token: browser_token(browser_identity),
98
+ csrf_token: csrf_token(csrf),
95
99
  browser_identity_active: browser_identity_active?(browser_identity),
96
- unapproved_candidate_count: unapproved_candidate_count(result),
100
+ unapproved_candidates: candidates, population_approval_error: approval&.error,
97
101
  principal_source_selection_saved: !selection.nil? && selection.error.nil?,
98
102
  principal_source_selection_error: selection&.error)
99
103
  end
@@ -119,6 +123,19 @@ module Karst
119
123
  env["REQUEST_METHOD"] == "POST" && params["operation"] == "select_principal_sources"
120
124
  end
121
125
 
126
+ def inline_population_approval_result(env, params, csrf)
127
+ return [nil, nil] unless env["REQUEST_METHOD"] == "POST" && params["operation"] == "approve_populations"
128
+ return [nil, forbidden] unless approved_origin?(env)
129
+
130
+ csrf.verify!(params["csrf_token"])
131
+ discovery = Access::PopulationDiscovery.new.call
132
+ approval = Access::PopulationApproval.new(discovery: discovery, principal_sources: Identity.principal_sources,
133
+ submitted: params["population"]).call
134
+ [approval, nil]
135
+ rescue Csrf::InvalidToken, Identity::Error
136
+ [nil, forbidden]
137
+ end
138
+
122
139
  # Only a model Devise itself currently maps can ever be written --
123
140
  # exactly like candidate-population approval only ever writes a
124
141
  # currently discovered scope -- so this form can never seed the file
@@ -130,58 +147,30 @@ module Karst
130
147
  Access::PrincipalSourceSelection.replace(names)
131
148
  end
132
149
 
133
- # Unlike every other operation Karst serves, approving writes local
134
- # state that outlives the request, so every POST to this path must
150
+ # Revocation changes local state that outlives the request, so every
151
+ # POST to this path must
135
152
  # have come from Karst's own page. There is no Rack session to hang a
136
153
  # CSRF token on here (this page deliberately touches none), so the
137
154
  # check is same-origin rather than token-based; a cross-site form POST
138
- # cannot forge Origin, so it cannot approve anything. GET -- the page
155
+ # cannot forge Origin, so it cannot revoke anything. GET -- the page
139
156
  # itself -- stays unauthenticated exactly like /karst.
140
157
  def call_populations(env)
141
158
  return forbidden unless approved_origin?(env)
142
159
 
143
160
  params = owned_params(env)
144
- discovery = Access::PopulationDiscovery.new.call
145
- saving = saving_approvals?(env, params)
146
- record = saving ? save_approvals(discovery, params) : Access::PopulationApprovals.load
147
- render_populations(discovery, params, record, saving)
161
+ revoking = env["REQUEST_METHOD"] == "POST" && params["operation"] == "revoke_population"
162
+ result = Access::PopulationRevocation.new(submitted: params["population"]).call if revoking
163
+ record = result&.record || Access::PopulationApprovals.load
164
+ render_populations(record, result&.revoked)
148
165
  end
149
166
 
150
- def render_populations(discovery, params, record, saved)
151
- approved = approved_candidates(discovery, record)
152
- snippet = Access::PopulationConfigSnippet.generate(approved) if params["generate_snippet"]
167
+ def render_populations(record, revoked)
153
168
  Web::PopulationsPanel.render(
154
- discovery: discovery, approved: record.entries, stale: stale_approvals(record),
155
- snippet: snippet, preview: population_preview(discovery, params),
156
- storage_path: Access::PopulationApprovals.display_path, storage_error: record.error, saved: saved
169
+ approved: record.entries, stale: stale_approvals(record),
170
+ storage_path: Access::PopulationApprovals.display_path, storage_error: record.error, revoked: revoked
157
171
  )
158
172
  end
159
173
 
160
- def saving_approvals?(env, params)
161
- env["REQUEST_METHOD"] == "POST" && params.key?("save_approvals")
162
- end
163
-
164
- # Only a candidate the *current* discovery result actually lists can
165
- # ever be written, so the file cannot be seeded through this form with
166
- # a model/scope pair Karst would refuse to confirm later anyway.
167
- def save_approvals(discovery, params)
168
- raw = Array(params["population"]).map(&:to_s)
169
- entries = discovery.candidates.filter_map do |candidate|
170
- next unless raw.include?(candidate_key(candidate))
171
-
172
- Access::PopulationApprovals::Entry.new(model_name: candidate.model_name,
173
- method_name: candidate.method_name.to_s)
174
- end
175
- Access::PopulationApprovals.replace(entries)
176
- end
177
-
178
- # Discovery candidates (which carry principal-source metadata the
179
- # snippet generator needs) for the approved entries that are still
180
- # discovered at all.
181
- def approved_candidates(discovery, record)
182
- discovery.candidates.select { |candidate| record.approved?(candidate.model_name, candidate.method_name) }
183
- end
184
-
185
174
  def stale_approvals(record)
186
175
  Access::ApprovedPopulations.stale(safe_principal_sources, record: record)
187
176
  rescue StandardError
@@ -213,21 +202,6 @@ module Karst
213
202
  [403, { "content-type" => "text/plain; charset=utf-8", "cache-control" => "no-store" }, ["Forbidden"]]
214
203
  end
215
204
 
216
- def population_preview(discovery, params)
217
- key = params["preview"].to_s
218
- return nil if key.empty?
219
-
220
- candidate = discovery.candidates.find { |item| candidate_key(item) == key }
221
- return nil unless candidate
222
-
223
- Access::PopulationPreview.call(model_name: candidate.model_name, method_name: candidate.method_name,
224
- discovery_result: discovery)
225
- end
226
-
227
- def candidate_key(candidate)
228
- "#{candidate.model_name}#{CANDIDATE_SEPARATOR}#{candidate.method_name}"
229
- end
230
-
231
205
  def call_with_badge(env)
232
206
  Karst::ExecutionContext[CONTEXT_KEY] = nil
233
207
  status, headers, body = @app.call(env)
@@ -262,9 +236,9 @@ module Karst
262
236
  # "try this population" operation for a developer to press --
263
237
  # and no path by which a merely discovered, unapproved population
264
238
  # name can be executed.
265
- def analyze(env, params)
239
+ def analyze(env, params, approval: nil)
266
240
  return nil unless env["REQUEST_METHOD"] == "POST"
267
- return nil unless params["operation"] == "access_sweep"
241
+ return nil unless params["operation"] == "access_sweep" || approval
268
242
 
269
243
  Access::Search.new(path: params["path"], http_method: params["method"],
270
244
  sources: Identity.principal_sources).call
@@ -272,22 +246,21 @@ module Karst
272
246
  e
273
247
  end
274
248
 
275
- # How many application-defined groups on an already-configured user
276
- # source a developer could still approve. Computed only after an
249
+ # Application-defined groups on an already-configured user source a
250
+ # developer could still approve. Computed only after an
277
251
  # analysis that found nothing usable -- the one moment the answer is
278
252
  # actionable -- so an ordinary panel render never parses model source,
279
- # and the main page never turns into a population-configuration
280
- # workflow. Discovery executes nothing; see PopulationDiscovery.
281
- def unapproved_candidate_count(result)
282
- return nil unless result.is_a?(Access::Search::Result) && result.verified_outcome.nil?
253
+ # and the main page stays a contextual approval step rather than a
254
+ # population-management dashboard. Discovery executes nothing.
255
+ def inline_population_candidates(result)
256
+ return [] unless result.is_a?(Access::Search::Result) && result.verified_outcome.nil?
283
257
 
284
258
  record = Access::PopulationApprovals.load
285
- count = Access::PopulationDiscovery.new.call.candidates.count do |candidate|
259
+ Access::PopulationDiscovery.new.call.candidates.select do |candidate|
286
260
  candidate.principal_source && !record.approved?(candidate.model_name, candidate.method_name)
287
261
  end
288
- count.positive? ? count : nil
289
262
  rescue StandardError
290
- nil
263
+ []
291
264
  end
292
265
 
293
266
  def mutate_browser_identity(env, params, browser_identity)
@@ -311,9 +284,9 @@ module Karst
311
284
  end
312
285
  end
313
286
 
314
- def browser_token(browser_identity)
315
- browser_identity.token if Identity.browser_supported?
316
- rescue Identity::Error
287
+ def csrf_token(csrf)
288
+ csrf.token
289
+ rescue Csrf::InvalidToken
317
290
  nil
318
291
  end
319
292
 
@@ -363,8 +336,7 @@ module Karst
363
336
  nil
364
337
  end
365
338
 
366
- # Mirrors Karst::Spec::Observer's own treatment of request paths: a
367
- # query string can carry a token (password reset, OAuth callback,
339
+ # A query string can carry a token (password reset, OAuth callback,
368
340
  # signed URL), and this path is only ever contextual display evidence
369
341
  # in the panel, never route identity, so it is never worth the risk.
370
342
  def strip_query(value)