sentiero 1.0.0.alpha1

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 (155) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +7 -0
  3. data/README.md +679 -0
  4. data/lib/sentiero/analytics/analyzer.rb +91 -0
  5. data/lib/sentiero/analytics/bounded.rb +29 -0
  6. data/lib/sentiero/analytics/browser_event_discovery.rb +70 -0
  7. data/lib/sentiero/analytics/collectors/click_collector.rb +135 -0
  8. data/lib/sentiero/analytics/collectors/custom_tag_collector.rb +61 -0
  9. data/lib/sentiero/analytics/collectors/error_collector.rb +89 -0
  10. data/lib/sentiero/analytics/collectors/form_collector.rb +156 -0
  11. data/lib/sentiero/analytics/collectors/frustration_collector.rb +85 -0
  12. data/lib/sentiero/analytics/collectors/scroll_collector.rb +156 -0
  13. data/lib/sentiero/analytics/collectors/vitals_collector.rb +104 -0
  14. data/lib/sentiero/analytics/conversion_analyzer.rb +247 -0
  15. data/lib/sentiero/analytics/engagement_analyzer.rb +331 -0
  16. data/lib/sentiero/analytics/entry_attribution.rb +71 -0
  17. data/lib/sentiero/analytics/error_discovery.rb +118 -0
  18. data/lib/sentiero/analytics/events.rb +21 -0
  19. data/lib/sentiero/analytics/exporter.rb +242 -0
  20. data/lib/sentiero/analytics/form_analyzer.rb +153 -0
  21. data/lib/sentiero/analytics/frustration/detectors.rb +158 -0
  22. data/lib/sentiero/analytics/frustration_analyzer.rb +235 -0
  23. data/lib/sentiero/analytics/funnel_analyzer.rb +160 -0
  24. data/lib/sentiero/analytics/heatmap_analyzer.rb +93 -0
  25. data/lib/sentiero/analytics/page_report_analyzer.rb +198 -0
  26. data/lib/sentiero/analytics/problem_detail.rb +97 -0
  27. data/lib/sentiero/analytics/scroll_depth_analyzer.rb +30 -0
  28. data/lib/sentiero/analytics/segmenter.rb +133 -0
  29. data/lib/sentiero/analytics/server_event_metrics.rb +120 -0
  30. data/lib/sentiero/analytics/stats.rb +30 -0
  31. data/lib/sentiero/analytics/stats_aggregator/result_builder.rb +153 -0
  32. data/lib/sentiero/analytics/stats_aggregator.rb +346 -0
  33. data/lib/sentiero/analytics/web_vitals_analyzer.rb +57 -0
  34. data/lib/sentiero/configuration.rb +184 -0
  35. data/lib/sentiero/erasure.rb +48 -0
  36. data/lib/sentiero/fingerprint.rb +34 -0
  37. data/lib/sentiero/ip_anonymizer.rb +29 -0
  38. data/lib/sentiero/redaction/config.rb +61 -0
  39. data/lib/sentiero/redaction.rb +207 -0
  40. data/lib/sentiero/reporter/configuration.rb +50 -0
  41. data/lib/sentiero/reporter/context.rb +31 -0
  42. data/lib/sentiero/reporter/dispatcher.rb +91 -0
  43. data/lib/sentiero/reporter/http_transport.rb +57 -0
  44. data/lib/sentiero/reporter/log_transport.rb +26 -0
  45. data/lib/sentiero/reporter/middleware.rb +62 -0
  46. data/lib/sentiero/reporter/normalizer.rb +14 -0
  47. data/lib/sentiero/reporter/null_transport.rb +18 -0
  48. data/lib/sentiero/reporter/report_context.rb +29 -0
  49. data/lib/sentiero/reporter/scrubber.rb +47 -0
  50. data/lib/sentiero/reporter/test_helper.rb +32 -0
  51. data/lib/sentiero/reporter/test_transport.rb +28 -0
  52. data/lib/sentiero/reporter.rb +214 -0
  53. data/lib/sentiero/roda.rb +47 -0
  54. data/lib/sentiero/store/error_store.rb +220 -0
  55. data/lib/sentiero/store/limits.rb +31 -0
  56. data/lib/sentiero/store/session_store.rb +118 -0
  57. data/lib/sentiero/store.rb +72 -0
  58. data/lib/sentiero/stores/file.rb +566 -0
  59. data/lib/sentiero/stores/memory.rb +362 -0
  60. data/lib/sentiero/stores/redis/keys.rb +59 -0
  61. data/lib/sentiero/stores/redis/lua.rb +119 -0
  62. data/lib/sentiero/stores/redis.rb +665 -0
  63. data/lib/sentiero/stores/sqlite/schema.rb +79 -0
  64. data/lib/sentiero/stores/sqlite.rb +626 -0
  65. data/lib/sentiero/user_agent.rb +32 -0
  66. data/lib/sentiero/version.rb +5 -0
  67. data/lib/sentiero/web/analytics_app.rb +538 -0
  68. data/lib/sentiero/web/assets/analytics-RH24EOLD.js +1 -0
  69. data/lib/sentiero/web/assets/dashboard-JFYNHZZV.js +3 -0
  70. data/lib/sentiero/web/assets/heatmap-EBKFWSKN.js +1 -0
  71. data/lib/sentiero/web/assets/import-HIMBJJ4S.js +1 -0
  72. data/lib/sentiero/web/assets/manifest.json +11 -0
  73. data/lib/sentiero/web/assets/recorder-SLLXSUUX.js +71 -0
  74. data/lib/sentiero/web/assets/rrweb-player-cd435a95.js +126 -0
  75. data/lib/sentiero/web/assets/rrweb-player-css-ce5e9629.css +2 -0
  76. data/lib/sentiero/web/assets/sessions_index-2RAGTEZM.js +1 -0
  77. data/lib/sentiero/web/assets/style-d71e72fd.css +2 -0
  78. data/lib/sentiero/web/assets_app.rb +42 -0
  79. data/lib/sentiero/web/base_app.rb +319 -0
  80. data/lib/sentiero/web/basic_auth.rb +27 -0
  81. data/lib/sentiero/web/basic_auth_check.rb +41 -0
  82. data/lib/sentiero/web/body_reader.rb +44 -0
  83. data/lib/sentiero/web/csv_writer.rb +45 -0
  84. data/lib/sentiero/web/dashboard_app.rb +236 -0
  85. data/lib/sentiero/web/errors_app.rb +97 -0
  86. data/lib/sentiero/web/escaping.rb +37 -0
  87. data/lib/sentiero/web/events_app.rb +196 -0
  88. data/lib/sentiero/web/formatting.rb +43 -0
  89. data/lib/sentiero/web/ingest_app.rb +92 -0
  90. data/lib/sentiero/web/manifest.rb +43 -0
  91. data/lib/sentiero/web/monitoring_app.rb +316 -0
  92. data/lib/sentiero/web/script_tag.rb +57 -0
  93. data/lib/sentiero/web/shareable_replay.rb +88 -0
  94. data/lib/sentiero/web/templates/_analytics_nav.html.erb +22 -0
  95. data/lib/sentiero/web/templates/_brand.html.erb +18 -0
  96. data/lib/sentiero/web/templates/_date_range.html.erb +18 -0
  97. data/lib/sentiero/web/templates/_errors_client_filter.html.erb +25 -0
  98. data/lib/sentiero/web/templates/_errors_server_filter.html.erb +36 -0
  99. data/lib/sentiero/web/templates/_events_browser_filter.html.erb +18 -0
  100. data/lib/sentiero/web/templates/_events_server_filter.html.erb +39 -0
  101. data/lib/sentiero/web/templates/_pagination.html.erb +14 -0
  102. data/lib/sentiero/web/templates/_payload_metrics.html.erb +62 -0
  103. data/lib/sentiero/web/templates/_session_row.html.erb +42 -0
  104. data/lib/sentiero/web/templates/_sibling_tab_hint.html.erb +6 -0
  105. data/lib/sentiero/web/templates/_tabs.html.erb +10 -0
  106. data/lib/sentiero/web/templates/_truncation_warning.html.erb +19 -0
  107. data/lib/sentiero/web/templates/_window_tab.html.erb +5 -0
  108. data/lib/sentiero/web/templates/analytics_conversions.html.erb +94 -0
  109. data/lib/sentiero/web/templates/analytics_engagement.html.erb +101 -0
  110. data/lib/sentiero/web/templates/analytics_frustration.html.erb +135 -0
  111. data/lib/sentiero/web/templates/analytics_funnel.html.erb +103 -0
  112. data/lib/sentiero/web/templates/analytics_index.html.erb +380 -0
  113. data/lib/sentiero/web/templates/analytics_page.html.erb +287 -0
  114. data/lib/sentiero/web/templates/analytics_scroll.html.erb +94 -0
  115. data/lib/sentiero/web/templates/analytics_vitals.html.erb +91 -0
  116. data/lib/sentiero/web/templates/client_error_show.html.erb +73 -0
  117. data/lib/sentiero/web/templates/dashboard.html.erb +56 -0
  118. data/lib/sentiero/web/templates/errors_index.html.erb +149 -0
  119. data/lib/sentiero/web/templates/event_show.html.erb +52 -0
  120. data/lib/sentiero/web/templates/events_index.html.erb +177 -0
  121. data/lib/sentiero/web/templates/export_index.html.erb +69 -0
  122. data/lib/sentiero/web/templates/forms.html.erb +105 -0
  123. data/lib/sentiero/web/templates/heatmap.html.erb +76 -0
  124. data/lib/sentiero/web/templates/import.html.erb +39 -0
  125. data/lib/sentiero/web/templates/problem_show.html.erb +200 -0
  126. data/lib/sentiero/web/templates/segments.html.erb +114 -0
  127. data/lib/sentiero/web/templates/session_show.html.erb +195 -0
  128. data/lib/sentiero/web/templates/sessions_index.html.erb +97 -0
  129. data/lib/sentiero/web/track_app.rb +57 -0
  130. data/lib/sentiero/web/views/analytics_index_view.rb +86 -0
  131. data/lib/sentiero/web/views/analyzer_view.rb +27 -0
  132. data/lib/sentiero/web/views/base_view.rb +76 -0
  133. data/lib/sentiero/web/views/client_error_show_view.rb +29 -0
  134. data/lib/sentiero/web/views/conversions_view.rb +41 -0
  135. data/lib/sentiero/web/views/engagement_view.rb +67 -0
  136. data/lib/sentiero/web/views/errors_index_view.rb +37 -0
  137. data/lib/sentiero/web/views/event_show_view.rb +20 -0
  138. data/lib/sentiero/web/views/events_index_view.rb +56 -0
  139. data/lib/sentiero/web/views/export_view.rb +23 -0
  140. data/lib/sentiero/web/views/forms_view.rb +28 -0
  141. data/lib/sentiero/web/views/frustration_view.rb +15 -0
  142. data/lib/sentiero/web/views/funnel_view.rb +36 -0
  143. data/lib/sentiero/web/views/heatmap_view.rb +34 -0
  144. data/lib/sentiero/web/views/import_view.rb +13 -0
  145. data/lib/sentiero/web/views/page_report_view.rb +43 -0
  146. data/lib/sentiero/web/views/problem_show_view.rb +46 -0
  147. data/lib/sentiero/web/views/scroll_view.rb +23 -0
  148. data/lib/sentiero/web/views/segments_view.rb +28 -0
  149. data/lib/sentiero/web/views/session_show_view.rb +105 -0
  150. data/lib/sentiero/web/views/sessions_index_view.rb +28 -0
  151. data/lib/sentiero/web/views/vitals_view.rb +45 -0
  152. data/lib/sentiero/web/views.rb +24 -0
  153. data/lib/sentiero/window_ref.rb +6 -0
  154. data/lib/sentiero.rb +69 -0
  155. metadata +232 -0
