kairos-chain 3.42.2 → 3.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/hestia/config/hestia.yml +24 -0
- data/templates/skillsets/hestia/lib/hestia/import_command_generator.rb +43 -0
- data/templates/skillsets/hestia/lib/hestia/place_router.rb +110 -1
- data/templates/skillsets/hestia/lib/hestia/public_presenter.rb +86 -0
- data/templates/skillsets/hestia/lib/hestia/public_rate_limiter.rb +47 -0
- data/templates/skillsets/hestia/lib/hestia/skill_auditor.rb +395 -0
- data/templates/skillsets/hestia/lib/hestia/skill_board.rb +8 -0
- data/templates/skillsets/hestia/lib/hestia/web/assets/marketplace.css +101 -0
- data/templates/skillsets/hestia/lib/hestia/web/assets/pico.min.css +4 -0
- data/templates/skillsets/hestia/lib/hestia/web/views/web_about.erb +49 -0
- data/templates/skillsets/hestia/lib/hestia/web/views/web_agent.erb +46 -0
- data/templates/skillsets/hestia/lib/hestia/web/views/web_anchor.erb +53 -0
- data/templates/skillsets/hestia/lib/hestia/web/views/web_index.erb +64 -0
- data/templates/skillsets/hestia/lib/hestia/web/views/web_layout.erb +33 -0
- data/templates/skillsets/hestia/lib/hestia/web/views/web_skill.erb +132 -0
- data/templates/skillsets/hestia/lib/hestia/web/views/web_verify.erb +31 -0
- data/templates/skillsets/hestia/lib/hestia/web_router.rb +339 -0
- data/templates/skillsets/hestia/lib/hestia.rb +5 -0
- data/templates/skillsets/hestia/skillset.json +1 -1
- data/templates/skillsets/synoptis/bin/khab_verify.rb +172 -0
- data/templates/skillsets/synoptis/bin/map_verify.rb +250 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/anchoring_rule.rb +184 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/attestation_types.rb +107 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/chain_credential.rb +187 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/containment.rb +222 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/conventions/khab-1.md +112 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/conventions/map-1.md +198 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/cumulative_commitment.rb +229 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/deposit_board.rb +288 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/entry.rb +215 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/head_binding.rb +294 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/log.rb +504 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/public_read.rb +57 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/public_verifier.rb +97 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/read_path.rb +70 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/succession.rb +222 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/write_budget.rb +121 -0
- data/templates/skillsets/synoptis/lib/synoptis/anchoring/write_path.rb +102 -0
- data/templates/skillsets/synoptis/lib/synoptis.rb +11 -0
- data/templates/skillsets/synoptis/test/test_head_anchor.rb +431 -0
- data/templates/skillsets/synoptis/test/test_map_anchoring.rb +359 -0
- metadata +36 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ea7401710d6467287e35146c84d731c08733f184fac36be13e76de11d7af5433
|
|
4
|
+
data.tar.gz: a823ba8bf1bb1157394ff04466cdcf96c0347eee50796816fe8c2fd1d188792c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a11ba74c10eb1c56ef15b57992755083b6d36bd4fef1ea73bf1cae64afc1f07ad26f8774ee703c3c98eefa25c8a422441c5c5af032b9ae523d7518c7dc50193d
|
|
7
|
+
data.tar.gz: 26f95dd3245e9e39311349ef540954d200985a59bf9da38b3b08256d11ec50a445d60cc3f0c41538b6a0c0bfb2eb591ce742dc4523950d9ffe8f06471d22e50a
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,35 @@ All notable changes to the `kairos-chain` gem will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
This project follows [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [3.45.0] - 2026-07-21
|
|
8
|
+
|
|
9
|
+
### Synoptis — mutual anchoring protocol MAP-1..4 (map-1, AUD-L2)
|
|
10
|
+
|
|
11
|
+
Implements AUD-L2 design v0.5 (FROZEN) slice 1 in the synoptis SkillSet:
|
|
12
|
+
|
|
13
|
+
- `conventions/map-1.md`: content-addressed mutual-anchoring convention
|
|
14
|
+
(chain identity credential, typed attestation vocabulary, succession
|
|
15
|
+
records, declared anchoring rule, changeover event). Builds on khab-1
|
|
16
|
+
without modifying it.
|
|
17
|
+
- `chain_credential.rb`: Ed25519 self-authenticating chain identity
|
|
18
|
+
credential — verification needs no registry and no network.
|
|
19
|
+
- `succession.rb`: succession governance (earliest non-retracted designation
|
|
20
|
+
governs; at-or-before changeover boundary; issuer-only retraction;
|
|
21
|
+
retract-and-redesignate trails surfaced in the contested register).
|
|
22
|
+
- `anchoring_rule.rb`: result-free declared anchoring rule (closed schema)
|
|
23
|
+
with a coverage checker that reports and never enforces.
|
|
24
|
+
- `attestation_types.rb`: MAP-4 vocabulary intake validation and anchor-log
|
|
25
|
+
retraction coherence (issuer = depositor at map-1, disclosed deferral).
|
|
26
|
+
- `entry.rb` / `log.rb` / `write_path.rb`: additive `attestation_type` on
|
|
27
|
+
newly appended entries only (AHM-4 byte-invariance preserved).
|
|
28
|
+
- `bin/map_verify.rb`: standalone offline verifier (credential, attestation,
|
|
29
|
+
succession, coverage, pair, retraction subcommands).
|
|
30
|
+
- `test/test_map_anchoring.rb`: 75 design-constraint tests.
|
|
31
|
+
|
|
32
|
+
Design review converged R5 5/6 APPROVE; implementation review converged
|
|
33
|
+
R4 5/5 APPROVE with zero residual P0/P1. The inaugural mutual anchor with a
|
|
34
|
+
second, independently operated HestiaChain instance is deliberately pending.
|
|
35
|
+
|
|
7
36
|
## [3.42.2] - 2026-07-14
|
|
8
37
|
|
|
9
38
|
### Knowledge — loop_engineering_patterns §B: Model/Effort budget allocation
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -13,6 +13,30 @@ meeting_place:
|
|
|
13
13
|
# registry_path: storage/agent_registry.json
|
|
14
14
|
# parent_place_url: null # URL of parent place for federation
|
|
15
15
|
|
|
16
|
+
# Public web surface (unauthenticated, read-only): skill catalog, JSON API,
|
|
17
|
+
# and — when anchoring is enabled — the ANC-7 verification view.
|
|
18
|
+
# Mounted by PlaceRouter under /place/web/* and /place/api/v1/*.
|
|
19
|
+
web_ui:
|
|
20
|
+
enabled: true
|
|
21
|
+
# place_host / place_url anchor the public links and the ANC-7 verification
|
|
22
|
+
# address (place://<place_host>/anchor/<slug>). Set to the served hostname.
|
|
23
|
+
# place_host: meeting.genomicschain.io
|
|
24
|
+
# place_url: https://meeting.genomicschain.io
|
|
25
|
+
|
|
26
|
+
# Anchor attestation capability (ANC-1..9 / BRD-1..4). Opt-in: when disabled
|
|
27
|
+
# the catalog still serves but /place/web/verify and /place/web/anchor/* 404.
|
|
28
|
+
# Enable on the place that hosts external-reference (content-by-reference)
|
|
29
|
+
# anchors — e.g. the constitutive-note / GenomicsChain provenance deployment.
|
|
30
|
+
anchoring:
|
|
31
|
+
enabled: false
|
|
32
|
+
# ANC-9 free-write budget (mechanism, design §11) — tune to observed load.
|
|
33
|
+
# The Sybil disclosure is shown regardless of the numbers.
|
|
34
|
+
per_identity: 60 # writes per identity per window
|
|
35
|
+
aggregate: 600 # writes across all identities per window
|
|
36
|
+
window_seconds: 3600 # refill window
|
|
37
|
+
# log_path: storage/hestia_anchor_log.json
|
|
38
|
+
# attestation_store_path: storage/hestia_anchor_attestations.json
|
|
39
|
+
|
|
16
40
|
# Deposit policy (operator-configurable)
|
|
17
41
|
deposit_policy:
|
|
18
42
|
max_skill_size_bytes: 100000
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
|
|
5
|
+
module Hestia
|
|
6
|
+
# Generates shell-safe import commands for various LLM tools.
|
|
7
|
+
# All interpolated values are escaped via Shellwords.shellescape.
|
|
8
|
+
class ImportCommandGenerator
|
|
9
|
+
def initialize(place_url:)
|
|
10
|
+
@place_url = place_url
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def commands_for(skill, deposit_id:)
|
|
14
|
+
safe_url = Shellwords.shellescape(@place_url)
|
|
15
|
+
safe_skill_id = Shellwords.shellescape(skill[:skill_id] || deposit_id.split('__', 2).last)
|
|
16
|
+
|
|
17
|
+
{
|
|
18
|
+
claude_code: claude_code_command(safe_skill_id, safe_url),
|
|
19
|
+
codex: codex_command(safe_skill_id, safe_url),
|
|
20
|
+
kairos_cli: kairos_cli_command(safe_skill_id, safe_url),
|
|
21
|
+
curl_preview: curl_preview_command(Shellwords.shellescape(deposit_id), safe_url)
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def claude_code_command(skill_id, place_url)
|
|
28
|
+
"claude -p \"Use meeting_acquire_skill to acquire #{skill_id} from place #{place_url}\""
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def codex_command(skill_id, place_url)
|
|
32
|
+
"echo \"Use meeting_acquire_skill to acquire #{skill_id} from place #{place_url}\" | codex exec -"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def kairos_cli_command(skill_id, place_url)
|
|
36
|
+
"kairos-chain acquire --place #{place_url} --skill #{skill_id}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def curl_preview_command(deposit_id, place_url)
|
|
40
|
+
"curl -s #{place_url}/place/api/v1/skill/#{deposit_id}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -55,7 +55,8 @@ module Hestia
|
|
|
55
55
|
end
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
attr_reader :registry, :skill_board, :heartbeat_manager, :session_store, :started_at, :extensions
|
|
58
|
+
attr_reader :registry, :skill_board, :heartbeat_manager, :session_store, :started_at, :extensions,
|
|
59
|
+
:web_router, :anchor_log, :anchor_board
|
|
59
60
|
|
|
60
61
|
def initialize(config: nil)
|
|
61
62
|
@config = config || ::Hestia.load_config
|
|
@@ -63,6 +64,16 @@ module Hestia
|
|
|
63
64
|
@skill_board = nil
|
|
64
65
|
@heartbeat_manager = nil
|
|
65
66
|
@session_store = nil
|
|
67
|
+
# The public (unauthenticated) web UI + JSON API + ANC-7 verification view.
|
|
68
|
+
# Constructed in #start when the operator enables it (default on). The
|
|
69
|
+
# Meeting Place SkillSet owns its own public surface here rather than in the
|
|
70
|
+
# core HTTP server, so mounting it never crosses the L0/SkillSet boundary.
|
|
71
|
+
@web_router = nil
|
|
72
|
+
# Anchor store instances (nil until #start with anchoring enabled). Exposed
|
|
73
|
+
# so an authenticated write entry point can deposit into the same log/board
|
|
74
|
+
# the public ANC-7 view reads from.
|
|
75
|
+
@anchor_log = nil
|
|
76
|
+
@anchor_board = nil
|
|
66
77
|
@started = false
|
|
67
78
|
@started_at = nil
|
|
68
79
|
@self_id = nil
|
|
@@ -121,6 +132,16 @@ module Hestia
|
|
|
121
132
|
observer_id: @self_id
|
|
122
133
|
)
|
|
123
134
|
|
|
135
|
+
# Anchor store (ANC-1 log + BRD board + ANC-9 budget). Built independently
|
|
136
|
+
# of the web UI so it also backs the authenticated write path (the
|
|
137
|
+
# inaugural / external anchoring deposit), and exposed via attr_reader so an
|
|
138
|
+
# operator write entry point deposits into the same instances the public
|
|
139
|
+
# view reads from. [nil, nil] when anchoring is disabled.
|
|
140
|
+
@anchor_log, @anchor_board = build_anchor_store(place_config, default_storage)
|
|
141
|
+
|
|
142
|
+
# Mount the public web surface (catalog/api + ANC-7 verification view).
|
|
143
|
+
@web_router = build_web_router(place_config)
|
|
144
|
+
|
|
124
145
|
# Self-register: the Place IS also a participant (主客未分)
|
|
125
146
|
@registry.self_register(identity)
|
|
126
147
|
|
|
@@ -135,6 +156,84 @@ module Hestia
|
|
|
135
156
|
}
|
|
136
157
|
end
|
|
137
158
|
|
|
159
|
+
# Build the public WebRouter (catalog/api + ANC-7 verification view).
|
|
160
|
+
#
|
|
161
|
+
# The web classes require rack/utils (present in the server bundle) and are
|
|
162
|
+
# required lazily here so a place that never serves the web UI does not pull
|
|
163
|
+
# in rack at load time. A missing dependency degrades to "web UI not mounted"
|
|
164
|
+
# (the authenticated API keeps working) rather than a boot failure.
|
|
165
|
+
#
|
|
166
|
+
# @return [WebRouter, nil] nil when disabled or the dependency is unavailable.
|
|
167
|
+
def build_web_router(place_config)
|
|
168
|
+
web_config = place_config['web_ui'] || {}
|
|
169
|
+
return nil unless web_config.fetch('enabled', true)
|
|
170
|
+
|
|
171
|
+
require_relative 'public_rate_limiter'
|
|
172
|
+
require_relative 'public_presenter'
|
|
173
|
+
require_relative 'import_command_generator'
|
|
174
|
+
require_relative 'web_router'
|
|
175
|
+
|
|
176
|
+
place_host = web_config['place_host']
|
|
177
|
+
place_url = web_config['place_url'] || (place_host && "https://#{place_host}")
|
|
178
|
+
|
|
179
|
+
WebRouter.new(
|
|
180
|
+
skill_board: @skill_board,
|
|
181
|
+
agent_registry: @registry,
|
|
182
|
+
config: {
|
|
183
|
+
'name' => place_config['name'] || 'KairosChain Meeting Place',
|
|
184
|
+
'place_host' => place_host,
|
|
185
|
+
'place_url' => place_url
|
|
186
|
+
}.compact,
|
|
187
|
+
anchor_read: build_anchor_read
|
|
188
|
+
)
|
|
189
|
+
rescue LoadError => e
|
|
190
|
+
warn "[PlaceRouter] Public web UI not mounted (missing dependency): #{e.message}"
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Build the Synoptis-owned public read handle the WebRouter presents through
|
|
195
|
+
# (AHM-1). nil when anchoring is disabled (@anchor_log unset), in which case
|
|
196
|
+
# the anchor routes 404. The write-side handles (@anchor_log/@anchor_board)
|
|
197
|
+
# stay owned here for the authenticated deposit path (design §4.3).
|
|
198
|
+
def build_anchor_read
|
|
199
|
+
return nil unless @anchor_log
|
|
200
|
+
|
|
201
|
+
Synoptis::Anchoring::PublicRead.new(log: @anchor_log, board: @anchor_board)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Build the anchor store (ANC-1 log + BRD deposit board + ANC-9 write budget)
|
|
205
|
+
# when the operator enables anchoring. Returns [nil, nil] when disabled, in
|
|
206
|
+
# which case the WebRouter serves the catalog but the /place/web/verify and
|
|
207
|
+
# /place/web/anchor/* routes 404 (the capability is simply not present).
|
|
208
|
+
#
|
|
209
|
+
# The operator identity (@self_id) is the ANC-5/ANC-8 control boundary: it is
|
|
210
|
+
# the only identity permitted to withdraw any entry and the one against which
|
|
211
|
+
# same-party vs foreign is derived.
|
|
212
|
+
def build_anchor_store(place_config, default_storage)
|
|
213
|
+
anchor_config = place_config['anchoring'] || {}
|
|
214
|
+
return [nil, nil] unless anchor_config['enabled']
|
|
215
|
+
|
|
216
|
+
require 'synoptis'
|
|
217
|
+
|
|
218
|
+
log = Synoptis::Anchoring::Log.new(
|
|
219
|
+
storage_path: anchor_config['log_path'] || "#{default_storage}hestia_anchor_log.json",
|
|
220
|
+
operator_id: @self_id
|
|
221
|
+
)
|
|
222
|
+
budget = Synoptis::Anchoring::WriteBudget.new(
|
|
223
|
+
per_identity: anchor_config['per_identity'] || Synoptis::Anchoring::WriteBudget::DEFAULT_PER_IDENTITY,
|
|
224
|
+
aggregate: anchor_config['aggregate'] || Synoptis::Anchoring::WriteBudget::DEFAULT_AGGREGATE,
|
|
225
|
+
window_seconds: anchor_config['window_seconds'] || Synoptis::Anchoring::WriteBudget::DEFAULT_WINDOW_SECONDS,
|
|
226
|
+
operator_id: @self_id
|
|
227
|
+
)
|
|
228
|
+
board = Synoptis::Anchoring::DepositBoard.new(
|
|
229
|
+
log: log,
|
|
230
|
+
attestation_store_path: anchor_config['attestation_store_path'] ||
|
|
231
|
+
"#{default_storage}hestia_anchor_attestations.json",
|
|
232
|
+
budget: budget
|
|
233
|
+
)
|
|
234
|
+
[log, board]
|
|
235
|
+
end
|
|
236
|
+
|
|
138
237
|
# Rack-compatible call method.
|
|
139
238
|
def call(env)
|
|
140
239
|
unless @started
|
|
@@ -147,6 +246,16 @@ module Hestia
|
|
|
147
246
|
request_method = env['REQUEST_METHOD']
|
|
148
247
|
path = env['PATH_INFO']
|
|
149
248
|
|
|
249
|
+
# Public, unauthenticated, read-only web surface (ANC-7 verification view,
|
|
250
|
+
# skill catalog, JSON API). Delegated before the auth flow — same position
|
|
251
|
+
# as the other unauthenticated endpoints below. The WebRouter enforces
|
|
252
|
+
# GET-only, IP rate limiting, and HTML escaping internally.
|
|
253
|
+
if @web_router &&
|
|
254
|
+
(path == '/place/web' || path.start_with?('/place/web/') ||
|
|
255
|
+
path.start_with?('/place/api/v1/'))
|
|
256
|
+
return @web_router.call(env)
|
|
257
|
+
end
|
|
258
|
+
|
|
150
259
|
# Unauthenticated endpoints
|
|
151
260
|
if request_method == 'GET' && path == '/place/v1/info'
|
|
152
261
|
return handle_info
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hestia
|
|
4
|
+
# Shared presenter for public data — used by both JSON API and HTML views.
|
|
5
|
+
# Truncates IDs and strips content to prevent information leakage.
|
|
6
|
+
class PublicPresenter
|
|
7
|
+
ID_TRUNCATE_LENGTH = 12
|
|
8
|
+
|
|
9
|
+
def catalog_entry(entry, auditor = nil)
|
|
10
|
+
deposit_id = make_deposit_id(entry)
|
|
11
|
+
{
|
|
12
|
+
deposit_id: deposit_id,
|
|
13
|
+
skill_id: entry[:skill_id],
|
|
14
|
+
name: entry[:name],
|
|
15
|
+
description: truncate(entry[:description], 500),
|
|
16
|
+
tags: entry[:tags] || [],
|
|
17
|
+
format: entry[:format],
|
|
18
|
+
type: entry[:type],
|
|
19
|
+
size_bytes: entry[:size_bytes],
|
|
20
|
+
content_hash: entry[:content_hash]&.slice(0, 16),
|
|
21
|
+
deposited_at: entry[:deposited_at],
|
|
22
|
+
depositor_id: truncate_id(entry[:agent_id] || entry[:depositor_id]),
|
|
23
|
+
summary: truncate(entry[:summary], 500),
|
|
24
|
+
version: entry[:version],
|
|
25
|
+
license: entry[:license],
|
|
26
|
+
attestation_count: entry[:attestations]&.size || 0,
|
|
27
|
+
audit_status: auditor ? auditor.audit_status(deposit_id)[:status] : nil
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def skill_detail(skill, auditor = nil)
|
|
32
|
+
base = catalog_entry(skill, auditor)
|
|
33
|
+
base.merge(
|
|
34
|
+
sections: skill[:sections],
|
|
35
|
+
first_lines: skill[:first_lines],
|
|
36
|
+
input_output: skill[:input_output],
|
|
37
|
+
attestations: present_attestations(skill[:attestations]),
|
|
38
|
+
trust_notice: skill[:trust_notice],
|
|
39
|
+
audit_detail: auditor ? auditor.audit_status(make_deposit_id(skill)) : nil
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def agent_profile(agent)
|
|
44
|
+
id = agent.respond_to?(:id) ? agent.id : agent[:id]
|
|
45
|
+
{
|
|
46
|
+
agent_id: truncate_id(id),
|
|
47
|
+
name: agent.respond_to?(:name) ? agent.name : agent[:name],
|
|
48
|
+
description: agent.respond_to?(:description) ? agent.description : agent[:description],
|
|
49
|
+
registered_at: agent.respond_to?(:registered_at) ? agent.registered_at : agent[:registered_at]
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def make_deposit_id(entry)
|
|
54
|
+
agent = entry[:agent_id] || entry[:depositor_id]
|
|
55
|
+
skill = entry[:skill_id]
|
|
56
|
+
"#{agent}__#{skill}" if agent && skill
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def present_attestations(attestations)
|
|
62
|
+
return [] unless attestations
|
|
63
|
+
|
|
64
|
+
attestations.map do |a|
|
|
65
|
+
{
|
|
66
|
+
claim: a[:claim],
|
|
67
|
+
attester_id: truncate_id(a[:attester_id]),
|
|
68
|
+
attester_name: a[:attester_name],
|
|
69
|
+
actor_role: a[:actor_role],
|
|
70
|
+
has_signature: a[:has_signature],
|
|
71
|
+
deposited_at: a[:deposited_at]
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def truncate_id(id)
|
|
77
|
+
return nil unless id
|
|
78
|
+
id.length > ID_TRUNCATE_LENGTH ? "#{id[0, ID_TRUNCATE_LENGTH]}..." : id
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def truncate(text, max)
|
|
82
|
+
return nil unless text
|
|
83
|
+
text.length > max ? "#{text[0, max]}..." : text
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hestia
|
|
4
|
+
# IP-based rate limiter for public web/API endpoints.
|
|
5
|
+
# Bounded memory: TTL eviction + max tracked IPs.
|
|
6
|
+
class PublicRateLimiter
|
|
7
|
+
MAX_REQUESTS_PER_MINUTE = 30
|
|
8
|
+
MAX_TRACKED_IPS = 10_000
|
|
9
|
+
CLEANUP_INTERVAL = 1000
|
|
10
|
+
STALE_SECONDS = 300
|
|
11
|
+
|
|
12
|
+
def initialize(max_rpm: MAX_REQUESTS_PER_MINUTE)
|
|
13
|
+
@max_rpm = max_rpm
|
|
14
|
+
@requests = {}
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
@total_requests = 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def allow?(ip)
|
|
20
|
+
@mutex.synchronize do
|
|
21
|
+
@total_requests += 1
|
|
22
|
+
cleanup! if (@total_requests % CLEANUP_INTERVAL).zero?
|
|
23
|
+
|
|
24
|
+
now = Time.now.to_f
|
|
25
|
+
@requests[ip] = (@requests[ip] || []).select { |t| now - t < 60 }
|
|
26
|
+
|
|
27
|
+
return false if @requests[ip].size >= @max_rpm
|
|
28
|
+
|
|
29
|
+
@requests[ip] << now
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def cleanup!
|
|
37
|
+
now = Time.now.to_f
|
|
38
|
+
@requests.delete_if { |_ip, timestamps| timestamps.all? { |t| now - t >= STALE_SECONDS } }
|
|
39
|
+
|
|
40
|
+
return unless @requests.size > MAX_TRACKED_IPS
|
|
41
|
+
|
|
42
|
+
sorted = @requests.sort_by { |_ip, ts| ts.last || 0 }
|
|
43
|
+
excess = @requests.size - MAX_TRACKED_IPS
|
|
44
|
+
sorted.first(excess).each { |ip, _| @requests.delete(ip) }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|