kairos-chain 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. checksums.yaml +7 -0
  2. data/bin/kairos-chain +255 -0
  3. data/bin/kairos_mcp_server +12 -0
  4. data/lib/kairos-chain.rb +5 -0
  5. data/lib/kairos_mcp/action_log.rb +78 -0
  6. data/lib/kairos_mcp/admin/helpers.rb +250 -0
  7. data/lib/kairos_mcp/admin/router.rb +548 -0
  8. data/lib/kairos_mcp/admin/views/_error.erb +5 -0
  9. data/lib/kairos_mcp/admin/views/chain.erb +56 -0
  10. data/lib/kairos_mcp/admin/views/config.erb +118 -0
  11. data/lib/kairos_mcp/admin/views/context.erb +59 -0
  12. data/lib/kairos_mcp/admin/views/dashboard.erb +117 -0
  13. data/lib/kairos_mcp/admin/views/knowledge.erb +73 -0
  14. data/lib/kairos_mcp/admin/views/layout.erb +39 -0
  15. data/lib/kairos_mcp/admin/views/login.erb +37 -0
  16. data/lib/kairos_mcp/admin/views/partials/_chain_blocks.erb +64 -0
  17. data/lib/kairos_mcp/admin/views/partials/_chain_detail.erb +30 -0
  18. data/lib/kairos_mcp/admin/views/partials/_context_detail.erb +43 -0
  19. data/lib/kairos_mcp/admin/views/partials/_context_list.erb +37 -0
  20. data/lib/kairos_mcp/admin/views/partials/_knowledge_detail.erb +44 -0
  21. data/lib/kairos_mcp/admin/views/partials/_skill_detail.erb +37 -0
  22. data/lib/kairos_mcp/admin/views/partials/_token_list.erb +110 -0
  23. data/lib/kairos_mcp/admin/views/skills.erb +50 -0
  24. data/lib/kairos_mcp/admin/views/tokens.erb +46 -0
  25. data/lib/kairos_mcp/anthropic_skill_parser.rb +218 -0
  26. data/lib/kairos_mcp/auth/authenticator.rb +99 -0
  27. data/lib/kairos_mcp/auth/token_store.rb +256 -0
  28. data/lib/kairos_mcp/config_merger.rb +142 -0
  29. data/lib/kairos_mcp/context_manager.rb +196 -0
  30. data/lib/kairos_mcp/dsl_skills_provider.rb +127 -0
  31. data/lib/kairos_mcp/http_server.rb +244 -0
  32. data/lib/kairos_mcp/initializer.rb +185 -0
  33. data/lib/kairos_mcp/kairos.rb +83 -0
  34. data/lib/kairos_mcp/kairos_chain/block.rb +58 -0
  35. data/lib/kairos_mcp/kairos_chain/chain.rb +120 -0
  36. data/lib/kairos_mcp/kairos_chain/merkle_tree.rb +88 -0
  37. data/lib/kairos_mcp/kairos_chain/skill_transition.rb +45 -0
  38. data/lib/kairos_mcp/knowledge_provider.rb +598 -0
  39. data/lib/kairos_mcp/layer_registry.rb +172 -0
  40. data/lib/kairos_mcp/protocol.rb +113 -0
  41. data/lib/kairos_mcp/resource_registry.rb +503 -0
  42. data/lib/kairos_mcp/safe_evolver.rb +345 -0
  43. data/lib/kairos_mcp/safety.rb +130 -0
  44. data/lib/kairos_mcp/server.rb +66 -0
  45. data/lib/kairos_mcp/skill_contexts.rb +145 -0
  46. data/lib/kairos_mcp/skill_tool_adapter.rb +66 -0
  47. data/lib/kairos_mcp/skills_ast.rb +55 -0
  48. data/lib/kairos_mcp/skills_config.rb +178 -0
  49. data/lib/kairos_mcp/skills_dsl.rb +143 -0
  50. data/lib/kairos_mcp/skills_parser.rb +100 -0
  51. data/lib/kairos_mcp/state_commit/commit_service.rb +264 -0
  52. data/lib/kairos_mcp/state_commit/diff_calculator.rb +309 -0
  53. data/lib/kairos_mcp/state_commit/manifest_builder.rb +205 -0
  54. data/lib/kairos_mcp/state_commit/pending_changes.rb +197 -0
  55. data/lib/kairos_mcp/state_commit/snapshot_manager.rb +197 -0
  56. data/lib/kairos_mcp/storage/backend.rb +179 -0
  57. data/lib/kairos_mcp/storage/exporter.rb +195 -0
  58. data/lib/kairos_mcp/storage/file_backend.rb +173 -0
  59. data/lib/kairos_mcp/storage/importer.rb +284 -0
  60. data/lib/kairos_mcp/storage/sqlite_backend.rb +389 -0
  61. data/lib/kairos_mcp/tool_registry.rb +123 -0
  62. data/lib/kairos_mcp/tools/base_tool.rb +87 -0
  63. data/lib/kairos_mcp/tools/chain_export.rb +105 -0
  64. data/lib/kairos_mcp/tools/chain_history.rb +213 -0
  65. data/lib/kairos_mcp/tools/chain_import.rb +269 -0
  66. data/lib/kairos_mcp/tools/chain_record.rb +61 -0
  67. data/lib/kairos_mcp/tools/chain_status.rb +68 -0
  68. data/lib/kairos_mcp/tools/chain_verify.rb +55 -0
  69. data/lib/kairos_mcp/tools/context_create_subdir.rb +80 -0
  70. data/lib/kairos_mcp/tools/context_save.rb +98 -0
  71. data/lib/kairos_mcp/tools/hello_world.rb +57 -0
  72. data/lib/kairos_mcp/tools/knowledge_get.rb +120 -0
  73. data/lib/kairos_mcp/tools/knowledge_list.rb +94 -0
  74. data/lib/kairos_mcp/tools/knowledge_update.rb +139 -0
  75. data/lib/kairos_mcp/tools/resource_list.rb +163 -0
  76. data/lib/kairos_mcp/tools/resource_read.rb +187 -0
  77. data/lib/kairos_mcp/tools/skills_audit.rb +1014 -0
  78. data/lib/kairos_mcp/tools/skills_dsl_get.rb +73 -0
  79. data/lib/kairos_mcp/tools/skills_dsl_list.rb +90 -0
  80. data/lib/kairos_mcp/tools/skills_evolve.rb +168 -0
  81. data/lib/kairos_mcp/tools/skills_get.rb +71 -0
  82. data/lib/kairos_mcp/tools/skills_list.rb +65 -0
  83. data/lib/kairos_mcp/tools/skills_promote.rb +624 -0
  84. data/lib/kairos_mcp/tools/skills_rollback.rb +141 -0
  85. data/lib/kairos_mcp/tools/state_commit.rb +169 -0
  86. data/lib/kairos_mcp/tools/state_history.rb +207 -0
  87. data/lib/kairos_mcp/tools/state_status.rb +121 -0
  88. data/lib/kairos_mcp/tools/system_upgrade.rb +648 -0
  89. data/lib/kairos_mcp/tools/token_manage.rb +249 -0
  90. data/lib/kairos_mcp/tools/tool_guide.rb +834 -0
  91. data/lib/kairos_mcp/upgrade_analyzer.rb +263 -0
  92. data/lib/kairos_mcp/vector_search/base.rb +82 -0
  93. data/lib/kairos_mcp/vector_search/fallback_search.rb +109 -0
  94. data/lib/kairos_mcp/vector_search/provider.rb +99 -0
  95. data/lib/kairos_mcp/vector_search/semantic_search.rb +286 -0
  96. data/lib/kairos_mcp/version.rb +4 -0
  97. data/lib/kairos_mcp/version_manager.rb +96 -0
  98. data/lib/kairos_mcp.rb +219 -0
  99. data/templates/config/safety.yml +13 -0
  100. data/templates/config/tool_metadata.yml +37 -0
  101. data/templates/context/.gitkeep +0 -0
  102. data/templates/knowledge/.gitkeep +0 -0
  103. data/templates/knowledge/example_knowledge/example_knowledge.md +38 -0
  104. data/templates/knowledge/example_knowledge/references/README.md +15 -0
  105. data/templates/knowledge/example_knowledge/scripts/example_script.sh +8 -0
  106. data/templates/knowledge/kairoschain_design/kairoschain_design.md +176 -0
  107. data/templates/knowledge/kairoschain_design_jp/kairoschain_design_jp.md +176 -0
  108. data/templates/knowledge/kairoschain_faq/kairoschain_faq.md +1492 -0
  109. data/templates/knowledge/kairoschain_faq_jp/kairoschain_faq_jp.md +1423 -0
  110. data/templates/knowledge/kairoschain_operations/kairoschain_operations.md +260 -0
  111. data/templates/knowledge/kairoschain_operations_jp/kairoschain_operations_jp.md +299 -0
  112. data/templates/knowledge/kairoschain_philosophy/kairoschain_philosophy.md +193 -0
  113. data/templates/knowledge/kairoschain_philosophy_jp/kairoschain_philosophy_jp.md +193 -0
  114. data/templates/knowledge/kairoschain_setup/kairoschain_setup.md +1356 -0
  115. data/templates/knowledge/kairoschain_setup_jp/kairoschain_setup_jp.md +1340 -0
  116. data/templates/knowledge/kairoschain_usage/kairoschain_usage.md +381 -0
  117. data/templates/knowledge/kairoschain_usage_jp/kairoschain_usage_jp.md +381 -0
  118. data/templates/knowledge/l1_health_guide/l1_health_guide.md +220 -0
  119. data/templates/knowledge/layer_placement_guide/layer_placement_guide.md +179 -0
  120. data/templates/knowledge/mcp_to_saas_development_workflow/mcp_to_saas_development_workflow.md +301 -0
  121. data/templates/knowledge/persona_definitions/persona_definitions.md +270 -0
  122. data/templates/skills/config.yml +207 -0
  123. data/templates/skills/kairos.md +401 -0
  124. data/templates/skills/kairos.rb +760 -0
  125. data/templates/skills/versions/.gitkeep +0 -0
  126. data/templates/storage/embeddings/knowledge/.gitkeep +0 -0
  127. data/templates/storage/embeddings/skills/.gitkeep +0 -0
  128. metadata +206 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 89223ad42654fe812b9219cfeb4d136101e3cf88054cd0dc8f99999c993af43a