@@ -0,0 +1,2 @@
1
+ .switch.svelte-a6h7w7.svelte-a6h7w7.svelte-a6h7w7{height:1em;display:flex;align-items:center}.switch.disabled.svelte-a6h7w7.svelte-a6h7w7.svelte-a6h7w7{opacity:.5}.label.svelte-a6h7w7.svelte-a6h7w7.svelte-a6h7w7{margin:0 8px}.switch.svelte-a6h7w7 input[type=checkbox].svelte-a6h7w7.svelte-a6h7w7{position:absolute;opacity:0}.switch.svelte-a6h7w7 label.svelte-a6h7w7.svelte-a6h7w7{width:2em;height:1em;position:relative;cursor:pointer;display:block}.switch.disabled.svelte-a6h7w7 label.svelte-a6h7w7.svelte-a6h7w7{cursor:not-allowed}.switch.svelte-a6h7w7 label.svelte-a6h7w7.svelte-a6h7w7:before{content:"";position:absolute;width:2em;height:1em;left:.1em;transition:background .1s ease;background:#4950f680;border-radius:50px}.switch.svelte-a6h7w7 label.svelte-a6h7w7.svelte-a6h7w7:after{content:"";position:absolute;width:1em;height:1em;border-radius:50px;left:0;transition:all .2s ease;box-shadow:0 2px 5px #0000004d;background:#fcfff4;animation:switch-off .2s ease-out;z-index:2}.switch.svelte-a6h7w7 input[type=checkbox].svelte-a6h7w7:checked+label.svelte-a6h7w7:before{background:#4950f6}.switch.svelte-a6h7w7 input[type=checkbox].svelte-a6h7w7:checked+label.svelte-a6h7w7:after{animation:switch-on .2s ease-out;left:1.1em}.rr-controller.svelte-189zk2r.svelte-189zk2r{width:100%;height:80px;background:#fff;display:flex;flex-direction:column;justify-content:space-around;align-items:center;border-radius:0 0 5px 5px}.rr-timeline.svelte-189zk2r.svelte-189zk2r{width:80%;display:flex;align-items:center}.rr-timeline__time.svelte-189zk2r.svelte-189zk2r{display:inline-block;width:100px;text-align:center;color:#11103e}.rr-progress.svelte-189zk2r.svelte-189zk2r{flex:1;height:12px;background:#eee;position:relative;border-radius:3px;cursor:pointer;box-sizing:border-box;border-top:solid 4px #fff;border-bottom:solid 4px #fff}.rr-progress.disabled.svelte-189zk2r.svelte-189zk2r{cursor:not-allowed}.rr-progress__step.svelte-189zk2r.svelte-189zk2r{height:100%;position:absolute;left:0;top:0;background:#e0e1fe}.rr-progress__handler.svelte-189zk2r.svelte-189zk2r{width:20px;height:20px;border-radius:10px;position:absolute;top:2px;transform:translate(-50%,-50%);background:#4950f6}.rr-controller__btns.svelte-189zk2r.svelte-189zk2r{display:flex;align-items:center;justify-content:center;font-size:13px}.rr-controller__btns.svelte-189zk2r button.svelte-189zk2r{width:32px;height:32px;display:flex;padding:0;align-items:center;justify-content:center;background:none;border:none;border-radius:50%;cursor:pointer}.rr-controller__btns.svelte-189zk2r button.svelte-189zk2r:active{background:#e0e1fe}.rr-controller__btns.svelte-189zk2r button.active.svelte-189zk2r{color:#fff;background:#4950f6}.rr-controller__btns.svelte-189zk2r button.svelte-189zk2r:disabled{cursor:not-allowed}.replayer-wrapper{position:relative}.replayer-mouse{position:absolute;width:20px;height:20px;transition:left .05s linear,top .05s linear;background-size:contain;background-position:center center;background-repeat:no-repeat;background-image:url(data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9JzMwMHB4JyB3aWR0aD0nMzAwcHgnICBmaWxsPSIjMDAwMDAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGRhdGEtbmFtZT0iTGF5ZXIgMSIgdmlld0JveD0iMCAwIDUwIDUwIiB4PSIwcHgiIHk9IjBweCI+PHRpdGxlPkRlc2lnbl90bnA8L3RpdGxlPjxwYXRoIGQ9Ik00OC43MSw0Mi45MUwzNC4wOCwyOC4yOSw0NC4zMywxOEExLDEsMCwwLDAsNDQsMTYuMzlMMi4zNSwxLjA2QTEsMSwwLDAsMCwxLjA2LDIuMzVMMTYuMzksNDRhMSwxLDAsMCwwLDEuNjUuMzZMMjguMjksMzQuMDgsNDIuOTEsNDguNzFhMSwxLDAsMCwwLDEuNDEsMGw0LjM4LTQuMzhBMSwxLDAsMCwwLDQ4LjcxLDQyLjkxWm0tNS4wOSwzLjY3TDI5LDMyYTEsMSwwLDAsMC0xLjQxLDBsLTkuODUsOS44NUwzLjY5LDMuNjlsMzguMTIsMTRMMzIsMjcuNThBMSwxLDAsMCwwLDMyLDI5TDQ2LjU5LDQzLjYyWiI+PC9wYXRoPjwvc3ZnPg==);border-color:transparent}.replayer-mouse:after{content:"";display:inline-block;width:20px;height:20px;background:#4950f6;border-radius:100%;transform:translate(-50%,-50%);opacity:.3}.replayer-mouse.active:after{animation:click .2s ease-in-out 1}.replayer-mouse.touch-device{background-image:none;width:70px;height:70px;border-width:4px;border-style:solid;border-radius:100%;margin-left:-37px;margin-top:-37px;border-color:#4950f600;transition:left 0s linear,top 0s linear,border-color .2s ease-in-out}.replayer-mouse.touch-device.touch-active{border-color:#4950f6;transition:left .25s linear,top .25s linear,border-color .2s ease-in-out}.replayer-mouse.touch-device:after{opacity:0}.replayer-mouse.touch-device.active:after{animation:touch-click .2s ease-in-out 1}.replayer-mouse-tail{position:absolute;pointer-events:none}@keyframes click{0%{opacity:.3;width:20px;height:20px}50%{opacity:.5;width:10px;height:10px}}@keyframes touch-click{0%{opacity:0;width:20px;height:20px}50%{opacity:.5;width:10px;height:10px}}.rr-player{position:relative;background:#fff;float:left;border-radius:5px;box-shadow:0 24px 48px #11103e1f}.rr-player__frame{overflow:hidden}.replayer-wrapper{float:left;clear:both;transform-origin:top left;left:50%;top:50%}.replayer-wrapper>iframe{border:none}
2
+ /*# sourceMappingURL=style.min.css.map */
@@ -0,0 +1 @@
1
+ (()=>{var c=document.getElementById("select-all"),s=document.querySelectorAll(".session-checkbox"),i=document.getElementById("bulk-actions"),l=document.getElementById("selected-count"),d=document.getElementById("bulk-delete-form");function a(){let e=document.querySelectorAll(".session-checkbox:checked").length;i&&(i.style.display=e>0?"block":"none"),l&&(l.textContent=e),c&&(c.checked=e===s.length&&s.length>0)}c&&c.addEventListener("change",()=>{s.forEach(e=>{e.checked=c.checked}),a()});s.forEach(e=>{e.addEventListener("change",a)});d&&d.addEventListener("submit",e=>{let t=d.getAttribute("data-confirm");t&&!confirm(t)&&e.preventDefault()});document.addEventListener("click",e=>{let t=e.target.closest("[data-action='delete-session']");if(!t||!confirm("Delete this session?"))return;let r=t.getAttribute("data-session-id"),u=t.getAttribute("data-csrf-token"),m=t.getAttribute("data-base-path"),n=document.createElement("form");n.method="POST",n.action=`${m}/sessions/${r}?_method=delete`;let o=document.createElement("input");o.type="hidden",o.name="csrf_token",o.value=u,n.appendChild(o),document.body.appendChild(n),n.submit()});})();
@@ -0,0 +1,2 @@
1
+ /*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */
2
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-leading:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid}}}@layer theme{:root,:host{--font-sans:"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--font-mono:ui-monospace, "SF Mono", "Fira Code", "Fira Mono", "Roboto Mono", Menlo, monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-400:oklch(70.4% .191 22.216);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-900:oklch(39.6% .141 25.723);--color-blue-50:oklch(97% .014 254.604);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-400:oklch(67.3% .182 276.935);--color-indigo-700:oklch(45.7% .24 277.023);--color-indigo-900:oklch(35.9% .144 278.697);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wider:.05em;--leading-normal:1.5;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-surface-subtle:#f8f9fb;--color-border:#e2e5ea;--color-border-light:#eef0f3;--color-primary:#2563eb;--color-primary-hover:#1d4ed8;--color-primary-light:#eff6ff;--color-success:#059669;--color-success-bg:#ecfdf5;--color-warning:#d97706;--color-warning-bg:#fffbeb;--color-danger:#dc2626;--color-danger-bg:#fef2f2;--color-danger-hover:#b91c1c;--color-info:#0891b2;--color-info-bg:#ecfeff;--sidebar-w:220px}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}body{--tw-leading:var(--leading-normal);font-size:13px;line-height:var(--leading-normal);color:var(--color-gray-900);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:var(--font-sans);background:var(--color-surface-subtle);margin:0}*,:before,:after{border-color:var(--color-border)}a{color:var(--color-primary);text-decoration-line:none}a:hover{color:var(--color-primary-hover)}h1,h2,h3,h4,h5,h6{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--color-gray-900)}}@layer components{.btn{cursor:pointer;justify-content:center;align-items:center;gap:calc(var(--spacing) * 1.5);border-radius:var(--radius-md);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:1;--tw-font-weight:var(--font-weight-medium);line-height:1;font-weight:var(--font-weight-medium);white-space:nowrap;font-family:var(--font-sans);border-width:1px;border-color:#0000;text-decoration-line:none;transition:all .12s;display:inline-flex;box-shadow:0 1px 2px #0000000d}.btn:focus-visible{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px;outline-color:var(--color-primary)}.btn-sm{padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 1.5);font-size:11px}.btn-xs{padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * 1);font-size:11px}.btn-primary{color:var(--color-white);background:var(--color-gray-900);border-color:var(--color-gray-900)}.btn-primary:hover{color:var(--color-white);background:var(--color-gray-800);border-color:var(--color-gray-800)}.btn-secondary{background-color:var(--color-white);color:var(--color-gray-600);border-color:var(--color-border);box-shadow:0 1px 2px #0000000a}.btn-secondary:hover{background-color:var(--color-gray-50);color:var(--color-gray-800);border-color:var(--color-gray-300)}.btn-danger{color:var(--color-white);background:var(--color-danger);border-color:var(--color-danger)}.btn-danger:hover{color:var(--color-white);background:var(--color-danger-hover);border-color:var(--color-danger-hover)}.btn-ghost-danger{color:var(--color-gray-400);border-color:var(--color-border);box-shadow:none;background-color:#0000}.btn-ghost-danger:hover{color:var(--color-danger);background:var(--color-danger-bg);border-color:var(--color-danger)}.btn-active{color:var(--color-white);background:var(--color-primary);border-color:var(--color-primary)}.btn-active:hover{color:var(--color-white);background:var(--color-primary-hover);border-color:var(--color-primary-hover)}.input,.select{border-radius:var(--radius-md);background-color:var(--color-white);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--color-gray-900);font-family:var(--font-sans);border:1px solid var(--color-border);transition:border-color .12s,box-shadow .12s;box-shadow:0 1px 2px #0000000a}.select{appearance:none;padding-right:calc(var(--spacing) * 7);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");background-position:right .4rem center;background-repeat:no-repeat;background-size:.65rem}.input:focus,.select:focus{--tw-outline-style:none;border-color:var(--color-primary);outline-style:none;box-shadow:0 0 0 3px #2563eb14}.input::placeholder{color:var(--color-gray-400)}.label{margin-bottom:calc(var(--spacing) * 1);--tw-font-weight:var(--font-weight-semibold);font-size:10px;font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);color:var(--color-gray-400);text-transform:uppercase;display:block}.checkbox{height:calc(var(--spacing) * 3.5);width:calc(var(--spacing) * 3.5);cursor:pointer;border:1px solid var(--color-gray-300);accent-color:var(--color-primary);border-radius:.25rem}.badge{padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * .5);--tw-font-weight:var(--font-weight-medium);font-size:10px;font-weight:var(--font-weight-medium);white-space:nowrap;font-family:var(--font-sans);letter-spacing:.01em;border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-neutral{background-color:var(--color-gray-100);color:var(--color-gray-500)}.badge-primary{background:var(--color-primary-light);color:var(--color-primary)}.badge-warning{background:var(--color-warning-bg);color:var(--color-warning)}.badge-danger{background:var(--color-danger-bg);color:var(--color-danger)}.badge-success{background:var(--color-success-bg);color:var(--color-success)}.badge-info{background:var(--color-info-bg);color:var(--color-info)}.card{border-radius:var(--radius-lg);background-color:var(--color-white);border:1px solid var(--color-border);box-shadow:0 1px 3px #0000000a,0 1px 2px #00000005}.card-body{padding:calc(var(--spacing) * 5)}.data-table{text-align:left;width:100%;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));border-collapse:separate;border-spacing:0}.data-table th{padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);--tw-font-weight:var(--font-weight-semibold);font-size:10px;font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);white-space:nowrap;color:var(--color-gray-400);text-transform:uppercase;background:var(--color-surface-subtle);border-bottom:1px solid var(--color-border)}.data-table td{padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2.5);vertical-align:middle;color:var(--color-gray-600);border-bottom:1px solid var(--color-border-light)}.data-table tbody tr:last-child td{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.data-table tbody tr:hover td{background-color:#eff6ff66}@supports (color:color-mix(in lab, red, red)){.data-table tbody tr:hover td{background-color:color-mix(in oklab, var(--color-blue-50) 40%, transparent)}}.table-wrap{border-radius:var(--radius-lg);background-color:var(--color-white);border:1px solid var(--color-border);overflow:hidden;box-shadow:0 1px 3px #0000000a,0 1px 2px #00000005}.pagination{margin:calc(var(--spacing) * 0);gap:calc(var(--spacing) * 1);padding:calc(var(--spacing) * 0);list-style-type:none;display:flex}.page-link{border-radius:var(--radius-md);background-color:var(--color-white);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-600);border:1px solid var(--color-border);text-decoration-line:none;display:block}.page-link:hover{background-color:var(--color-gray-50);color:var(--color-gray-800);border-color:var(--color-gray-300)}.page-disabled .page-link{pointer-events:none;background-color:var(--color-gray-50);color:var(--color-gray-300)}.alert-error{margin-bottom:calc(var(--spacing) * 3);border-radius:var(--radius-lg);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));background:var(--color-danger-bg);color:var(--color-danger);border:1px solid #fecaca}.tabs{margin-bottom:calc(var(--spacing) * 5);align-items:center;gap:calc(var(--spacing) * 6);border-bottom:1px solid var(--color-border);display:flex}.tab{align-items:center;gap:calc(var(--spacing) * 1.5);padding-bottom:calc(var(--spacing) * 2.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);white-space:nowrap;color:var(--color-gray-500);border-bottom:2px solid #0000;margin-bottom:-1px;text-decoration-line:none;transition:color .12s,border-color .12s;display:inline-flex}.tab:hover{color:var(--color-gray-800);border-bottom-color:var(--color-gray-300)}.tab-active,.tab-active:hover{color:var(--color-primary);border-bottom-color:var(--color-primary)}.tab-count{padding-inline:calc(var(--spacing) * 1.5);padding-block:calc(var(--spacing) * .5);--tw-font-weight:var(--font-weight-semibold);font-size:10px;font-weight:var(--font-weight-semibold);--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);background:var(--color-gray-100);color:var(--color-gray-500);border-radius:3.40282e38px}.tab-active .tab-count{background:var(--color-primary-light);color:var(--color-primary)}.banner-warning{margin-bottom:calc(var(--spacing) * 4);align-items:flex-start;gap:calc(var(--spacing) * 2);border-radius:var(--radius-lg);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));background:var(--color-warning-bg);color:var(--color-warning);border:1px solid #fde68a;border-left:3px solid var(--color-warning);display:flex}.banner-warning strong{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}}@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.my-4{margin-block:calc(var(--spacing) * 4)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mb-0{margin-bottom:calc(var(--spacing) * 0)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline\!{display:inline!important}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.h-2{height:calc(var(--spacing) * 2)}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.w-3{width:calc(var(--spacing) * 3)}.w-8{width:calc(var(--spacing) * 8)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-56{width:calc(var(--spacing) * 56)}.w-full{width:100%}.w-px{width:1px}.max-w-0{max-width:calc(var(--spacing) * 0)}.max-w-full{max-width:100%}.max-w-xs{max-width:var(--container-xs)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[100px_1fr\]{grid-template-columns:100px 1fr}.grid-cols-\[140px_1fr\]{grid-template-columns:140px 1fr}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.self-end{align-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.rounded-sm{border-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-blue-400{border-color:var(--color-blue-400)}.border-gray-100{border-color:var(--color-gray-100)}.border-gray-200{border-color:var(--color-gray-200)}.border-indigo-400{border-color:var(--color-indigo-400)}.border-red-400{border-color:var(--color-red-400)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-red-50{background-color:var(--color-red-50)}.p-1{padding:calc(var(--spacing) * 1)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-10{padding-block:calc(var(--spacing) * 10)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pb-1\.5{padding-bottom:calc(var(--spacing) * 1.5)}.pl-2{padding-left:calc(var(--spacing) * 2)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-indigo-400{color:var(--color-indigo-400)}.text-indigo-700{color:var(--color-indigo-700)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.select-all{-webkit-user-select:all;user-select:all}.select-none{-webkit-user-select:none;user-select:none}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}@media (hover:hover){.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:text-blue-700:hover{color:var(--color-blue-700)}.hover\:text-blue-800:hover{color:var(--color-blue-800)}.hover\:text-indigo-900:hover{color:var(--color-indigo-900)}.hover\:text-red-900:hover{color:var(--color-red-900)}.hover\:underline:hover{text-decoration-line:underline}}@media (min-width:40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:48rem){.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:grid-cols-\[1fr_280px\]{grid-template-columns:1fr 280px}}}.s-layout{min-height:100vh;display:flex}.s-sidebar{top:calc(var(--spacing) * 0);bottom:calc(var(--spacing) * 0);left:calc(var(--spacing) * 0);z-index:50;background-color:var(--color-white);padding-block:calc(var(--spacing) * 4);width:var(--sidebar-w);border-right:1px solid var(--color-border);flex-direction:column;display:flex;position:fixed}.s-sidebar-brand{margin-bottom:calc(var(--spacing) * 5);align-items:center;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--color-gray-900);text-decoration-line:none;display:flex}.s-sidebar-brand:hover{color:var(--color-gray-900)}.s-sidebar-brand-logo{height:calc(var(--spacing) * 7);width:calc(var(--spacing) * 7);border-radius:var(--radius-md);flex-shrink:0}.s-sidebar-nav{margin:calc(var(--spacing) * 0);padding-inline:calc(var(--spacing) * 2.5);list-style-type:none}.s-nav-item{margin-bottom:calc(var(--spacing) * .5);align-items:center;gap:calc(var(--spacing) * 2);border-radius:var(--radius-md);padding-inline:calc(var(--spacing) * 2.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-500);padding-block:7px;text-decoration-line:none;transition:all .12s;display:flex}.s-nav-item:hover{background-color:var(--color-gray-100);color:var(--color-gray-800)}.s-nav-item.active{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);background:var(--color-primary-light);color:var(--color-primary)}.s-nav-icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);opacity:.6;flex-shrink:0}.s-nav-item.active .s-nav-icon{opacity:1}.s-main{padding-inline:calc(var(--spacing) * 8);padding-block:calc(var(--spacing) * 6);margin-left:var(--sidebar-w);max-width:calc(1400px + var(--sidebar-w));flex:1}.s-mobile-header{background-color:var(--color-white);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);border-bottom:1px solid var(--color-border);justify-content:space-between;align-items:center;display:none}.page-title{margin-bottom:calc(var(--spacing) * 4);font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--color-gray-900)}.session-id{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--color-gray-500);font-family:var(--font-mono)}.metadata-url{text-overflow:ellipsis;white-space:nowrap;max-width:200px;overflow:hidden}.window-switcher{align-items:center;gap:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 2);display:flex}.window-switcher-label{--tw-font-weight:var(--font-weight-semibold);font-size:10px;font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);white-space:nowrap;color:var(--color-gray-400);text-transform:uppercase}.window-overflow{display:inline-block;position:relative}.window-overflow summary{cursor:pointer;list-style-type:none}.window-overflow summary::-webkit-details-marker{display:none}.window-overflow-menu{top:100%;left:calc(var(--spacing) * 0);z-index:10;margin-top:calc(var(--spacing) * 1);border-radius:var(--radius-lg);background-color:var(--color-white);min-width:200px;padding:calc(var(--spacing) * 1);border:1px solid var(--color-border);position:absolute;box-shadow:0 8px 24px #0000001f,0 2px 6px #0000000a}.window-overflow-menu a{border-radius:var(--radius-md);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--color-gray-600);text-decoration-line:none;display:block}.window-overflow-menu a:hover{background-color:var(--color-gray-50)}.window-overflow-menu a.active{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);background:var(--color-primary-light);color:var(--color-primary)}.player-frame{margin-top:calc(var(--spacing) * 2);border-radius:var(--radius-lg);background-color:var(--color-white);border:1px solid var(--color-border);overflow:hidden;box-shadow:0 1px 3px #0000000a,0 1px 2px #00000005}.player-frame .rr-player{--tw-shadow:0 0 #0000;max-width:100%;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-radius:0}.player-frame .rr-controller{background:var(--color-surface-subtle);border-top:1px solid var(--color-border-light);border-radius:0}#replayer{justify-content:center;display:flex;overflow:hidden}.click-overlay-container{pointer-events:none;inset:calc(var(--spacing) * 0);z-index:20;position:absolute}.click-dot{pointer-events:none;background:#dc35458c;border:1px solid #dc3545;border-radius:3.40282e38px;width:12px;height:12px;transition:transform .12s,background .12s;position:absolute;box-shadow:0 0 0 2px #dc354533}.click-dot:hover,.click-dot.active{background:#dc3545d9;transform:scale(1.4)}#event-markers{padding-inline:calc(var(--spacing) * 3);padding-top:calc(var(--spacing) * 2)}.marker-legend{margin-bottom:calc(var(--spacing) * 1);gap:calc(var(--spacing) * 3);color:var(--color-gray-400);font-size:10px;display:flex}.marker-legend-item{align-items:center;gap:calc(var(--spacing) * 1);display:flex}.marker-legend-dot{height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);border-radius:3.40282e38px;display:inline-block}.marker-bar{margin-bottom:calc(var(--spacing) * 2);height:calc(var(--spacing) * 8);cursor:pointer;border-radius:var(--radius-md);background:var(--color-surface-subtle);border:1px solid var(--color-border);box-sizing:border-box;max-width:100%;position:relative;overflow:visible}.marker-track{pointer-events:none;right:calc(var(--spacing) * 2);left:calc(var(--spacing) * 2);background:var(--color-border);border-radius:3.40282e38px;height:2px;position:absolute;top:50%;transform:translateY(-50%)}.marker-playhead{pointer-events:none;top:calc(var(--spacing) * 0);bottom:calc(var(--spacing) * 0);z-index:10;background:var(--color-primary);border-radius:1px;width:2px;transition:left .15s linear;position:absolute;box-shadow:0 0 4px #2563eb4d}.marker-playhead:before{content:"";background:var(--color-primary);border-radius:50%;width:8px;height:8px;display:block;position:absolute;top:-3px;left:50%;transform:translate(-50%);box-shadow:0 0 0 2px #fff,0 1px 3px #00000026}.event-marker{height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);cursor:pointer;opacity:.7;border-radius:3.40282e38px;transition:opacity .12s,transform .12s;position:absolute;top:50%;transform:translate(-50%,-50%)}.event-marker:hover{z-index:1;opacity:1;transform:translate(-50%,-50%)scale(1.6)}.event-marker.error-marker{z-index:2;height:calc(var(--spacing) * 2.5);width:calc(var(--spacing) * 2.5);opacity:1;box-shadow:0 0 4px #dc262666}.marker-gap{top:calc(var(--spacing) * 0);bottom:calc(var(--spacing) * 0);z-index:0;cursor:help;border-left:1px solid var(--color-gray-300);border-right:1px solid var(--color-gray-300);background:repeating-linear-gradient(-45deg, transparent, transparent 3px, var(--color-border) 3px, var(--color-border) 5px);position:absolute}.marker-group{z-index:3;cursor:pointer;position:absolute;top:50%;transform:translate(-50%,-50%)}.marker-group .event-marker{display:block;position:static;transform:none}.marker-group:hover .event-marker{transform:scale(1.4)}.marker-group-dot{opacity:.9;width:14px!important;height:14px!important}.marker-group-count{pointer-events:none;background-color:var(--color-gray-700);text-align:center;--tw-font-weight:var(--font-weight-bold);font-size:9px;font-weight:var(--font-weight-bold);color:var(--color-white);border-radius:3.40282e38px;min-width:14px;padding-inline:3px;line-height:14px;position:absolute;top:-8px;left:50%;transform:translate(-50%)}.marker-dropdown{z-index:50;max-width:var(--container-xs);border-radius:var(--radius-lg);background-color:var(--color-white);min-width:200px;padding:calc(var(--spacing) * 1);white-space:nowrap;border:1px solid var(--color-border);position:absolute;top:22px;left:50%;transform:translate(-50%);box-shadow:0 8px 24px #0000001f,0 2px 6px #0000000a}.marker-dropdown-item{cursor:pointer;align-items:center;gap:calc(var(--spacing) * 1.5);border-radius:var(--radius-md);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 1.5);color:var(--color-gray-600);font-size:11px;display:flex}.marker-dropdown-item:hover{background-color:var(--color-gray-50)}.marker-dropdown-item.error-entry{background:var(--color-danger-bg)}.marker-dropdown-item.error-entry:hover{background:#fee2e2}#activity-sidebar{margin-top:calc(var(--spacing) * 2);border-radius:var(--radius-lg);background-color:var(--color-white);max-height:600px;padding:calc(var(--spacing) * 3);border:1px solid var(--color-border);overflow-y:auto;box-shadow:0 1px 3px #0000000a,0 1px 2px #00000005}#activity-sidebar h6{margin-bottom:calc(var(--spacing) * 2);align-items:center;gap:calc(var(--spacing) * 2);display:flex}.activity-entry{cursor:pointer;align-items:center;gap:calc(var(--spacing) * 2);border-radius:var(--radius-md);padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * 1);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));border-bottom:1px solid var(--color-border-light);transition:background 80ms;display:flex}.activity-entry:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.activity-entry:hover{background-color:var(--color-gray-50)}.activity-entry.active{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);background:var(--color-primary-light)}.activity-entry.error-entry{background:var(--color-danger-bg);border-left:3px solid var(--color-danger)}.activity-entry.navigation-entry{background:var(--color-success-bg);border-left:3px solid var(--color-success)}.activity-entry.frustration-entry{background:#ff6b6b1a;border-left:3px solid #ff6b6b}.activity-entry.server-exception-entry{background:#b91c1c14;border-left:3px solid #b91c1c}.activity-entry.server-event-entry{background:#7c3aed14;border-left:3px solid #7c3aed}.activity-label-link{color:var(--color-blue-600);text-decoration-line:none}@media (hover:hover){.activity-label-link:hover{text-decoration-line:underline}}.activity-time{min-width:calc(var(--spacing) * 10);white-space:nowrap;color:var(--color-gray-400);font-size:10px;font-family:var(--font-mono)}.activity-dot{height:calc(var(--spacing) * 1.5);width:calc(var(--spacing) * 1.5);border-radius:3.40282e38px;flex-shrink:0;display:inline-block}.activity-label{text-overflow:ellipsis;white-space:nowrap;color:var(--color-gray-600);overflow:hidden}.activity-gap{margin-block:calc(var(--spacing) * .5);padding-block:calc(var(--spacing) * .5);text-align:center;color:var(--color-gray-400);border-top:1px dashed var(--color-border-light);border-bottom:1px dashed var(--color-border-light);background:repeating-linear-gradient(-45deg, transparent, transparent 4px, var(--color-surface-subtle) 4px, var(--color-surface-subtle) 6px);font-size:10px}.activity-wrapper{position:relative}.activity-detail{margin-left:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 1.5);overflow-wrap:break-word;color:var(--color-gray-500);background:var(--color-surface-subtle);border-left:2px solid var(--color-border);border-radius:0 6px 6px 0;font-size:11px;display:none}.activity-wrapper:hover .activity-detail{display:block}.activity-detail-line{margin-bottom:calc(var(--spacing) * .5)}.activity-detail-line:last-child{margin-bottom:calc(var(--spacing) * 0)}.activity-detail-pre{margin-top:calc(var(--spacing) * .5);background-color:var(--color-gray-100);max-height:120px;padding:calc(var(--spacing) * 1.5);word-break:break-all;white-space:pre-wrap;font-size:10px;font-family:var(--font-mono);border-radius:.25rem;overflow-y:auto}.stats-chart{margin-top:calc(var(--spacing) * 3);align-items:flex-end;gap:calc(var(--spacing) * 1);height:180px;padding-top:calc(var(--spacing) * 4);padding-bottom:calc(var(--spacing) * 2);border-bottom:1px solid var(--color-border-light);display:flex}.stats-chart-col{min-width:calc(var(--spacing) * 0);flex-direction:column;flex:1;align-items:center;display:flex}.stats-chart-bar-wrapper{flex:1;justify-content:center;align-items:flex-end;width:100%;height:140px;display:flex}.stats-chart-bar{background:var(--color-primary);border-radius:4px 4px 0 0;width:60%;max-width:36px;min-height:2px;transition:height .3s,background .12s;position:relative}.stats-chart-bar:hover{background:var(--color-primary-hover)}.stats-chart-value{--tw-font-weight:var(--font-weight-semibold);font-size:10px;font-weight:var(--font-weight-semibold);white-space:nowrap;color:var(--color-gray-500);position:absolute;top:-16px;left:50%;transform:translate(-50%)}.stats-chart-label{margin-top:calc(var(--spacing) * 1);white-space:nowrap;color:var(--color-gray-400);font-size:10px}@media (max-width:768px){.s-sidebar{display:none}.s-mobile-header{display:flex}.s-main{margin-left:calc(var(--spacing) * 0);padding:calc(var(--spacing) * 4)}}details[open]>summary .group-open\:rotate-90{transform:rotate(90deg)}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-leading{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_app"
4
+
5
+ module Sentiero
6
+ module Web
7
+ # Standalone Rack endpoint serving the gem's static assets (mounted via
8
+ # r.sentiero_assets). Shares #serve with BaseApp#handle_asset so the dashboard's
9
+ # /assets/* route applies the same traversal guard, content types, and caching.
10
+ class AssetsApp < BaseApp
11
+ def call(env)
12
+ return [405, {"content-type" => "text/plain"}, ["Method Not Allowed"]] unless env["REQUEST_METHOD"] == "GET"
13
+
14
+ serve(env["PATH_INFO"].delete_prefix("/"))
15
+ end
16
+
17
+ # Resolved path must stay inside ASSETS_DIR (blocks traversal/absolute
18
+ # paths); .erb files are never served raw. Fingerprinted basenames
19
+ # (name-HASH.ext) get a year of immutable cache (31536000s), else one day.
20
+ def serve(relative_path)
21
+ return not_found if relative_path.nil? || relative_path.empty?
22
+
23
+ full_path = File.expand_path(relative_path, ASSETS_DIR)
24
+
25
+ return not_found unless full_path.start_with?(ASSETS_DIR + File::SEPARATOR)
26
+ return not_found if full_path.end_with?(".erb")
27
+ return not_found unless File.file?(full_path)
28
+
29
+ ext = File.extname(full_path)
30
+ content_type = CONTENT_TYPES.fetch(ext, "application/octet-stream")
31
+
32
+ cache_control = if File.basename(full_path).match?(/\A[^\/]+-[A-Za-z0-9]+\.\w+\z/)
33
+ "public, max-age=31536000, immutable"
34
+ else
35
+ "public, max-age=86400"
36
+ end
37
+
38
+ [200, {"content-type" => content_type, "cache-control" => cache_control}, [File.binread(full_path)]]
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,319 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "uri"
6
+ require "time"
7
+ require_relative "escaping"
8
+ require_relative "formatting"
9
+ require_relative "views"
10
+ require_relative "basic_auth_check"
11
+ require_relative "../store"
12
+ require_relative "../user_agent"
13
+ require_relative "../ip_anonymizer"
14
+
15
+ module Sentiero
16
+ module Web
17
+ # Shared, non-routing machinery for the dashboard UI Rack apps (DashboardApp,
18
+ # AnalyticsApp, MonitoringApp): auth, CSRF, escaping, security headers, asset
19
+ # serving, the routing combinators, and the render_page view-rendering entry
20
+ # point. Subclasses implement their own #call routing.
21
+ class BaseApp
22
+ include Escaping
23
+ include Formatting
24
+
25
+ ASSETS_DIR = File.expand_path("assets", __dir__).freeze
26
+
27
+ CSP_POLICY = [
28
+ "default-src 'self'",
29
+ "script-src 'self'",
30
+ "style-src 'self' 'unsafe-inline'",
31
+ "img-src 'self' data:",
32
+ "frame-src 'self' blob:"
33
+ ].join("; ").freeze
34
+
35
+ CONTENT_TYPES = {
36
+ ".css" => "text/css",
37
+ ".js" => "application/javascript",
38
+ ".html" => "text/html",
39
+ ".png" => "image/png",
40
+ ".svg" => "image/svg+xml"
41
+ }.freeze
42
+
43
+ # Warn at most once per process: the Roda plugin and /analytics delegation
44
+ # construct apps per request, so a per-construction warning would spam.
45
+ AUTH_WARNING_LOCK = Mutex.new
46
+ @auth_warning_emitted = false
47
+
48
+ class << self
49
+ # With no auth configured the dashboard fails closed (403) unless
50
+ # allow_insecure_dashboard is set, in which case it serves unauthenticated
51
+ # and we warn once. Called on BaseApp so both subclasses share one flag
52
+ # (class ivars aren't inherited).
53
+ def warn_unauthenticated_once
54
+ config = Sentiero.configuration
55
+ return if config.basic_auth || config.auth_callback
56
+ return unless config.allow_insecure_dashboard
57
+
58
+ AUTH_WARNING_LOCK.synchronize do
59
+ return if @auth_warning_emitted
60
+ @auth_warning_emitted = true
61
+ end
62
+
63
+ warn "[Sentiero] dashboard mounted with allow_insecure_dashboard and no " \
64
+ "authentication (config.basic_auth and config.auth_callback both unset); " \
65
+ "session recordings and analytics are publicly accessible to anyone who " \
66
+ "can reach this mount. Set config.basic_auth or config.auth_callback to " \
67
+ "protect it."
68
+ end
69
+
70
+ def reset_auth_warning!
71
+ @auth_warning_emitted = false
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def authorized?(env)
78
+ creds = Sentiero.configuration.basic_auth
79
+ return basic_auth_authorized?(env, creds) unless creds.nil?
80
+
81
+ callback = Sentiero.configuration.auth_callback
82
+ # No auth configured: fail closed unless explicitly opted out.
83
+ return Sentiero.configuration.allow_insecure_dashboard if callback.nil?
84
+ !!callback.call(env)
85
+ rescue Sentiero::Error
86
+ raise
87
+ rescue => e
88
+ warn "[Sentiero] auth_callback raised #{e.class}: #{e.message}"
89
+ false
90
+ end
91
+
92
+ def basic_auth_authorized?(env, creds)
93
+ if BasicAuthCheck.credentials_blank?(creds)
94
+ raise Sentiero::Error,
95
+ "config.basic_auth is set but the username or password is blank. " \
96
+ "Set SENTIERO_DASHBOARD_PASSWORD (or remove config.basic_auth to " \
97
+ "disable dashboard auth)."
98
+ end
99
+ BasicAuthCheck.authorized?(env, creds)
100
+ end
101
+
102
+ def audit!(env, action:, session_id: nil, window_id: nil, dataset: nil, problem_id: nil)
103
+ callback = Sentiero.configuration.audit_log
104
+ return if callback.nil?
105
+
106
+ callback.call(
107
+ action: action,
108
+ session_id: session_id,
109
+ window_id: window_id,
110
+ dataset: dataset,
111
+ problem_id: problem_id,
112
+ user: audit_user(env),
113
+ ip: audit_ip(env),
114
+ timestamp: Time.now,
115
+ path: env["PATH_INFO"]
116
+ )
117
+ rescue => e
118
+ warn "[Sentiero] audit_log raised #{e.class}: #{e.message}"
119
+ end
120
+
121
+ def audit_user(env)
122
+ env["sentiero.user"] || env["REMOTE_USER"]
123
+ end
124
+
125
+ def audit_ip(env)
126
+ forwarded = env["HTTP_X_FORWARDED_FOR"]&.split(",")&.first&.strip
127
+ ip = (forwarded && !forwarded.empty?) ? forwarded : env["REMOTE_ADDR"]
128
+ Sentiero.configuration.anonymize_ip ? IpAnonymizer.anonymize(ip) : ip
129
+ end
130
+
131
+ def forbidden
132
+ [403, {"content-type" => "text/plain"}, ["Forbidden"]]
133
+ end
134
+
135
+ def unauthorized_response
136
+ if Sentiero.configuration.basic_auth.nil?
137
+ forbidden
138
+ else
139
+ [401,
140
+ {"content-type" => "text/plain", "www-authenticate" => 'Basic realm="Sentiero"'},
141
+ ["Unauthorized"]]
142
+ end
143
+ end
144
+
145
+ def not_found
146
+ [404, {"content-type" => "text/plain"}, ["Not Found"]]
147
+ end
148
+
149
+ def redirect(location, status: 302)
150
+ [status, {"location" => location}, []]
151
+ end
152
+
153
+ # Mount prefix (empty for a root mount); prepended to redirect targets.
154
+ def base_path(env)
155
+ env["SCRIPT_NAME"] || ""
156
+ end
157
+
158
+ def invalid_id
159
+ [400, json_headers, ['{"error":"invalid ID format"}']]
160
+ end
161
+
162
+ def forbidden_csrf
163
+ [403, {"content-type" => "text/plain"}, ["Invalid CSRF token"]]
164
+ end
165
+
166
+ # Combinator: returns a 403 to short-circuit a mutating route when the CSRF
167
+ # token is missing/invalid, or nil to fall through (`verify_csrf(env) || …`).
168
+ def verify_csrf(env)
169
+ forbidden_csrf unless valid_csrf_token?(env, Rack::Request.new(env).POST["csrf_token"])
170
+ end
171
+
172
+ def valid_id?(id)
173
+ id.is_a?(String) && id.match?(Store::VALID_ID)
174
+ end
175
+
176
+ def html_headers(extra = {})
177
+ {
178
+ "content-type" => "text/html",
179
+ "content-security-policy" => CSP_POLICY,
180
+ "x-content-type-options" => "nosniff",
181
+ "x-frame-options" => "DENY"
182
+ }.merge(extra)
183
+ end
184
+
185
+ def json_headers(extra = {})
186
+ {"content-type" => "application/json", "x-content-type-options" => "nosniff"}.merge(extra)
187
+ end
188
+
189
+ def generate_csrf_token
190
+ SecureRandom.hex(32)
191
+ end
192
+
193
+ def csrf_cookie_header(env, csrf_token, base_path)
194
+ cookie = "sentiero_csrf=#{csrf_token}; HttpOnly; SameSite=Strict; Path=#{base_path}/"
195
+ cookie += "; Secure" if env["rack.url_scheme"] == "https"
196
+ cookie
197
+ end
198
+
199
+ def valid_csrf_token?(env, token)
200
+ expected = Rack::Utils.parse_cookies(env)["sentiero_csrf"]
201
+ return false if expected.nil? || expected.empty?
202
+ return false if token.nil? || token.empty?
203
+ Rack::Utils.secure_compare(expected, token)
204
+ end
205
+
206
+ # Standard `since`/`until` range params (ISO date or ISO-8601 timestamp),
207
+ # parsed in UTC. Zone-less timestamps assume UTC; invalid input = no filter.
208
+ DATE_ONLY_FORMAT = /\A\d{4}-\d{2}-\d{2}\z/
209
+ END_OF_DAY_SECONDS = 86_399.999
210
+
211
+ def parse_range_params(params)
212
+ [parse_since_param(params["since"]), parse_until_param(params["until"])]
213
+ end
214
+
215
+ def parse_since_param(value)
216
+ parse_utc_time_param(value, end_of_day: false)
217
+ end
218
+
219
+ def parse_until_param(value)
220
+ parse_utc_time_param(value, end_of_day: true)
221
+ end
222
+
223
+ def parse_utc_time_param(value, end_of_day:)
224
+ return nil if value.nil? || value.empty?
225
+
226
+ if DATE_ONLY_FORMAT.match?(value)
227
+ base = Time.utc(*value.split("-").map(&:to_i)).to_f
228
+ end_of_day ? base + END_OF_DAY_SECONDS : base
229
+ elsif value.match?(/(?:Z|[+-]\d{2}:?\d{2})\z/i)
230
+ Time.parse(value).to_f
231
+ else
232
+ Time.parse("#{value} UTC").to_f
233
+ end
234
+ rescue ArgumentError, TypeError, RangeError
235
+ nil
236
+ end
237
+
238
+ def echo_range_params(params, since, until_time)
239
+ [since ? params["since"].to_s : "", until_time ? params["until"].to_s : ""]
240
+ end
241
+
242
+ # Ceiling on the requested page
243
+ MAX_PAGE = 10_000
244
+
245
+ def clamp_page(value)
246
+ page = value.to_i
247
+ page = 1 if page < 1
248
+ [page, MAX_PAGE].min
249
+ end
250
+
251
+ def clamp_per_page(value, default:, max:)
252
+ per_page = value.to_i
253
+ per_page = default if per_page < 1
254
+ [per_page, max].min
255
+ end
256
+
257
+ def query_params(env)
258
+ Rack::Utils.parse_query(env["QUERY_STRING"] || "")
259
+ end
260
+
261
+ # Clamped [page, per_page, offset] from the request params.
262
+ def paginate(params, default:, max:)
263
+ page = clamp_page(params["page"])
264
+ per_page = clamp_per_page(params["per_page"], default: default, max: max)
265
+ [page, per_page, (page - 1) * per_page]
266
+ end
267
+
268
+ # Callers obtain per_page + 1 rows; the extra row signals that another page exists
269
+ def take_page(rows, per_page)
270
+ [rows.first(per_page), rows.size > per_page]
271
+ end
272
+
273
+ # Router combinators: each returns an early-exit Rack response, or nil to
274
+ # fall through, so a route arm reads
275
+ # `get_only(method) || guard(id) || handle_x(env, id)`.
276
+
277
+ def get_only(method)
278
+ not_found unless method == "GET"
279
+ end
280
+
281
+ def post_only(method)
282
+ not_found unless method == "POST"
283
+ end
284
+
285
+ # 400 unless every captured path id is well-formed, so malformed ids are
286
+ # rejected before they reach a handler or a store query.
287
+ def guard(*ids)
288
+ invalid_id unless ids.all? { |id| valid_id?(id) }
289
+ end
290
+
291
+ def delete_request?(method, env)
292
+ return true if method == "DELETE"
293
+
294
+ method == "POST" && query_params(env)["_method"] == "delete"
295
+ end
296
+
297
+ def handle_asset(relative_path)
298
+ require_relative "assets_app"
299
+ AssetsApp.new.serve(relative_path)
300
+ end
301
+
302
+ # With csrf: true a CSRF token is minted, set as the sentiero_csrf cookie,
303
+ # and injected into the view as csrf_token.
304
+ def render_page(env, view, csrf: true, request_path: nil)
305
+ view.base_path = base_path(env)
306
+ request_path ||= env["PATH_INFO"] || "/"
307
+ headers = if csrf
308
+ token = generate_csrf_token
309
+ view.csrf_token = token
310
+ html_headers("set-cookie" => csrf_cookie_header(env, token, view.base_path))
311
+ else
312
+ html_headers
313
+ end
314
+ body = view.render_layout(view.render, request_path: request_path)
315
+ [200, headers, [body]]
316
+ end
317
+ end
318
+ end
319
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/utils"
4
+ require_relative "basic_auth_check"
5
+
6
+ module Sentiero
7
+ module Web
8
+ # Optional HTTP Basic auth middleware for standalone dashboards. Passes
9
+ # through when basic_auth is unset; blank configured credentials lock
10
+ # everyone out (401). Assumes TLS upstream.
11
+ class BasicAuth
12
+ def initialize(app)
13
+ @app = app
14
+ end
15
+
16
+ def call(env)
17
+ creds = Sentiero.configuration.basic_auth
18
+ return @app.call(env) if creds.nil?
19
+ return @app.call(env) if BasicAuthCheck.authorized?(env, creds)
20
+
21
+ [401,
22
+ {"content-type" => "text/plain", "www-authenticate" => 'Basic realm="Sentiero"'},
23
+ ["Unauthorized"]]
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/utils"
4
+
5
+ module Sentiero
6
+ module Web
7
+ # Shared HTTP Basic credential checking. Constant-time comparison;
8
+ # assumes TLS terminated upstream.
9
+ module BasicAuthCheck
10
+ module_function
11
+
12
+ def credentials_blank?(creds)
13
+ creds[:user].to_s.empty? || creds[:password].to_s.empty?
14
+ end
15
+
16
+ def authorized?(env, creds)
17
+ return false if credentials_blank?(creds)
18
+
19
+ header = env["HTTP_AUTHORIZATION"]
20
+ return false unless header
21
+
22
+ scheme, encoded = header.split(" ", 2)
23
+ return false unless scheme&.downcase == "basic" && encoded
24
+
25
+ begin
26
+ # Strict base64 decode: "m0" raises ArgumentError on invalid input.
27
+ decoded = encoded.unpack1("m0")
28
+ rescue ArgumentError
29
+ return false
30
+ end
31
+
32
+ user, password = decoded.split(":", 2)
33
+ return false if user.nil? || password.nil?
34
+
35
+ user_ok = Rack::Utils.secure_compare(user, creds[:user].to_s)
36
+ pass_ok = Rack::Utils.secure_compare(password, creds[:password].to_s)
37
+ user_ok && pass_ok
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require "zlib"
5
+
6
+ module Sentiero
7
+ module Web
8
+ # Reads a request body (optionally gzip-encoded) with a hard 512KB cap on
9
+ # BOTH the compressed and decompressed size, so a gzip bomb can't blow past
10
+ # it. Shared by the two untrusted-input lanes (EventsApp, IngestApp), which
11
+ # differ only in the response they build from the error symbol.
12
+ module BodyReader
13
+ MAX_BODY_SIZE = 524_288 # 512 KB
14
+
15
+ # error symbol => [http_status, message]
16
+ ERRORS = {
17
+ too_large: [413, "request body too large"],
18
+ bad_gzip: [400, "invalid gzip encoding"]
19
+ }.freeze
20
+
21
+ module_function
22
+
23
+ # [body, nil] on success, or [nil, :too_large | :bad_gzip] on failure.
24
+ def read(env)
25
+ raw = env["rack.input"].read(MAX_BODY_SIZE + 1) || ""
26
+ env["rack.input"].rewind if env["rack.input"].respond_to?(:rewind)
27
+ return [nil, :too_large] if raw.bytesize > MAX_BODY_SIZE
28
+
29
+ if env["HTTP_CONTENT_ENCODING"]&.downcase == "gzip"
30
+ begin
31
+ gz = Zlib::GzipReader.new(StringIO.new(raw))
32
+ raw = gz.read(MAX_BODY_SIZE + 1) || ""
33
+ gz.close
34
+ rescue Zlib::GzipFile::Error
35
+ return [nil, :bad_gzip]
36
+ end
37
+ return [nil, :too_large] if raw.bytesize > MAX_BODY_SIZE
38
+ end
39
+
40
+ [raw, nil]
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentiero
4
+ module Web
5
+ # Minimal RFC 4180 CSV serializer with spreadsheet-formula-injection guarding:
6
+ # cells starting with a formula trigger are prefixed with a single quote so the
7
+ # spreadsheet treats them as text rather than executing them.
8
+ module CsvWriter
9
+ # Tab/CR included: some spreadsheets strip them before re-evaluating the cell.
10
+ FORMULA_TRIGGERS = ["=", "+", "-", "@", "\t", "\r"].freeze
11
+
12
+ module_function
13
+
14
+ def generate(headers, rows)
15
+ ([headers] + rows).map { |row| format_row(row) }.join("\r\n") + "\r\n"
16
+ end
17
+
18
+ def format_row(row)
19
+ row.map { |cell| format_cell(cell) }.join(",")
20
+ end
21
+
22
+ def format_cell(cell)
23
+ quote(guard_injection(stringify(cell)))
24
+ end
25
+
26
+ def stringify(cell)
27
+ case cell
28
+ when nil then ""
29
+ when true then "true"
30
+ when false then "false"
31
+ else cell.to_s
32
+ end
33
+ end
34
+
35
+ def guard_injection(value)
36
+ value.start_with?(*FORMULA_TRIGGERS) ? "'#{value}" : value
37
+ end
38
+
39
+ def quote(value)
40
+ return value unless value.match?(/[",\r\n]/)
41
+ %("#{value.gsub('"', '""')}")
42
+ end
43
+ end
44
+ end
45
+ end