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.
@@ -1,32 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "cgi"
4
- require "securerandom"
5
- require_relative "../access/population_preview"
6
4
 
7
5
  module Karst
8
6
  module Web
9
- # The local approval surface for Karst::Access::PopulationDiscovery:
10
- # browse the application-defined groups Karst found, approve the ones
11
- # Karst may try, optionally preview one, and see which approvals are no
12
- # longer doing anything. Approving is the whole workflow -- generating
13
- # Ruby configuration is kept as an advanced export path, not the way a
14
- # developer is expected to finish.
15
- #
16
- # Kept as a pure renderer, exactly like Karst::Web::Panel: every value
17
- # this module needs (the discovery result, the approved entries, stale
18
- # approvals, an optional storage error, an optional generated snippet, an
19
- # optional single preview) is computed by Karst::Web::Middleware and
20
- # passed in, so this file never touches Active Record, the filesystem, or
21
- # Karst.config directly.
22
- # rubocop:disable Metrics/ModuleLength
7
+ # Advanced, local-only management for approvals already granted from the
8
+ # contextual /karst workflow. New approvals deliberately cannot be made
9
+ # here: this surface exists only so persistent state can be inspected and
10
+ # safely revoked, including after its model or scope becomes stale.
23
11
  module PopulationsPanel