4
+ data.tar.gz: c75b155784af0ce1782c67bb5f925b83428ffbab463150567a0bdb04798ced66
5
+ SHA512:
6
+ metadata.gz: 007d915cbb44e3ed2834a1c176dbf28ea9631b7ef2ea88c934e20926f3dace4058f4ea393327dbf5a0923070b5ac35526a88dcae9d25f22f761eef3d38ba40d3
7
+ data.tar.gz: baf5e843902f5e7d3b09dc62087c1dbadd1cda17055400d5a6acc66de9b9df3431507aa551eb61984596b2d84c15c7d23af54cfd4d4ece6387ee42810862aac2
data/bin/kairos-chain ADDED
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # KairosChain - Memory-driven agent framework
5
+ #
6
+ # Usage:
7
+ # kairos-chain # stdio mode (default, for Cursor local)
8
+ # kairos-chain --http # Streamable HTTP mode (remote access)
9
+ # kairos-chain --http --port 9090
10
+ # kairos-chain --init-admin # Generate initial admin token
11
+ # kairos-chain init [DIR] # Initialize data directory with templates
12
+ # kairos-chain init --data-dir /path # Initialize at specific path
13
+ # kairos-chain upgrade # Preview template migrations after gem update
14
+ # kairos-chain upgrade --apply # Apply template migrations
15
+ #
16
+ # Data directory resolution (priority order):
17
+ # 1. --data-dir CLI option
18
+ # 2. KAIROS_DATA_DIR environment variable
19
+ # 3. .kairos/ in the current working directory
20
+ #
21
+ # Streamable HTTP mode requires: gem install puma rack
22
+
23
+ require 'optparse'
24
+
25
+ # Check for subcommands first (before OptionParser)
26
+ case ARGV[0]
27
+ when 'init'
28
+ ARGV.shift # Remove 'init' from ARGV
29
+
30
+ init_options = {}
31
+ OptionParser.new do |opts|
32
+ opts.banner = "Usage: kairos-chain init [options] [DIR]"
33
+
34
+ opts.on('--data-dir DIR', 'Data directory path (default: .kairos/)') do |dir|
35
+ init_options[:data_dir] = dir
36
+ end
37
+
38
+ opts.on('-h', '--help', 'Show help') do
39
+ puts opts
40
+ exit
41
+ end
42
+ end.parse!
43
+
44
+ # Remaining argument is the directory
45
+ init_dir = init_options[:data_dir] || ARGV.shift
46
+
47
+ # Setup load path and require entry point
48
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
49
+ require 'kairos_mcp'
50
+
51
+ # Set data_dir if specified, otherwise use default
52
+ if init_dir
53
+ KairosMcp.data_dir = File.expand_path(init_dir)
54
+ end
55
+
56
+ require 'kairos_mcp/initializer'
57
+ KairosMcp::Initializer.run
58
+ exit
59
+
60
+ when 'upgrade'
61
+ ARGV.shift # Remove 'upgrade' from ARGV
62
+
63
+ upgrade_options = {}
64
+ OptionParser.new do |opts|
65
+ opts.banner = "Usage: kairos-chain upgrade [options]"
66
+
67
+ opts.on('--data-dir DIR', 'Data directory path (default: .kairos/)') do |dir|
68
+ upgrade_options[:data_dir] = dir
69
+ end
70
+
71
+ opts.on('--apply', 'Apply the upgrade (default: preview only)') do
72
+ upgrade_options[:apply] = true
73
+ end
74
+
75
+ opts.on('-h', '--help', 'Show help') do
76
+ puts opts
77
+ exit
78
+ end
79
+ end.parse!
80
+
81
+ # Setup load path and require entry point
82
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
83
+ require 'kairos_mcp'
84
+
85
+ # Set data_dir if specified
86
+ if upgrade_options[:data_dir]
87
+ KairosMcp.data_dir = File.expand_path(upgrade_options[:data_dir])
88
+ end
89
+
90
+ require 'kairos_mcp/upgrade_analyzer'
91
+ require 'kairos_mcp/config_merger'
92
+ require 'kairos_mcp/tools/system_upgrade'
93
+
94
+ # Create a minimal tool instance for CLI use
95
+ tool = KairosMcp::Tools::SystemUpgrade.new
96
+ command = upgrade_options[:apply] ? 'apply' : 'preview'
97
+ result = tool.call({ 'command' => command, 'approved' => upgrade_options[:apply] || false })
98
+
99
+ # Extract text from MCP response format
100
+ result.each do |content|
101
+ puts content[:text] if content[:type] == 'text'
102
+ end
103
+ exit
104
+ end
105
+
106
+ # Parse CLI options
107
+ options = {}
108
+
109
+ OptionParser.new do |opts|
110
+ opts.banner = "Usage: kairos-chain [options]"
111
+
112
+ opts.on('--data-dir DIR', 'Data directory path (default: .kairos/ or KAIROS_DATA_DIR)') do |dir|
113
+ options[:data_dir] = dir
114
+ end
115
+
116
+ opts.on('--http', 'Start in Streamable HTTP mode (default: stdio)') do
117
+ options[:http] = true
118
+ end
119
+
120
+ opts.on('--port PORT', Integer, 'HTTP port (default: 8080)') do |port|
121
+ options[:port] = port
122
+ end
123
+
124
+ opts.on('--host HOST', 'HTTP bind host (default: 0.0.0.0)') do |host|
125
+ options[:host] = host
126
+ end
127
+
128
+ opts.on('--init-admin', 'Generate initial admin token and exit') do
129
+ options[:init_admin] = true
130
+ end
131
+
132
+ opts.on('--token-store PATH', 'Path to token store file') do |path|
133
+ options[:token_store] = path
134
+ end
135
+
136
+ opts.on('-v', '--version', 'Show version') do
137
+ options[:version] = true
138
+ end
139
+
140
+ opts.on('-h', '--help', 'Show help') do
141
+ puts opts
142
+ puts ""
143
+ puts "Subcommands:"
144
+ puts " init [DIR] Initialize data directory with default templates"
145
+ puts " upgrade [--apply] Check/apply template migrations after gem update"
146
+ exit
147
+ end
148
+ end.parse!
149
+
150
+ # Setup bundler if Gemfile exists (enables optional gems)
151
+ gemfile_path = File.expand_path('../Gemfile', __dir__)
152
+ if File.exist?(gemfile_path)
153
+ ENV['BUNDLE_GEMFILE'] ||= gemfile_path
154
+ # Use local vendor/bundle if it exists
155
+ vendor_path = File.expand_path('../vendor/bundle', __dir__)
156
+ ENV['BUNDLE_PATH'] ||= vendor_path if File.directory?(vendor_path)
157
+ begin
158
+ require 'bundler/setup'
159
+ rescue Bundler::GemNotFound, LoadError
160
+ # Fall back to running without bundler if gems not installed
161
+ warn "[KairosChain] Bundler gems not found, running without optional dependencies"
162
+ end
163
+ end
164
+
165
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
166
+
167
+ # Load entry point
168
+ require 'kairos_mcp'
169
+
170
+ # Set data_dir if specified via CLI
171
+ if options[:data_dir]
172
+ KairosMcp.data_dir = File.expand_path(options[:data_dir])
173
+ end
174
+
175
+ # Handle --version
176
+ if options[:version]
177
+ puts "KairosChain MCP Server v#{KairosMcp::VERSION}"
178
+ puts "Data directory: #{KairosMcp.data_dir}"
179
+ exit
180
+ end
181
+
182
+ # Handle --init-admin
183
+ if options[:init_admin]
184
+ require 'kairos_mcp/auth/token_store'
185
+
186
+ store = KairosMcp::Auth::TokenStore.new(options[:token_store])
187
+
188
+ if !store.empty?
189
+ $stderr.puts "[WARNING] Active tokens already exist."
190
+ $stderr.puts "Use the token_manage MCP tool to create additional tokens."
191
+ $stderr.puts ""
192
+ $stderr.puts "Existing tokens:"
193
+ store.list.each do |t|
194
+ $stderr.puts " - #{t[:user]} (#{t[:role]}, expires: #{t[:expires_at] || 'never'})"
195
+ end
196
+ $stderr.puts ""
197
+ $stderr.puts "Proceed anyway? (y/N)"
198
+ answer = $stdin.gets&.strip
199
+ exit unless answer&.downcase == 'y'
200
+ end
201
+
202
+ result = store.create(user: 'admin', role: 'owner', issued_by: 'system')
203
+
204
+ puts ""
205
+ puts "=" * 60
206
+ puts " KairosChain Admin Token Generated"
207
+ puts "=" * 60
208
+ puts ""
209
+ puts " Token: #{result['raw_token']}"
210
+ puts " User: #{result['user']}"
211
+ puts " Role: #{result['role']}"
212
+ puts " Expires: #{result['expires_at'] || 'never'}"
213
+ puts ""
214
+ puts " IMPORTANT: Store this token securely."
215
+ puts " It will NOT be shown again."
216
+ puts ""
217
+ puts " Configure in Cursor mcp.json:"
218
+ puts " {"
219
+ puts " \"mcpServers\": {"
220
+ puts " \"kairos\": {"
221
+ puts " \"url\": \"http://localhost:#{options[:port] || 8080}/mcp\","
222
+ puts " \"headers\": {"
223
+ puts " \"Authorization\": \"Bearer #{result['raw_token']}\""
224
+ puts " }"
225
+ puts " }"
226
+ puts " }"
227
+ puts " }"
228
+ puts ""
229
+ puts "=" * 60
230
+ exit
231
+ end
232
+
233
+ # Auto-initialize if data directory doesn't exist
234
+ unless KairosMcp.initialized?
235
+ $stderr.puts "[KairosChain] Data directory not initialized at: #{KairosMcp.data_dir}"
236
+ $stderr.puts "[KairosChain] Run 'kairos-chain init' to set up, or auto-initializing..."
237
+ require 'kairos_mcp/initializer'
238
+ KairosMcp::Initializer.run(quiet: true)
239
+ end
240
+
241
+ # Handle --http (Streamable HTTP mode)
242
+ if options[:http]
243
+ require 'kairos_mcp/http_server'
244
+
245
+ server = KairosMcp::HttpServer.new(
246
+ port: options[:port],
247
+ host: options[:host],
248
+ token_store_path: options[:token_store]
249
+ )
250
+ server.run
251
+ else
252
+ # Default: stdio mode
253
+ require 'kairos_mcp/server'
254
+ KairosMcp::Server.run
255
+ end
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Backward-compatible shim: kairos_mcp_server -> kairos-chain
5
+ #
6
+ # This executable is kept for backward compatibility with existing
7
+ # configurations (mcp.json, scripts, etc.). It delegates to the
8
+ # main kairos-chain executable.
9
+
10
+ $stderr.puts "[KairosChain] NOTE: 'kairos_mcp_server' is deprecated. Use 'kairos-chain' instead."
11
+
12
+ load File.expand_path('kairos-chain', __dir__)
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Gem entrypoint for kairos-chain
4
+ # Delegates to the internal KairosMcp module
5
+ require_relative 'kairos_mcp'
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+ require 'fileutils'
6
+
7
+ module KairosMcp
8
+ # ActionLog: Records actions for audit trail
9
+ #
10
+ # This class has been refactored to use the Storage backend abstraction.
11
+ # By default, it uses FileBackend (file-based storage).
12
+ # When SQLite is enabled, it uses SqliteBackend.
13
+ #
14
+ class ActionLog
15
+
16
+ class << self
17
+ # Record an action to the log
18
+ #
19
+ # @param action [String] The action performed
20
+ # @param skill_id [String, nil] The skill ID involved
21
+ # @param details [Hash, nil] Additional details
22
+ # @return [Boolean] Success status
23
+ def record(action:, skill_id: nil, details: nil)
24
+ entry = {
25
+ timestamp: Time.now.iso8601,
26
+ action: action,
27
+ skill_id: skill_id,
28
+ details: details
29
+ }
30
+
31
+ storage_backend.record_action(entry)
32
+ end
33
+
34
+ # Get action history
35
+ #
36
+ # @param limit [Integer] Maximum number of entries to return
37
+ # @return [Array<Hash>] Recent action log entries
38
+ def history(limit: 50)
39
+ storage_backend.action_history(limit: limit)
40
+ end
41
+
42
+ # Clear all action logs
43
+ #
44
+ # @return [Boolean] Success status
45
+ def clear!
46
+ storage_backend.clear_action_log!
47
+ end
48
+
49
+ # Get the storage backend type
50
+ # @return [Symbol] :file or :sqlite
51
+ def storage_type
52
+ storage_backend.backend_type
53
+ end
54
+
55
+ # Reset the storage backend (useful for testing)
56
+ def reset_backend!
57
+ @storage_backend = nil
58
+ end
59
+
60
+ # Set a custom storage backend (useful for testing or dependency injection)
61
+ # @param backend [Storage::Backend] The backend to use
62
+ def storage_backend=(backend)
63
+ @storage_backend = backend
64
+ end
65
+
66
+ private
67
+
68
+ def storage_backend
69
+ @storage_backend ||= default_storage_backend
70
+ end
71
+
72
+ def default_storage_backend
73
+ require_relative 'storage/backend'
74
+ Storage::Backend.default
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'securerandom'
5
+ require 'digest'
6
+
7
+ module KairosMcp
8
+ module Admin
9
+ # Helpers: ERB template helpers for the admin UI
10
+ #
11
+ # Provides HTML escaping, template rendering, flash messages,
12
+ # CSRF protection, and session management utilities.
13
+ #
14
+ module Helpers
15
+ VIEWS_DIR = File.expand_path('views', __dir__)
16
+ STATIC_DIR = File.expand_path('static', __dir__)
17
+
18
+ # HTML-escape a string to prevent XSS
19
+ #
20
+ # @param text [String, nil] Raw text
21
+ # @return [String] HTML-escaped text
22
+ def h(text)
23
+ ERB::Util.html_escape(text.to_s)
24
+ end
25
+
26
+ # Render an ERB template
27
+ #
28
+ # @param template_name [String] Template file name (without .erb)
29
+ # @param layout [Boolean] Whether to wrap in layout
30
+ # @param locals [Hash] Local variables for the template
31
+ # @return [String] Rendered HTML
32
+ def render(template_name, layout: true, **locals)
33
+ template_path = File.join(VIEWS_DIR, "#{template_name}.erb")
34
+ template = ERB.new(File.read(template_path), trim_mode: '-')
35
+
36
+ # Make locals available as instance variables for ERB binding
37
+ b = binding
38
+ locals.each { |k, v| b.local_variable_set(k, v) }
39
+
40
+ content = template.result(b)
41
+
42
+ if layout
43
+ render_layout(content)
44
+ else
45
+ content
46
+ end
47
+ end
48
+
49
+ # Render a partial (no layout)
50
+ #
51
+ # @param partial_name [String] Partial file name (without .erb, with _prefix)
52
+ # @param locals [Hash] Local variables
53
+ # @return [String] Rendered HTML fragment
54
+ def render_partial(partial_name, **locals)
55
+ render("partials/#{partial_name}", layout: false, **locals)
56
+ end
57
+
58
+ # Wrap content in the shared layout
59
+ #
60
+ # @param content [String] Page content HTML
61
+ # @return [String] Full HTML page
62
+ def render_layout(content)
63
+ @content = content
64
+ layout_path = File.join(VIEWS_DIR, 'layout.erb')
65
+ layout_template = ERB.new(File.read(layout_path), trim_mode: '-')
66
+ layout_template.result(binding)
67
+ end
68
+
69
+ # Generate an HTML response
70
+ #
71
+ # @param status [Integer] HTTP status code
72
+ # @param body [String] HTML body
73
+ # @return [Array] Rack response triple
74
+ def html_response(status, body)
75
+ [status, { 'Content-Type' => 'text/html; charset=utf-8' }, [body]]
76
+ end
77
+
78
+ # Redirect response
79
+ #
80
+ # @param path [String] Redirect target
81
+ # @return [Array] Rack response triple
82
+ def redirect(path)
83
+ [302, { 'Location' => path }, []]
84
+ end
85
+
86
+ # Serve a static file
87
+ #
88
+ # @param filename [String] File name in static/ directory
89
+ # @return [Array] Rack response triple
90
+ def serve_static(filename)
91
+ filepath = File.join(STATIC_DIR, filename)
92
+ return [404, {}, ['Not found']] unless File.exist?(filepath)
93
+
94
+ content_type = case File.extname(filename)
95
+ when '.css' then 'text/css'
96
+ when '.js' then 'application/javascript'
97
+ when '.png' then 'image/png'
98
+ when '.svg' then 'image/svg+xml'
99
+ else 'application/octet-stream'
100
+ end
101
+
102
+ [200, { 'Content-Type' => content_type, 'Cache-Control' => 'public, max-age=3600' },
103
+ [File.read(filepath)]]
104
+ end
105
+
106
+ # -----------------------------------------------------------------------
107
+ # Session Management
108
+ # -----------------------------------------------------------------------
109
+
110
+ SESSION_COOKIE = 'kairos_admin_session'
111
+ SESSION_SECRET = ENV['KAIROS_SESSION_SECRET'] || SecureRandom.hex(32)
112
+
113
+ # Encode a session value into a signed cookie
114
+ #
115
+ # @param data [Hash] Session data
116
+ # @return [String] Signed cookie value
117
+ def encode_session(data)
118
+ payload = JSON.generate(data)
119
+ encoded = [payload].pack('m0') # Base64 (no newlines)
120
+ signature = sign(encoded)
121
+ "#{encoded}--#{signature}"
122
+ end
123
+
124
+ # Decode and verify a signed session cookie
125
+ #
126
+ # @param cookie_value [String] Raw cookie value
127
+ # @return [Hash, nil] Session data or nil if invalid
128
+ def decode_session(cookie_value)
129
+ return nil unless cookie_value
130
+
131
+ encoded, signature = cookie_value.split('--', 2)
132
+ return nil unless encoded && signature
133
+ return nil unless secure_compare(sign(encoded), signature)
134
+
135
+ payload = encoded.unpack1('m0')
136
+ JSON.parse(payload, symbolize_names: true)
137
+ rescue StandardError
138
+ nil
139
+ end
140
+
141
+ # Extract session from Rack env
142
+ #
143
+ # @param env [Hash] Rack environment
144
+ # @return [Hash, nil] Session data
145
+ def get_session(env)
146
+ cookies = parse_cookies(env)
147
+ cookie_value = cookies[SESSION_COOKIE]
148
+ decode_session(cookie_value)
149
+ end
150
+
151
+ # Build Set-Cookie header for session
152
+ #
153
+ # @param data [Hash] Session data
154
+ # @return [String] Set-Cookie header value
155
+ def session_cookie(data)
156
+ value = encode_session(data)
157
+ "#{SESSION_COOKIE}=#{value}; Path=/admin; HttpOnly; SameSite=Strict"
158
+ end
159
+
160
+ # Build Set-Cookie header to clear session
161
+ #
162
+ # @return [String] Set-Cookie header value
163
+ def clear_session_cookie
164
+ "#{SESSION_COOKIE}=; Path=/admin; HttpOnly; SameSite=Strict; Max-Age=0"
165
+ end
166
+
167
+ # -----------------------------------------------------------------------
168
+ # CSRF Protection
169
+ # -----------------------------------------------------------------------
170
+
171
+ # Generate a CSRF token
172
+ #
173
+ # @return [String] CSRF token
174
+ def generate_csrf_token
175
+ SecureRandom.hex(32)
176
+ end
177
+
178
+ # Verify a CSRF token from form submission
179
+ #
180
+ # @param env [Hash] Rack environment
181
+ # @param session [Hash] Current session
182
+ # @return [Boolean] Whether CSRF token is valid
183
+ def valid_csrf?(env, session)
184
+ body = env['rack.input']&.read
185
+ env['rack.input']&.rewind
186
+ return false unless body
187
+
188
+ params = parse_form_body(body)
189
+ submitted_token = params['_csrf']
190
+ session_token = session&.dig(:csrf_token)
191
+
192
+ return false unless submitted_token && session_token
193
+
194
+ secure_compare(submitted_token, session_token)
195
+ end
196
+
197
+ # -----------------------------------------------------------------------
198
+ # Form/Cookie Parsing
199
+ # -----------------------------------------------------------------------
200
+
201
+ # Parse cookies from Rack env
202
+ #
203
+ # @param env [Hash] Rack environment
204
+ # @return [Hash] Cookie name → value
205
+ def parse_cookies(env)
206
+ cookie_header = env['HTTP_COOKIE'] || ''
207
+ cookie_header.split(';').each_with_object({}) do |pair, hash|
208
+ key, value = pair.strip.split('=', 2)
209
+ hash[key] = value if key
210
+ end
211
+ end
212
+
213
+ # Parse URL-encoded form body
214
+ #
215
+ # @param body [String] Form body
216
+ # @return [Hash] Parameter name → value
217
+ def parse_form_body(body)
218
+ body.split('&').each_with_object({}) do |pair, hash|
219
+ key, value = pair.split('=', 2)
220
+ hash[URI.decode_www_form_component(key)] = URI.decode_www_form_component(value || '')
221
+ end
222
+ end
223
+
224
+ # Parse query string
225
+ #
226
+ # @param env [Hash] Rack environment
227
+ # @return [Hash] Query parameters
228
+ def parse_query(env)
229
+ qs = env['QUERY_STRING'] || ''
230
+ parse_form_body(qs)
231
+ end
232
+
233
+ private
234
+
235
+ def sign(data)
236
+ Digest::SHA256.hexdigest("#{data}#{SESSION_SECRET}")
237
+ end
238
+
239
+ def secure_compare(a, b)
240
+ return false unless a.bytesize == b.bytesize
241
+
242
+ l = a.unpack("C#{a.bytesize}")
243
+ r = b.unpack("C#{b.bytesize}")
244
+ result = 0
245
+ l.zip(r) { |x, y| result |= x ^ y }
246
+ result.zero?
247
+ end
248
+ end
249
+ end
250
+ end