24
- CANDIDATE_SEPARATOR = "::"
25
-
26
- # Why an approved entry currently does nothing (see
27
- # Karst::Access::ApprovedPopulations.stale). Reported rather than
28
- # silently ignored -- and never repaired automatically, since the fix
29
- # is always a decision about the application, not about Karst.
30
12
  STALE_REASONS = {
31
13
  not_discovered: "no longer a discovered scope on this model — not used",
32
14
  no_principal_source: "not part of a configured user source — not used"
@@ -36,356 +18,84 @@ module Karst
36
18
  STYLE = <<~CSS
37
19
  :root{color-scheme:light dark}
38
20
  *{box-sizing:border-box}
39
- body{font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;max-width:48rem;margin:2rem auto;padding:0 1.25rem;color:#1a1a1a;background:#fff}
40
- h1{font-size:1.4rem;margin:0 0 1rem}
41
- h2{font-size:1.1rem;margin:1.5rem 0 .5rem}
42
- h3{font-size:.85rem;text-transform:uppercase;letter-spacing:.05em;color:#555;margin:1.5rem 0 .6rem;border-bottom:1px solid #e2e2e2;padding-bottom:.35rem}
43
- a{color:#2563eb}
44
- .lead{margin:.4rem 0 1rem}
45
- .hint{color:#8a5b00;font-size:.85rem;margin:.4rem 0}
46
- .warning{color:#8a5b00;font-size:.85rem;margin:.4rem 0;border:1px solid #eacb6b;background:#fff7e0;border-radius:.4rem;padding:.6rem .8rem}
47
- .saved{color:#0f5132;font-size:.9rem;margin:.4rem 0;border:1px solid #a6d9bb;background:#e6f4ea;border-radius:.4rem;padding:.6rem .8rem}
48
- .search-box{margin:1rem 0}
49
- .search-box label{display:flex;flex-direction:column;font-size:.78rem;font-weight:600;gap:.25rem;color:#444}
50
- .search-box input{font:inherit;padding:.5rem .6rem;border:1px solid #ccc;border-radius:.3rem;max-width:24rem}
51
- button{font:inherit;padding:.35rem .65rem;border:1px solid #ccc;border-radius:.35rem;background:#f4f4f4;cursor:pointer}
52
- button:hover{background:#eaeaea}
53
- button:focus-visible,input:focus-visible,summary:focus-visible,a:focus-visible{outline:2px solid #2563eb;outline-offset:2px}
54
- button.primary{background:#202124;border-color:#202124;color:#fff;font-weight:600;padding:.65rem 1.15rem;font-size:.95rem;margin-top:1rem}
55
- button.primary:hover{background:#3a3b3e}
56
- .approved-summary{border:1px solid #ddd;border-radius:.5rem;padding:.75rem 1rem}
57
- .approved-model{margin:.4rem 0}
58
- .approved-model strong{display:block}
59
- .approved-model ul{margin:.2rem 0 0;padding-left:1.2rem}
60
- .approved-model .stale{color:#8a5b00;font-size:.85rem}
61
- details.model-group{border:1px solid #e2e2e2;border-radius:.4rem;padding:.5rem .8rem;margin:.5rem 0}
62
- details.model-group summary{cursor:pointer;font-weight:600;display:flex;gap:.6rem;align-items:baseline;flex-wrap:wrap}
63
- details.model-group summary .count{font-weight:400;color:#666;font-size:.85rem}
64
- details.model-group summary .badge{font-weight:400;font-size:.72rem;color:#0f5132;background:#e6f4ea;border-radius:.6rem;padding:.05rem .5rem}
65
- .candidate-list{list-style:none;margin:.6rem 0 0;padding:0}
66
- .candidate-row{display:flex;align-items:center;gap:.6rem;padding:.3rem 0;flex-wrap:wrap}
67
- .candidate-row label{display:flex;align-items:center;gap:.4rem;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.92rem}
68
- .preview{flex-basis:100%;margin:.2rem 0 .3rem 1.6rem;font-size:.85rem;color:#444}
69
- .preview.error{color:#8a5b00}
70
- .snippet{margin:.6rem 0}
71
- .snippet textarea{width:100%;min-height:6rem;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.85rem;padding:.5rem;border:1px solid #ccc;border-radius:.3rem}
72
- details.advanced{border:1px solid #e2e2e2;border-radius:.4rem;padding:.5rem .8rem;margin:1.5rem 0}
73
- details.advanced summary{cursor:pointer;font-size:.9rem}
74
- code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.9em}
75
- small{color:#666}
76
- @media (prefers-color-scheme:dark){
77
- body{background:#16171a;color:#e4e4e6}
78
- a{color:#7aa2f7}
79
- h3{color:#a7a7ad;border-color:#2c2d31}
80
- .search-box input,.snippet textarea{background:#1f2023;border-color:#3a3b3e;color:#e4e4e6}
81
- button{background:#26272b;border-color:#3a3b3e;color:#e4e4e6}
82
- button:hover{background:#303136}
83
- button.primary{background:#e4e4e6;border-color:#e4e4e6;color:#16171a}
84
- button.primary:hover{background:#c9c9cc}
85
- .approved-summary,details.model-group,details.advanced{border-color:#33343a}
86
- .hint,.warning,.approved-model .stale{color:#d8a63d}
87
- .warning{background:#3a2f0d;border-color:#6b5423}
88
- .saved{background:#123822;border-color:#1f5c37;color:#7fd8a4}
89
- details.model-group summary .badge{background:#123822;color:#7fd8a4}
90
- }
21
+ body{font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;max-width:42rem;margin:2rem auto;padding:0 1.25rem;color:#1a1a1a;background:#fff}
22
+ h1{font-size:1.4rem;margin:0 0 1rem} h2{font-size:1.1rem;margin:1.5rem 0 .5rem}
23
+ a{color:#2563eb} .lead,small{color:#666} ul{list-style:none;padding:0}
24
+ li{display:flex;justify-content:space-between;align-items:center;gap:1rem;border-top:1px solid #ddd;padding:.75rem 0}
25
+ code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
26
+ .stale{color:#8a5b00;font-size:.85rem;display:block}
27
+ .warning{color:#8a5b00;font-size:.85rem;border:1px solid #eacb6b;background:#fff7e0;border-radius:.4rem;padding:.6rem .8rem}
28
+ .saved{color:#0f5132;font-size:.9rem;border:1px solid #a6d9bb;background:#e6f4ea;border-radius:.4rem;padding:.6rem .8rem}
29
+ button{font:inherit;padding:.35rem .65rem;border:1px solid #b3261e;border-radius:.35rem;background:transparent;color:#b3261e;cursor:pointer}
30
+ button:focus-visible,a:focus-visible{outline:2px solid #2563eb;outline-offset:2px}
31
+ @media (prefers-color-scheme:dark){body{background:#16171a;color:#e4e4e6}.lead,small{color:#a7a7ad}li{border-color:#33343a}.stale,.warning{color:#d8a63d}.warning{background:#3a2f0d;border-color:#6b5423}.saved{background:#123822;border-color:#1f5c37;color:#7fd8a4}button{color:#ff817a;border-color:#ff817a}}
91
32
  CSS
92
33
  private_constant :STYLE
93
34
 
94
- SEARCH_SCRIPT = <<~JS
95
- (function () {
96
- var input = document.getElementById("karst-population-search");
97
- if (!input) return;
98
- var groups = document.querySelectorAll(".model-group");
99
- input.addEventListener("input", function () {
100
- var query = input.value.trim().toLowerCase();
101
- groups.forEach(function (details) {
102
- var model = (details.getAttribute("data-model") || "").toLowerCase();
103
- var modelMatches = query === "" || model.indexOf(query) !== -1;
104
- var rows = details.querySelectorAll(".candidate-row");
105
- var anyRowMatches = false;
106
- rows.forEach(function (row) {
107
- var name = (row.getAttribute("data-name") || "").toLowerCase();
108
- var rowMatches = query === "" || modelMatches || name.indexOf(query) !== -1;
109
- row.style.display = rowMatches ? "" : "none";
110
- if (rowMatches) anyRowMatches = true;
111
- });
112
- var show = query === "" || modelMatches || anyRowMatches;
113
- details.style.display = show ? "" : "none";
114
- if (query !== "" && show) details.open = true;
115
- });
116
- });
117
- })();
118
- JS
119
- private_constant :SEARCH_SCRIPT
120
-
121
- COPY_SCRIPT = <<~JS
122
- (function () {
123
- var button = document.getElementById("karst-copy-snippet");
124
- var field = document.getElementById("karst-snippet-code");
125
- if (!button || !field || !navigator.clipboard) return;
126
- button.addEventListener("click", function () {
127
- navigator.clipboard.writeText(field.value).then(function () {
128
- button.textContent = "Copied";
129
- setTimeout(function () { button.textContent = "Copy"; }, 1500);
130
- }, function () {});
131
- });
132
- })();
133
- JS
134
- private_constant :COPY_SCRIPT
135
-
136
- # rubocop:disable Metrics/ClassLength
137
35
  class << self
138
- # `approved` and each entry of `stale` are anything exposing
139
- # #model_name/#method_name -- in practice
140
- # Karst::Access::PopulationApprovals::Entry.
141
- # rubocop:disable Metrics/ParameterLists
142
- def render(discovery:, approved: [], stale: [], snippet: nil, preview: nil, storage_path: nil,
143
- storage_error: nil, saved: false)
144
- nonce = SecureRandom.hex(16)
145
- state = { approved: approved, stale: stale, snippet: snippet, preview: preview,
146
- storage_path: storage_path, storage_error: storage_error, saved: saved }
147
- [200, headers(nonce), [document(discovery, state, nonce)]]
36
+ def render(approved: [], stale: [], storage_path: nil, storage_error: nil, revoked: false)
37
+ [200, headers, [document(approved, stale, storage_path, storage_error, revoked)]]
148
38
  end
149
- # rubocop:enable Metrics/ParameterLists
150
39
 
151
40
  private
152
41
 
153
- def headers(nonce)
154
- csp = "default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-#{nonce}'; frame-ancestors 'none'"
42
+ def headers
155
43
  {
156
44
  "content-type" => "text/html; charset=utf-8", "cache-control" => "no-store",
157
- "x-robots-tag" => "noindex, nofollow", "x-frame-options" => "DENY", "content-security-policy" => csp
45
+ "x-robots-tag" => "noindex, nofollow", "x-frame-options" => "DENY",
46
+ "content-security-policy" => "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'"
158
47
  }
159
48
  end
160
49
 
161
- # The per-candidate Preview and the advanced snippet export submit
162
- # through their own form, referenced by `form=` rather than nested
163
- # inside the approval form (HTML forbids nesting): pressing either
164
- # therefore cannot smuggle checkbox state into a save, so the only
165
- # way to change what Karst may execute is the explicit approve
166
- # button.
167
- def document(discovery, state, nonce)
50
+ def document(approved, stale, storage_path, storage_error, revoked)
168
51
  <<~HTML
169
- <!DOCTYPE html>
170
- <html lang="en"><head><meta charset="utf-8"><title>Karst — Candidate groups</title>
171
- <style>#{STYLE}</style>
172
- </head><body>
173
- <h1>Karst</h1>
52
+ <!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>Karst — Manage approvals</title>
53
+ <style>#{STYLE}</style></head><body><h1>Karst</h1>
174
54
  <p><a href="/karst">&larr; Back to route evidence</a></p>
175
- <h2>Candidate groups</h2>
176
- <p class="lead">Approving a group lets Karst try a few existing users from it when the ordinary sample
177
- fails. Approving is not a claim that the group grants access — only running an analysis against a route
178
- shows what actually happens.</p>
179
- #{saved_notice(state[:saved])}
180
- #{storage_error(state[:storage_error])}
181
- #{load_warning(discovery)}
182
- <form id="karst-secondary" method="post" action="/karst/populations"></form>
183
- <form method="post" action="/karst/populations">
184
- #{search_box}
185
- #{approved_section(state[:approved], state[:stale])}
186
- #{model_groups_section(discovery, state[:approved], state[:preview])}
187
- <button type="submit" name="save_approvals" value="1" class="primary">Approve selected groups</button>
188
- </form>
189
- #{advanced_section(state[:snippet])}
190
- #{storage_note(state[:storage_path])}
191
- <script nonce="#{nonce}">#{SEARCH_SCRIPT}#{COPY_SCRIPT}</script>
192
- </body></html>
55
+ <h2>Manage population approvals</h2>
56
+ <p class="lead">Advanced local-state management. Approve new candidate populations only when a failed route analysis offers them on <code>/karst</code>.</p>
57
+ #{notice(revoked)}#{storage_error_message(storage_error)}#{approval_list(approved, stale)}
58
+ #{storage_note(storage_path)}</body></html>
193
59
  HTML
194
60
  end
195
61
 
196
- def saved_notice(saved)
197
- return "" unless saved
198
-
199
- "<p class=\"saved\" role=\"status\">Approvals saved.</p>"
200
- end
201
-
202
- def storage_error(error)
203
- return "" unless error
204
-
205
- "<p class=\"warning\" role=\"alert\">#{escape(error)}</p>"
206
- end
207
-
208
- def storage_note(path)
209
- return "" unless path
210
-
211
- "<p><small>Approvals are stored locally in <code>#{escape(path)}</code> as plain model and " \
212
- "scope names — no user data, no Ruby. Delete that file to reset every approval.</small></p>"
213
- end
214
-
215
- def load_warning(discovery)
216
- return "" unless discovery.load_warning
217
-
218
- "<p class=\"warning\" role=\"alert\">#{escape(discovery.load_warning)}</p>"
219
- end
220
-
221
- def search_box
222
- <<~HTML
223
- <div class="search-box">
224
- <label>Search groups or models
225
- <input type="search" id="karst-population-search" placeholder="e.g. admin, User, subscription">
226
- </label>
227
- </div>
228
- HTML
229
- end
230
-
231
- # -- Approved summary --------------------------------------------------
232
-
233
- def approved_section(approved, stale)
234
- body = if approved.empty?
235
- "<p>No groups approved yet.</p>"
236
- else
237
- approved.group_by(&:model_name).sort.map do |model_name, group|
238
- approved_model(model_name, group, stale)
239
- end.join
240
- end
241
- "<section class=\"approved-summary\"><h3>Approved (#{approved.size})</h3>#{body}</section>"
242
- end
243
-
244
- def approved_model(model_name, entries, stale)
245
- items = entries.sort_by { |entry| entry.method_name.to_s }.map do |entry|
246
- "<li>#{escape(entry.method_name)}#{stale_note(entry, stale)}</li>"
247
- end.join
248
- "<div class=\"approved-model\"><strong>#{escape(model_name)}</strong><ul>#{items}</ul></div>"
249
- end
250
-
251
- def stale_note(entry, stale)
252
- match = stale.find { |item, _reason| same_candidate?(item, entry) }
253
- return "" unless match
254
-
255
- " <span class=\"stale\">— #{escape(STALE_REASONS.fetch(match.last, 'not used'))}</span>"
256
- end
257
-
258
- def same_candidate?(left, right)
259
- left.model_name.to_s == right.model_name.to_s && left.method_name.to_s == right.method_name.to_s
260
- end
261
-
262
- # -- Model groups ------------------------------------------------------
62
+ def approval_list(approved, stale)
63
+ return "<p>No population approvals are stored.</p>" if approved.empty?
263
64
 
264
- def model_groups_section(discovery, approved, preview)
265
- groups = discovery.model_groups.reject { |group| group.candidate_names.empty? }
266
- body = if groups.empty?
267
- "<p>No candidate groups were discovered.</p>"
268
- else
269
- groups.map { |group| model_group(group, approved, preview) }.join
270
- end
271
- "<section class=\"model-groups\"><h3>Available models</h3>#{body}</section>"
65
+ items = approved.map { |entry| approval_item(entry, stale) }.join
66
+ "<ul>#{items}</ul>"
272
67
  end
273
68
 
274
- def model_group(group, approved, preview)
275
- approved_names = approved.select { |entry| entry.model_name == group.model_name }
276
- .map { |entry| entry.method_name.to_s }
277
- open = approved_names.any? ? " open" : ""
278
- rows = group.candidate_names.map { |name| candidate_row(group, name, approved_names, preview) }.join
69
+ def approval_item(entry, stale)
70
+ match = stale.find { |item, _reason| item.matches?(entry.model_name, entry.method_name) }
71
+ note = match ? "<span class=\"stale\">#{escape(STALE_REASONS.fetch(match.last, 'not used'))}</span>" : ""
279
72
  <<~HTML
280
- <details class="model-group" data-model="#{escape(group.model_name)}"#{open}>
281
- <summary>#{model_summary(group)}</summary>
282
- <ul class="candidate-list">#{rows}</ul>
283
- </details>
73
+ <li><span><code>#{escape(entry.display_label)}</code>#{note}</span>
74
+ <form method="post" action="/karst/populations"><input type="hidden" name="operation" value="revoke_population">
75
+ <input type="hidden" name="population" value="#{escape(entry.model_name)}::#{escape(entry.method_name)}">
76
+ <button type="submit">Revoke</button></form></li>
284
77
  HTML
285
78
  end
286
79
 
287
- def model_summary(group)
288
- count = group.candidate_names.size
289
- "#{escape(group.model_name)} <span class=\"count\">#{count} group#{'s' unless count == 1}</span>" \
290
- "#{principal_badge(group)}"
291
- end
292
-
293
- def principal_badge(group)
294
- return "" unless group.principal_source
295
-
296
- "<span class=\"badge\">user source: #{escape(group.principal_source)}</span>"
297
- end
298
-
299
- def candidate_row(group, method_name, approved_names, preview)
300
- key = candidate_key(group.model_name, method_name)
301
- checked = approved_names.include?(method_name.to_s)
302
- box = "<input type=\"checkbox\" name=\"population[]\" value=\"#{escape(key)}\"#{' checked' if checked}>"
303
- preview_button = "<button type=\"submit\" form=\"karst-secondary\" name=\"preview\" " \
304
- "value=\"#{escape(key)}\">Preview</button>"
305
- label = "<label>#{box} #{escape(method_name)}</label>"
306
- "<li class=\"candidate-row\" data-name=\"#{escape(method_name)}\">#{label}" \
307
- "#{preview_button}#{preview_result(group, method_name, preview)}</li>"
308
- end
309
-
310
- def preview_result(group, method_name, preview)
311
- unless preview && preview.model_name == group.model_name && preview.method_name.to_s == method_name.to_s
312
- return ""
313
- end
314
-
315
- preview.resolved ? preview_success(preview) : preview_failure(preview)
316
- end
317
-
318
- def preview_success(preview)
319
- return "<p class=\"preview\">Preview: no matching records currently.</p>" if preview.records.empty?
320
-
321
- labels = preview.records.map { |record| record_label(record) }.join(", ")
322
- "<p class=\"preview\">Preview (up to #{Access::PopulationPreview::PREVIEW_LIMIT}): #{escape(labels)}</p>"
323
- end
324
-
325
- def preview_failure(preview)
326
- "<p class=\"preview error\">Preview: #{escape(preview.error)}.</p>"
80
+ def notice(revoked)
81
+ revoked ? '<p class="saved" role="status">Approval revoked.</p>' : ""
327
82
  end
328
83
 
329
- # Deliberately generic -- "ModelName #id" only, never an arbitrary
330
- # attribute dump, regardless of whether the previewed model happens
331
- # to be a configured principal source. A discovered candidate may
332
- # belong to any application model, not only ones a developer has
333
- # already reviewed for what is safe to display.
334
- def record_label(record)
335
- primary_key = record.class.respond_to?(:primary_key) ? record.class.primary_key : "id"
336
- "#{record.class.name} ##{record.public_send(primary_key)}"
84
+ def storage_error_message(error)
85
+ error ? "<p class=\"warning\" role=\"alert\">#{escape(error)}</p>" : ""
337
86
  end
338
87
 
339
- # -- Advanced: Ruby export ---------------------------------------------
340
-
341
- # Approving is the workflow; this stays available for an application
342
- # that would rather commit its populations as reviewable Ruby (a
343
- # shared or CI environment, where a machine-local approval file is
344
- # deliberately not consulted).
345
- def advanced_section(snippet)
346
- <<~HTML
347
- <details class="advanced"#{' open' if snippet}><summary>Advanced: export approvals as Ruby
348
- configuration</summary>
349
- <p><small>Explicit <code>config.principal_populations</code> keeps working exactly as before and always
350
- takes precedence over an approval of the same name. Karst never writes to your application's files.
351
- </small></p>
352
- <button type="submit" form="karst-secondary" name="generate_snippet" value="1">Generate snippet from
353
- approvals</button>
354
- #{snippet_section(snippet)}
355
- </details>
356
- HTML
357
- end
358
-
359
- def snippet_section(snippet)
360
- return "" unless snippet
361
-
362
- <<~HTML
363
- <div class="snippet">
364
- <textarea id="karst-snippet-code" readonly>#{escape(snippet.code)}</textarea>
365
- <p><button type="button" id="karst-copy-snippet">Copy</button></p>
366
- #{unwired_note(snippet)}
367
- </div>
368
- HTML
369
- end
370
-
371
- def unwired_note(snippet)
372
- return "" if snippet.unwired.empty?
373
-
374
- names = snippet.unwired.map { |c| "#{c.model_name}.#{c.method_name}" }.join(", ")
375
- "<p class=\"hint\">#{snippet.unwired.size} approved group(s) are not part of a configured " \
376
- "user source yet and were left out of the snippet above: #{escape(names)}.</p>"
377
- end
88
+ def storage_note(path)
89
+ return "" unless path
378
90
 
379
- def candidate_key(model_name, method_name)
380
- "#{model_name}#{CANDIDATE_SEPARATOR}#{method_name}"
91
+ "<p><small>Approvals are stored locally in <code>#{escape(path)}</code> " \
92
+ "as model and scope names only.</small></p>"
381
93
  end
382
94
 
383
95
  def escape(value)
384
96
  CGI.escapeHTML(value.to_s)
385
97
  end
386
98
  end
387
- # rubocop:enable Metrics/ClassLength
388
99
  end
389
- # rubocop:enable Metrics/ModuleLength
390
100
  end
391
101
  end
data/lib/karst.rb CHANGED
@@ -17,13 +17,11 @@ require_relative "karst/access/principal_source"
17
17
  require_relative "karst/access/principal_sampler"
18
18
  require_relative "karst/access/principal_selection"
19
19
  require_relative "karst/access/search"
20
- require_relative "karst/access/resource_evidence"
21
20
  require_relative "karst/access/candidate_population"
22
21
  require_relative "karst/access/population_discovery"
23
22
  require_relative "karst/access/population_approvals"
23
+ require_relative "karst/access/population_approval"
24
24
  require_relative "karst/access/approved_populations"
25
- require_relative "karst/access/population_preview"
26
- require_relative "karst/access/population_config_snippet"
27
25
  require_relative "karst/access/principal_source_selection"
28
26
  require_relative "karst/access/selected_principal_sources"
29
27
 
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails/command"
4
- require "karst/mcp/server"
5
4
  require_relative "../boot"
6
5
 
7
6
  module Rails
@@ -17,9 +16,23 @@ module Rails
17
16
 
18
17
  desc "Run Karst's MCP server over stdio, exposing the verify_access tool"
19
18
  def perform(*)
19
+ load_mcp!
20
20
  boot_karst_application!
21
21
  ::Karst::Mcp::Server.run!
22
22
  end
23
+
24
+ private
25
+
26
+ def load_mcp!
27
+ begin
28
+ gem "mcp", "~> 0.9.0"
29
+ rescue Gem::LoadError
30
+ abort "Karst MCP requires the optional dependency. " \
31
+ 'Add gem "mcp", "~> 0.9.0" to your Gemfile and run bundle install.'
32
+ end
33
+
34
+ require "karst/mcp/server"
35
+ end
23
36
  end
24
37
  end
25
38
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: karst
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Karst contributors
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-12 00:00:00.000000000 Z
11
+ date: 2026-08-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -37,7 +37,7 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: 0.9.0
40
- type: :runtime
40
+ type: :development
41
41
  prerelease: false
42
42
  version_requirements: !ruby/object:Gem::Requirement
43
43
  requirements:
@@ -59,6 +59,7 @@ files:
59
59
  - README.md
60
60
  - SECURITY.md
61
61
  - docs/advanced-configuration.md
62
+ - docs/rails8-authentication.md
62
63
  - lib/generators/karst/install/install_generator.rb
63
64
  - lib/generators/karst/install/templates/karst_identity_controller.rb
64
65
  - lib/generators/karst/install/templates/karst_initializer.rb
@@ -66,16 +67,15 @@ files:
66
67
  - lib/karst/access/approved_populations.rb
67
68
  - lib/karst/access/candidate_population.rb
68
69
  - lib/karst/access/database_isolation.rb
70
+ - lib/karst/access/population_approval.rb
69
71
  - lib/karst/access/population_approvals.rb
70
- - lib/karst/access/population_config_snippet.rb
71
72
  - lib/karst/access/population_discovery.rb
72
- - lib/karst/access/population_preview.rb
73
+ - lib/karst/access/population_revocation.rb
73
74
  - lib/karst/access/principal_sampler.rb
74
75
  - lib/karst/access/principal_selection.rb
75
76
  - lib/karst/access/principal_source.rb
76
77
  - lib/karst/access/principal_source_selection.rb
77
78
  - lib/karst/access/probe_application.rb
78
- - lib/karst/access/resource_evidence.rb
79
79
  - lib/karst/access/search.rb
80
80
  - lib/karst/access/selected_principal_sources.rb
81
81
  - lib/karst/access/sensitive_attribute_names.rb
@@ -89,17 +89,11 @@ files:
89
89
  - lib/karst/mcp/server.rb
90
90
  - lib/karst/mcp/verify_access_tool.rb
91
91
  - lib/karst/railtie.rb
92
- - lib/karst/spec/catalog.rb
93
- - lib/karst/spec/example_observation.rb
94
- - lib/karst/spec/observer.rb
95
- - lib/karst/spec/principal.rb
96
- - lib/karst/spec/reporter.rb
97
- - lib/karst/spec/request_observation.rb
98
- - lib/karst/spec/scenario.rb
99
92
  - lib/karst/value.rb
100
93
  - lib/karst/version.rb
101
94
  - lib/karst/web/badge.rb
102
95
  - lib/karst/web/browser_identity.rb
96
+ - lib/karst/web/csrf.rb
103
97
  - lib/karst/web/locality.rb
104
98
  - lib/karst/web/middleware.rb
105
99
  - lib/karst/web/panel.rb
@@ -108,7 +102,6 @@ files:
108
102
  - lib/rails/commands/karst/boot.rb
109
103
  - lib/rails/commands/karst/mcp/mcp_command.rb
110
104
  - lib/rails/commands/karst/verify/verify_command.rb
111
- - lib/tasks/karst.rake
112
105
  homepage: https://github.com/SilenceDogood1984/karst
113
106
  licenses:
114
107
  - MIT
@@ -1,67 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../value"
4
-
5
- module Karst
6
- module Access
7
- # Renders a developer's curated selection of
8
- # Karst::Access::PopulationDiscovery::Candidate into a Ruby config
9
- # snippet they can copy into their own Karst.configure block. Karst
10
- # never writes to the host application's files on its own -- the
11
- # developer stays in control of what actually gets committed (see
12
- # README "Curation / persistence").
13
- module PopulationConfigSnippet
14
- # wired is the subset actually rendered into `code` (every selected
15
- # candidate whose model matches a configured principal source).
16
- # unwired is every selected candidate that is not wired into
17
- # anything yet -- present so the UI can say so honestly instead of
18
- # silently dropping a selection (see README "Principal vs artifact
19
- # populations": this release only wires principal populations).
20
- Result = Value.define(:code, :wired, :unwired)
21
-
22
- class << self
23
- def generate(candidates)
24
- unique = candidates.uniq { |candidate| [candidate.model_name, candidate.method_name] }
25
- wired, unwired = unique.partition(&:principal_source)
26
- Result.new(code: render(wired), wired: wired, unwired: unwired)
27
- end
28
-
29
- private
30
-
31
- def render(wired)
32
- return "# Select at least one population above to generate a configuration snippet.\n" if wired.empty?
33
-
34
- by_source = wired.group_by(&:principal_source)
35
- by_source.size == 1 && by_source.keys.first == :default ? flat(by_source.fetch(:default)) : nested(by_source)
36
- end
37
-
38
- def flat(candidates)
39
- "config.principal_populations = {\n#{entries(candidates, indent: 2)}\n}\n"
40
- end
41
-
42
- def nested(by_source)
43
- sources = by_source.map do |source, group|
44
- " #{source_key(source)}: {\n populations: {\n#{entries(group, indent: 6)}\n }\n }"
45
- end.join(",\n")
46
- "config.principal_sources = {\n#{sources}\n}\n"
47
- end
48
-
49
- # Sorted by model then method so the same selection always renders
50
- # byte-identical output, regardless of checkbox submission order.
51
- def entries(candidates, indent:)
52
- pad = " " * indent
53
- sorted = candidates.sort_by { |candidate| [candidate.model_name, candidate.method_name.to_s] }
54
- sorted.map { |candidate| entry(candidate, pad) }.join(",\n")
55
- end
56
-
57
- def entry(candidate, pad)
58
- "#{pad}#{candidate.method_name}: -> { #{candidate.model_name}.#{candidate.method_name} }"
59
- end
60
-
61
- def source_key(source)
62
- source.to_s =~ /\A[a-zA-Z_][a-zA-Z0-9_]*\z/ ? source : source.inspect
63
- end
64
- end
65
- end
66
- end
67
- end