kairos-chain 3.52.1 → 3.53.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 +34 -0
- data/lib/kairos_mcp/http_server.rb +16 -4
- data/lib/kairos_mcp/skillset_manager.rb +11 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/hestia/lib/hestia/place_router.rb +186 -3
- data/templates/skillsets/skillset_exchange/lib/skillset_exchange/place_extension.rb +382 -6
- data/templates/skillsets/skillset_exchange/skillset.json +2 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 31e74313d89bf76d795f74dd21f641fd702a1c186cec186f7425b8af53d2e012
|
|
4
|
+
data.tar.gz: 0f325ce8ac8876685f7aa266fabf539e228e8fe5b57cfbfc5b6a395d82a8290c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 98f7d0763ee115a0380028deedc06a12a022fad0bfec85b89529527b15763cea236ef16a7ec3304ee3f0b6aa8f381f04f71f0cbd1c4ff425c9f76b77598ed2af
|
|
7
|
+
data.tar.gz: 7ca21d4ec532094e1b0fd0048ba4d8e1acd5bf0f3e4d35473d36ad751ca6f37b93e9ecb447e681be5a2ecf86694cefb7a7bd8ebce8068c43a0d64b70a797ff5d
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,40 @@ 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.53.0] - 2026-07-24
|
|
8
|
+
|
|
9
|
+
### SkillSet Web Catalog — anonymous public presentation surface for SkillSet deposits
|
|
10
|
+
|
|
11
|
+
Adds an anonymous, read-only web catalog for Meeting Place SkillSet deposits
|
|
12
|
+
(including certified distillates), closing the presentation gap with the
|
|
13
|
+
existing skill catalog: SkillSet deposits were previously reachable only
|
|
14
|
+
through the authenticated agent API and had no browser-visible surface.
|
|
15
|
+
|
|
16
|
+
Design `skillset_web_catalog` v0.3.1 FROZEN (design review 3 rounds, R3 6/6
|
|
17
|
+
APPROVE unanimous; implementation review 3 rounds, R3 8/8 APPROVE unanimous).
|
|
18
|
+
|
|
19
|
+
- **PlaceRouter public-route mechanism (WC-1/WC-2)**: extensions declare
|
|
20
|
+
unauthenticated `/place/web/` routes via `#public_route_prefixes` (the
|
|
21
|
+
capability travels with the extension, so every registration path wires it);
|
|
22
|
+
read-only by construction, dispatched through `#public_call` with no peer
|
|
23
|
+
identity; declarations are recorded and introspectable; the window
|
|
24
|
+
contributes method restriction, per-client rate limiting, and transport
|
|
25
|
+
security headers (CSP `default-src 'none'`, `X-Frame-Options: DENY`,
|
|
26
|
+
`nosniff`).
|
|
27
|
+
- **skillset_exchange catalog (WC-3/WC-4/WC-5)**: anonymous `/place/web/skillsets`
|
|
28
|
+
listing + per-deposit detail views. Certificate summary is projected onto the
|
|
29
|
+
CD-2 checkability vocabulary at the deposit crossing (never on the anonymous
|
|
30
|
+
path); provenance is rendered as a co-equal field with three truthful states
|
|
31
|
+
and no ranking/badge/filter keyed on certificate presence (CD-3); depositor
|
|
32
|
+
text is structurally separated and HTML-escaped; per-deposit-instance
|
|
33
|
+
addresses sever withdrawn/replaced listings; `no-store` caching.
|
|
34
|
+
- **docker/Caddyfile**: `header_up X-Real-IP {remote_host}` on the meeting-place
|
|
35
|
+
reverse-proxy blocks so the per-client rate limit keys on the real client and
|
|
36
|
+
cannot be spoofed or collapsed to the shared proxy IP.
|
|
37
|
+
|
|
38
|
+
New test suite `test_skillset_web_catalog.rb` (68 assertions). Exchange
|
|
39
|
+
interfaces, storage, and the hestia skill catalog are consumed unchanged.
|
|
40
|
+
|
|
7
41
|
## [3.51.3] - 2026-07-23
|
|
8
42
|
|
|
9
43
|
### Multi-LLM Review — knowledge template sync (v3.6.1)
|
|
@@ -374,10 +374,22 @@ module KairosMcp
|
|
|
374
374
|
require_path = File.join(ss.path, ext_def['require'])
|
|
375
375
|
require require_path
|
|
376
376
|
ext_class = Object.const_get(ext_def['class'])
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
)
|
|
377
|
+
# public_routes (skillset web catalog design WC-2): pass only when the
|
|
378
|
+
# installed hestia PlaceRouter supports it, so a newer gem composes
|
|
379
|
+
# with an older hestia SkillSet (and vice versa) without breaking.
|
|
380
|
+
supports_public = router.method(:register_extension).parameters.any? { |_type, name| name == :public_routes }
|
|
381
|
+
if supports_public
|
|
382
|
+
router.register_extension(
|
|
383
|
+
ext_class.new(router),
|
|
384
|
+
route_action_map: ext_def['route_actions'] || {},
|
|
385
|
+
public_routes: ext_def['public_routes'] || []
|
|
386
|
+
)
|
|
387
|
+
else
|
|
388
|
+
router.register_extension(
|
|
389
|
+
ext_class.new(router),
|
|
390
|
+
route_action_map: ext_def['route_actions'] || {}
|
|
391
|
+
)
|
|
392
|
+
end
|
|
381
393
|
rescue StandardError => e
|
|
382
394
|
$stderr.puts "[HttpServer] Failed to load extension '#{ext_def['class']}' " \
|
|
383
395
|
"from SkillSet '#{ss.name}': #{e.message}"
|
|
@@ -457,6 +457,17 @@ module KairosMcp
|
|
|
457
457
|
|
|
458
458
|
if !File.exist?(dst)
|
|
459
459
|
changed << rel
|
|
460
|
+
elsif rel.start_with?('config/')
|
|
461
|
+
# An existing config/ file is operator-owned state (activation
|
|
462
|
+
# flags, adopted profiles): a difference from the template is
|
|
463
|
+
# user customization, not a pending upgrade — never listed and
|
|
464
|
+
# therefore never overwritten by upgrade_apply. Parity with
|
|
465
|
+
# install(force:)'s config preservation and the docker
|
|
466
|
+
# entrypoint's config-preserving sync. (Production incident
|
|
467
|
+
# 2026-07-24: upgrade --apply silently reset the confidentiality
|
|
468
|
+
# guard's enabled: true to the template's false.) Absent config
|
|
469
|
+
# files still install above (new shipped config).
|
|
470
|
+
next
|
|
460
471
|
elsif Digest::SHA256.file(src).hexdigest != Digest::SHA256.file(dst).hexdigest
|
|
461
472
|
changed << rel
|
|
462
473
|
end
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -79,21 +79,113 @@ module Hestia
|
|
|
79
79
|
@self_id = nil
|
|
80
80
|
@extensions = []
|
|
81
81
|
@extension_action_map = {}
|
|
82
|
+
# WC-2 (skillset web catalog design): public (unauthenticated) routes
|
|
83
|
+
# declared by extensions. The router learns only that an extension has
|
|
84
|
+
# public routes under a prefix, never what they mean. Declarations are
|
|
85
|
+
# recorded acts (introspectable via #status).
|
|
86
|
+
@public_routes = {}
|
|
87
|
+
@public_route_declarations = []
|
|
88
|
+
@public_rate_limiter = nil
|
|
82
89
|
end
|
|
83
90
|
|
|
91
|
+
# Public web namespace an extension may declare routes under. Constraining
|
|
92
|
+
# the namespace keeps the public surface within the window's established
|
|
93
|
+
# anonymous-access discipline.
|
|
94
|
+
PUBLIC_ROUTE_NAMESPACE = '/place/web/'
|
|
95
|
+
|
|
84
96
|
# Register a PlaceRouter extension for additional endpoint handling.
|
|
85
97
|
# Extensions receive authenticated requests and return Rack responses or nil.
|
|
86
98
|
# Idempotent: skips if an extension of the same class is already registered.
|
|
87
99
|
#
|
|
100
|
+
# Public routes (WC-1/WC-2): an extension may additionally declare
|
|
101
|
+
# unauthenticated, read-only route prefixes under /place/web/. The router
|
|
102
|
+
# enforces the window discipline (GET/HEAD only, IP rate limiting) and
|
|
103
|
+
# dispatches via #public_call(env) — a separate entry point that carries no
|
|
104
|
+
# authenticated capability, so the anonymous invocation path is read-only
|
|
105
|
+
# at the capability level, not merely by method filtering. The declaration
|
|
106
|
+
# is a recorded act, kept in the router's declaration registry.
|
|
107
|
+
#
|
|
108
|
+
# Public routes are sourced from the extension itself when it responds to
|
|
109
|
+
# #public_route_prefixes (the capability travels with the extension, WC-2),
|
|
110
|
+
# so every registration path — startup, lazy tool-triggered, STDIO —
|
|
111
|
+
# wires them identically without each call site having to remember. An
|
|
112
|
+
# explicit +public_routes+ argument, when given, is unioned in (used by
|
|
113
|
+
# tests and the config-driven path).
|
|
114
|
+
#
|
|
115
|
+
# Idempotent-with-upgrade: a second registration of the same class is
|
|
116
|
+
# skipped, but if the already-registered instance carries no public routes
|
|
117
|
+
# and this call supplies them, they are attached to the existing instance —
|
|
118
|
+
# so a lazy path that registered first (without routes) does not
|
|
119
|
+
# permanently disable the catalog.
|
|
120
|
+
#
|
|
88
121
|
# @param extension [Object] Extension instance responding to #call(env, peer_id:)
|
|
89
122
|
# @param route_action_map [Hash] Maps route segments to action names for access control
|
|
90
|
-
|
|
91
|
-
|
|
123
|
+
# @param public_routes [Array<String>] Extra path prefixes under /place/web/ served unauthenticated
|
|
124
|
+
def register_extension(extension, route_action_map: {}, public_routes: [])
|
|
125
|
+
declared = public_routes.dup
|
|
126
|
+
if extension.respond_to?(:public_route_prefixes)
|
|
127
|
+
declared |= Array(extension.public_route_prefixes)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
existing = @extensions.find { |e| e.class == extension.class }
|
|
131
|
+
if existing
|
|
132
|
+
# Upgrade an earlier lazy registration: attach only the prefixes the
|
|
133
|
+
# existing instance does not already own, under the same public_call
|
|
134
|
+
# validation a fresh registration gets.
|
|
135
|
+
missing = declared.reject { |prefix| route_owned_by?(existing, prefix) }
|
|
136
|
+
unless missing.empty?
|
|
137
|
+
validate_public_call!(existing, missing)
|
|
138
|
+
attach_public_routes(existing, missing)
|
|
139
|
+
end
|
|
140
|
+
return
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
validate_public_call!(extension, declared) unless declared.empty?
|
|
92
144
|
|
|
93
145
|
@extensions << extension
|
|
94
146
|
@extension_action_map.merge!(route_action_map)
|
|
147
|
+
attach_public_routes(extension, declared) unless declared.empty?
|
|
95
148
|
end
|
|
96
149
|
|
|
150
|
+
# Recorded public-route declarations (WC-2: declaration as a recorded,
|
|
151
|
+
# checkable act). Deep read-only copy (strings included) so callers cannot
|
|
152
|
+
# mutate the registry.
|
|
153
|
+
def public_route_declarations
|
|
154
|
+
@public_route_declarations.map { |d| { **d, prefixes: d[:prefixes].map(&:dup) } }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Attach validated public route prefixes to an extension and record the
|
|
158
|
+
# declaration. Shared by fresh registration and lazy-upgrade paths.
|
|
159
|
+
def attach_public_routes(extension, prefixes)
|
|
160
|
+
prefixes.each do |prefix|
|
|
161
|
+
unless prefix.is_a?(String) && prefix.start_with?(PUBLIC_ROUTE_NAMESPACE) &&
|
|
162
|
+
prefix.chomp('/').length > PUBLIC_ROUTE_NAMESPACE.length
|
|
163
|
+
raise ArgumentError,
|
|
164
|
+
"public route prefix must be under #{PUBLIC_ROUTE_NAMESPACE} with a non-empty segment: #{prefix.inspect}"
|
|
165
|
+
end
|
|
166
|
+
@public_routes[prefix.chomp('/')] = extension
|
|
167
|
+
end
|
|
168
|
+
@public_route_declarations << {
|
|
169
|
+
extension_class: extension.class.name,
|
|
170
|
+
prefixes: prefixes.map { |p| p.dup.freeze },
|
|
171
|
+
declared_at: Time.now.utc.iso8601
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
private :attach_public_routes
|
|
175
|
+
|
|
176
|
+
def validate_public_call!(extension, prefixes)
|
|
177
|
+
return if prefixes.empty?
|
|
178
|
+
return if extension.respond_to?(:public_call)
|
|
179
|
+
|
|
180
|
+
raise ArgumentError, "#{extension.class} declares public routes but does not respond to #public_call"
|
|
181
|
+
end
|
|
182
|
+
private :validate_public_call!
|
|
183
|
+
|
|
184
|
+
def route_owned_by?(extension, prefix)
|
|
185
|
+
@public_routes[prefix.chomp('/')].equal?(extension)
|
|
186
|
+
end
|
|
187
|
+
private :route_owned_by?
|
|
188
|
+
|
|
97
189
|
# Start the Meeting Place: initialize components and self-register.
|
|
98
190
|
#
|
|
99
191
|
# @param identity [MMP::Identity] This instance's identity
|
|
@@ -246,6 +338,14 @@ module Hestia
|
|
|
246
338
|
request_method = env['REQUEST_METHOD']
|
|
247
339
|
path = env['PATH_INFO']
|
|
248
340
|
|
|
341
|
+
# Extension-declared public routes (WC-1/WC-2): checked before the
|
|
342
|
+
# WebRouter because their prefixes are more specific than the shared
|
|
343
|
+
# /place/web/ namespace. The window contributes method restriction and
|
|
344
|
+
# rate limiting here; content-side escaping is owned by the extension.
|
|
345
|
+
if (public_ext = match_public_route(path))
|
|
346
|
+
return handle_public_extension_route(env, public_ext, request_method)
|
|
347
|
+
end
|
|
348
|
+
|
|
249
349
|
# Public, unauthenticated, read-only web surface (ANC-7 verification view,
|
|
250
350
|
# skill catalog, JSON API). Delegated before the auth flow — same position
|
|
251
351
|
# as the other unauthenticated endpoints below. The WebRouter enforces
|
|
@@ -363,12 +463,95 @@ module Hestia
|
|
|
363
463
|
uptime_seconds: @started_at ? (Time.now.utc - @started_at).to_i : 0,
|
|
364
464
|
deposits: @skill_board.deposit_stats,
|
|
365
465
|
last_heartbeat_check: heartbeat_result,
|
|
366
|
-
federation_cleanup: cleanup_result
|
|
466
|
+
federation_cleanup: cleanup_result,
|
|
467
|
+
public_route_declarations: public_route_declarations
|
|
367
468
|
}
|
|
368
469
|
end
|
|
369
470
|
|
|
370
471
|
private
|
|
371
472
|
|
|
473
|
+
# --- Extension public routes (window discipline) ---
|
|
474
|
+
|
|
475
|
+
# Match a request path against declared public route prefixes.
|
|
476
|
+
# Longest-prefix wins, so a more specific prefix is never shadowed by a
|
|
477
|
+
# broader one regardless of declaration order.
|
|
478
|
+
def match_public_route(path)
|
|
479
|
+
return nil if @public_routes.empty?
|
|
480
|
+
|
|
481
|
+
normalized = path.chomp('/')
|
|
482
|
+
best = nil
|
|
483
|
+
@public_routes.each do |prefix, ext|
|
|
484
|
+
next unless normalized == prefix || path.start_with?("#{prefix}/")
|
|
485
|
+
best = [prefix, ext] if best.nil? || prefix.length > best[0].length
|
|
486
|
+
end
|
|
487
|
+
best&.last
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Transport security headers the anonymous window contributes to every
|
|
491
|
+
# extension-served public response (WC-1: the window owns transport
|
|
492
|
+
# discipline). Mirrors the WebRouter's public HTML posture. The extension
|
|
493
|
+
# keeps content-specific headers (its own Content-Type, Cache-Control).
|
|
494
|
+
PUBLIC_SECURITY_HEADERS = {
|
|
495
|
+
'X-Content-Type-Options' => 'nosniff',
|
|
496
|
+
'X-Frame-Options' => 'DENY',
|
|
497
|
+
'Content-Security-Policy' => "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'"
|
|
498
|
+
}.freeze
|
|
499
|
+
|
|
500
|
+
# Serve an extension-declared public route under the window discipline:
|
|
501
|
+
# read-only methods only, IP rate limiting shared across all extension
|
|
502
|
+
# public routes, and dispatch through #public_call — which receives no
|
|
503
|
+
# peer identity and no session capability (invocation-level read-only).
|
|
504
|
+
def handle_public_extension_route(env, extension, request_method)
|
|
505
|
+
unless %w[GET HEAD].include?(request_method)
|
|
506
|
+
return decorate_public([405, { 'Content-Type' => 'text/plain', 'Allow' => 'GET, HEAD' }, ['Method Not Allowed']], request_method)
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Resolve the client IP through the same trusted-proxy-aware resolver the
|
|
510
|
+
# authenticated path uses, so the anonymous rate limit is per real client
|
|
511
|
+
# and cannot be bypassed by a forged X-Real-IP / X-Forwarded-For header.
|
|
512
|
+
ip = resolve_public_ip(env)
|
|
513
|
+
unless public_extension_rate_limiter.allow?(ip)
|
|
514
|
+
return decorate_public([429, { 'Content-Type' => 'text/plain' }, ['Rate limit exceeded. Try again later.']], request_method)
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
result = extension.public_call(env)
|
|
518
|
+
result ||= [404, { 'Content-Type' => 'text/html' }, ['Not Found']]
|
|
519
|
+
decorate_public(result, request_method)
|
|
520
|
+
rescue StandardError => e
|
|
521
|
+
$stderr.puts "[PlaceRouter] Public extension route error: #{e.class}: #{e.message}"
|
|
522
|
+
decorate_public([500, { 'Content-Type' => 'text/plain' }, ['Internal Server Error']], request_method)
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# Client IP for anonymous rate limiting. Uses ServiceGrant's trusted-proxy
|
|
526
|
+
# resolver when present (as the authenticated path does); otherwise falls
|
|
527
|
+
# back to REMOTE_ADDR — never a raw client-suppliable header, so the limit
|
|
528
|
+
# cannot be evaded by spoofing X-Real-IP.
|
|
529
|
+
def resolve_public_ip(env)
|
|
530
|
+
if defined?(ServiceGrant) && ServiceGrant.respond_to?(:ip_resolver) && ServiceGrant.ip_resolver
|
|
531
|
+
ServiceGrant.ip_resolver.resolve(env) || env['REMOTE_ADDR'] || 'unknown'
|
|
532
|
+
else
|
|
533
|
+
env['REMOTE_ADDR'] || 'unknown'
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# Add the window's transport security headers and strip the body for HEAD.
|
|
538
|
+
def decorate_public(response, request_method)
|
|
539
|
+
status, headers, body = response
|
|
540
|
+
# Window security headers win over anything the extension set — the
|
|
541
|
+
# window owns transport discipline (WC-1); the extension keeps its
|
|
542
|
+
# content headers (Content-Type, Cache-Control) which are not in the set.
|
|
543
|
+
merged = headers.merge(PUBLIC_SECURITY_HEADERS)
|
|
544
|
+
body = [] if request_method == 'HEAD'
|
|
545
|
+
[status, merged, body]
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def public_extension_rate_limiter
|
|
549
|
+
@public_rate_limiter ||= begin
|
|
550
|
+
require_relative 'public_rate_limiter'
|
|
551
|
+
PublicRateLimiter.new
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
|
|
372
555
|
# --- Handlers ---
|
|
373
556
|
|
|
374
557
|
# GET /place/v1/info — Public, no auth
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'cgi'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'securerandom'
|
|
3
6
|
require 'fileutils'
|
|
4
7
|
require 'json'
|
|
5
8
|
require 'uri'
|
|
@@ -9,6 +12,7 @@ require 'rubygems/package'
|
|
|
9
12
|
require 'stringio'
|
|
10
13
|
require 'time'
|
|
11
14
|
require 'yaml'
|
|
15
|
+
require 'tmpdir'
|
|
12
16
|
|
|
13
17
|
module SkillsetExchange
|
|
14
18
|
# PlaceExtension adds SkillSet deposit/browse/content/withdraw endpoints
|
|
@@ -26,6 +30,50 @@ module SkillsetExchange
|
|
|
26
30
|
['POST', '/place/v1/skillset_withdraw'] => :handle_skillset_withdraw,
|
|
27
31
|
}.freeze
|
|
28
32
|
|
|
33
|
+
# Anonymous, read-only web catalog (skillset web catalog design WC-1..WC-5).
|
|
34
|
+
# The router sources these prefixes from #public_route_prefixes at every
|
|
35
|
+
# registration path (the capability travels with the extension, WC-2); the
|
|
36
|
+
# router contributes method restriction, rate limiting, and transport
|
|
37
|
+
# security headers, while this extension owns rendering, context-correct
|
|
38
|
+
# escaping, and the disclosure bound.
|
|
39
|
+
PUBLIC_PREFIX = '/place/web/skillsets'
|
|
40
|
+
|
|
41
|
+
# CD-2 checkability vocabulary. The anonymous summary projects the
|
|
42
|
+
# certificate's statuses onto exactly these claim keys, and only when the
|
|
43
|
+
# value is one of the CD-2 status words — so a malformed or quality-shaped
|
|
44
|
+
# certificate cannot inject non-vocabulary content into the machinery-owned
|
|
45
|
+
# provenance register (WC-3/WC-4). Everything else is dropped.
|
|
46
|
+
CD2_STATUS_VALUES = %w[checkable anchor-pending trusted].freeze
|
|
47
|
+
CD2_STATUS_KEYS = %w[
|
|
48
|
+
identity.binding identity.continuity identity.uniqueness
|
|
49
|
+
derivation recording reissuance_citation_observance
|
|
50
|
+
drawn_from revocation_status
|
|
51
|
+
].freeze
|
|
52
|
+
MAX_SUMMARY_IDENTITY_LEN = 128
|
|
53
|
+
MAX_SUMMARY_CHANNEL_LEN = 128
|
|
54
|
+
|
|
55
|
+
# Public route prefixes this extension serves unauthenticated (WC-2). Read
|
|
56
|
+
# by PlaceRouter#register_extension at every registration path.
|
|
57
|
+
def self.public_route_prefixes
|
|
58
|
+
[PUBLIC_PREFIX]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def public_route_prefixes
|
|
62
|
+
self.class.public_route_prefixes
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# In-package provenance certificate file (chain_distillation CD-7/BL-S2-6).
|
|
66
|
+
CERTIFICATE_FILENAME = 'certificate.json'
|
|
67
|
+
|
|
68
|
+
HTML_HEADERS = {
|
|
69
|
+
'Content-Type' => 'text/html; charset=utf-8',
|
|
70
|
+
# WC-5 cache-lifecycle bound: no cache this design controls may serve a
|
|
71
|
+
# listing beyond the deposit it describes. BL-WC-4 default: no-store,
|
|
72
|
+
# which also instructs caches the design does not control.
|
|
73
|
+
'Cache-Control' => 'no-store',
|
|
74
|
+
'X-Content-Type-Options' => 'nosniff'
|
|
75
|
+
}.freeze
|
|
76
|
+
|
|
29
77
|
# Executable extensions to check in tar headers (same as Skillset::EXECUTABLE_EXTENSIONS)
|
|
30
78
|
EXECUTABLE_EXTENSIONS = %w[.rb .py .sh .pl .js .ts .lua .exe .so .dylib .dll .class .jar .wasm].freeze
|
|
31
79
|
|
|
@@ -42,10 +90,23 @@ module SkillsetExchange
|
|
|
42
90
|
@session_store = router.session_store
|
|
43
91
|
@registry = router.registry
|
|
44
92
|
@deposited_skillsets = {} # { "name:depositor_id" => metadata_hash }
|
|
93
|
+
# Guards @deposited_skillsets across authenticated writer threads
|
|
94
|
+
# (deposit/withdraw) and anonymous reader threads (catalog/detail render).
|
|
95
|
+
@state_mutex = Mutex.new
|
|
45
96
|
@config = load_config
|
|
46
97
|
@storage_dir = resolve_storage_dir
|
|
47
98
|
FileUtils.mkdir_p(@storage_dir) if @storage_dir
|
|
48
99
|
load_state
|
|
100
|
+
# BL-WC-5 launch backfill: pre-existing deposits get their certificate
|
|
101
|
+
# state and listing address here, at construction — never on the
|
|
102
|
+
# anonymous request path. Scoped rescue so only backfill failures are
|
|
103
|
+
# non-fatal; storage/config init failures still propagate to the
|
|
104
|
+
# registration error handler.
|
|
105
|
+
begin
|
|
106
|
+
backfill_listing_state!
|
|
107
|
+
rescue StandardError => e
|
|
108
|
+
$stderr.puts "[SkillsetExchange] Listing-state backfill failed (non-fatal): #{e.message}"
|
|
109
|
+
end
|
|
49
110
|
end
|
|
50
111
|
|
|
51
112
|
# Rack-compatible dispatch. Returns Rack response or nil (not handled).
|
|
@@ -58,6 +119,22 @@ module SkillsetExchange
|
|
|
58
119
|
send(handler, env, peer_id)
|
|
59
120
|
end
|
|
60
121
|
|
|
122
|
+
# Anonymous public dispatch (WC-1): invoked by the PlaceRouter for declared
|
|
123
|
+
# public routes. Receives no peer identity and no session capability — the
|
|
124
|
+
# anonymous invocation path is read-only at the capability level. Returns a
|
|
125
|
+
# Rack response or nil (router 404s).
|
|
126
|
+
def public_call(env)
|
|
127
|
+
return nil unless %w[GET HEAD].include?(env['REQUEST_METHOD'])
|
|
128
|
+
|
|
129
|
+
path = env['PATH_INFO']
|
|
130
|
+
case path
|
|
131
|
+
when PUBLIC_PREFIX, "#{PUBLIC_PREFIX}/"
|
|
132
|
+
render_public_catalog(env)
|
|
133
|
+
when %r{\A#{Regexp.escape(PUBLIC_PREFIX)}/([a-f0-9]{16})/?\z}
|
|
134
|
+
render_public_detail(Regexp.last_match(1))
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
61
138
|
private
|
|
62
139
|
|
|
63
140
|
# -----------------------------------------------------------------------
|
|
@@ -141,6 +218,7 @@ module SkillsetExchange
|
|
|
141
218
|
# Extract to temp dir, create Skillset, compare content_hash
|
|
142
219
|
hash_verified = false
|
|
143
220
|
canonical_metadata = {}
|
|
221
|
+
certificate_state = nil
|
|
144
222
|
begin
|
|
145
223
|
Dir.mktmpdir('kairos_ss_deposit') do |tmpdir|
|
|
146
224
|
extract_tar_gz(archive_data, tmpdir)
|
|
@@ -155,6 +233,10 @@ module SkillsetExchange
|
|
|
155
233
|
provides: temp_ss.provides,
|
|
156
234
|
file_count: temp_ss.file_list.size
|
|
157
235
|
}
|
|
236
|
+
# BL-WC-5 (deposit-time extraction point): the certificate summary
|
|
237
|
+
# is derived here, at the deposit crossing, from the verified
|
|
238
|
+
# archive — never on the anonymous request path.
|
|
239
|
+
certificate_state = extract_certificate_state(extracted_dir)
|
|
158
240
|
if actual_hash == content_hash
|
|
159
241
|
hash_verified = true
|
|
160
242
|
else
|
|
@@ -256,12 +338,23 @@ module SkillsetExchange
|
|
|
256
338
|
provides: canonical_metadata[:provides] || provides,
|
|
257
339
|
archive_size_bytes: archive_data.bytesize,
|
|
258
340
|
file_count: file_list.size,
|
|
259
|
-
|
|
341
|
+
# Microsecond precision: the listing address derives from this value,
|
|
342
|
+
# and WC-5's identity severance must hold even for same-second
|
|
343
|
+
# replacements.
|
|
344
|
+
deposited_at: Time.now.utc.iso8601(6),
|
|
345
|
+
certificate: certificate_state
|
|
260
346
|
}
|
|
347
|
+
# WC-5 listing identity: a per-deposit-instance address. A fresh random
|
|
348
|
+
# nonce per deposit makes severance unconditional — even two replacements
|
|
349
|
+
# of the same (name, depositor) within the same microsecond get distinct
|
|
350
|
+
# addresses, so a new deposit never wears a predecessor's address.
|
|
351
|
+
metadata[:deposit_nonce] = SecureRandom.hex(16)
|
|
352
|
+
metadata[:listing_address] = listing_address_for(metadata)
|
|
261
353
|
File.write(File.join(deposit_dir, 'metadata.json'), JSON.pretty_generate(metadata))
|
|
262
354
|
|
|
263
|
-
# Replace existing deposit from same agent with same name
|
|
264
|
-
|
|
355
|
+
# Replace existing deposit from same agent with same name.
|
|
356
|
+
# Guarded so an anonymous reader's snapshot never sees a torn write.
|
|
357
|
+
@state_mutex.synchronize { @deposited_skillsets[deposit_key] = metadata }
|
|
265
358
|
save_state
|
|
266
359
|
|
|
267
360
|
# 11. Record chain event
|
|
@@ -489,8 +582,8 @@ module SkillsetExchange
|
|
|
489
582
|
})
|
|
490
583
|
end
|
|
491
584
|
|
|
492
|
-
# 4. Remove from in-memory state
|
|
493
|
-
@deposited_skillsets.delete(deposit_key)
|
|
585
|
+
# 4. Remove from in-memory state (guarded against anonymous readers)
|
|
586
|
+
@state_mutex.synchronize { @deposited_skillsets.delete(deposit_key) }
|
|
494
587
|
|
|
495
588
|
# 5. Delete disk files (use trusted metadata values, not raw request input)
|
|
496
589
|
deposit_dir = File.join(@storage_dir, "#{meta[:name]}_#{sanitize_id(meta[:depositor_id])}")
|
|
@@ -629,6 +722,284 @@ module SkillsetExchange
|
|
|
629
722
|
end
|
|
630
723
|
end
|
|
631
724
|
|
|
725
|
+
# -----------------------------------------------------------------------
|
|
726
|
+
# Certificate summary (skillset web catalog design WC-3/WC-4, BL-WC-2/5)
|
|
727
|
+
# -----------------------------------------------------------------------
|
|
728
|
+
|
|
729
|
+
# Derive the deposit-judgment certificate state from a verified extracted
|
|
730
|
+
# package directory. Three truthful states (WC-4):
|
|
731
|
+
# { present: false } — no certificate in the package
|
|
732
|
+
# { present: true, summary: {...} } — certificate, summary derived
|
|
733
|
+
# { present: true, summary: nil } — certificate, summary unavailable
|
|
734
|
+
# The summary is a CD-5-bounded projection of the certificate's own claims:
|
|
735
|
+
# identity, checkability statuses (CD-2 vocabulary), and the revocation
|
|
736
|
+
# channel. Never payload-derived content, never openings/salts, never
|
|
737
|
+
# diagnostics.
|
|
738
|
+
def extract_certificate_state(extracted_dir)
|
|
739
|
+
cert_path = File.join(extracted_dir, CERTIFICATE_FILENAME)
|
|
740
|
+
return { present: false } unless File.exist?(cert_path)
|
|
741
|
+
|
|
742
|
+
cert = JSON.parse(File.read(cert_path))
|
|
743
|
+
core = cert['claim_core']
|
|
744
|
+
return { present: true, summary: nil } unless core.is_a?(Hash)
|
|
745
|
+
|
|
746
|
+
identity = core['certificate_identity']
|
|
747
|
+
unless identity.is_a?(String) && !identity.empty? && identity.length <= MAX_SUMMARY_IDENTITY_LEN
|
|
748
|
+
return { present: true, summary: nil }
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
channel = core.dig('recording', 'revocation_channel')
|
|
752
|
+
channel = nil unless channel.is_a?(String) && channel.length <= MAX_SUMMARY_CHANNEL_LEN
|
|
753
|
+
|
|
754
|
+
{
|
|
755
|
+
present: true,
|
|
756
|
+
summary: {
|
|
757
|
+
certificate_identity: identity,
|
|
758
|
+
statuses: project_cd2_statuses(core['statuses']),
|
|
759
|
+
revocation_channel: channel
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
rescue StandardError
|
|
763
|
+
# Extraction failure yields absence of a summary, never diagnostics.
|
|
764
|
+
{ present: true, summary: nil }
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
# WC-3/WC-4: project depositor-supplied statuses onto the CD-2 vocabulary.
|
|
768
|
+
# Only known claim keys with recognized CD-2 status values survive; any
|
|
769
|
+
# other key, non-scalar value, or non-vocabulary word is dropped. This
|
|
770
|
+
# makes "the summary is CD-2 checkability statuses" structural rather than
|
|
771
|
+
# a trust in the depositor's certificate shape, and bounds the rendered
|
|
772
|
+
# table to at most CD2_STATUS_KEYS entries.
|
|
773
|
+
def project_cd2_statuses(raw)
|
|
774
|
+
return {} unless raw.is_a?(Hash)
|
|
775
|
+
|
|
776
|
+
projected = {}
|
|
777
|
+
CD2_STATUS_KEYS.each do |key|
|
|
778
|
+
value = raw[key]
|
|
779
|
+
projected[key] = value if value.is_a?(String) && CD2_STATUS_VALUES.include?(value)
|
|
780
|
+
end
|
|
781
|
+
projected
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
# WC-5 listing identity: per-deposit-instance, transport-safe address.
|
|
785
|
+
# The deposit_nonce (assigned at deposit time) guarantees uniqueness; the
|
|
786
|
+
# other components keep the address stable across a backfill re-derivation.
|
|
787
|
+
def listing_address_for(meta)
|
|
788
|
+
basis = "#{meta[:name]}|#{meta[:depositor_id]}|#{meta[:deposited_at]}|#{meta[:deposit_nonce]}"
|
|
789
|
+
Digest::SHA256.hexdigest(basis)[0, 16]
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
# Launch-time backfill (BL-WC-5): give pre-existing deposits their
|
|
793
|
+
# certificate state and listing address. Runs at extension construction —
|
|
794
|
+
# off the anonymous request path — and persists once. This is a writer
|
|
795
|
+
# under the guard-enrollment discipline at this instance's boundary.
|
|
796
|
+
def backfill_listing_state!
|
|
797
|
+
changed = false
|
|
798
|
+
@deposited_skillsets.each_value do |meta|
|
|
799
|
+
unless meta[:listing_address]
|
|
800
|
+
# Legacy deposits predate the nonce; assign one now so the address is
|
|
801
|
+
# per-deposit-unique, then persist it (deterministic thereafter).
|
|
802
|
+
meta[:deposit_nonce] ||= SecureRandom.hex(16)
|
|
803
|
+
meta[:listing_address] = listing_address_for(meta)
|
|
804
|
+
changed = true
|
|
805
|
+
end
|
|
806
|
+
next if meta.key?(:certificate)
|
|
807
|
+
|
|
808
|
+
meta[:certificate] = backfill_certificate_state(meta)
|
|
809
|
+
changed = true
|
|
810
|
+
end
|
|
811
|
+
save_state if changed
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
def backfill_certificate_state(meta)
|
|
815
|
+
deposit_dir = File.join(@storage_dir, "#{meta[:name]}_#{sanitize_id(meta[:depositor_id])}")
|
|
816
|
+
archive_path = File.join(deposit_dir, 'archive.tar.gz')
|
|
817
|
+
# Presence unknown when the archive cannot be inspected: rendered as the
|
|
818
|
+
# neutral unavailability marker, never as "no provenance claim".
|
|
819
|
+
return { present: nil } unless File.exist?(archive_path)
|
|
820
|
+
|
|
821
|
+
state = nil
|
|
822
|
+
Dir.mktmpdir('kairos_ss_backfill') do |tmpdir|
|
|
823
|
+
extract_tar_gz(File.binread(archive_path), tmpdir)
|
|
824
|
+
extracted_dir = File.join(tmpdir, meta[:name].to_s)
|
|
825
|
+
state = if File.directory?(extracted_dir)
|
|
826
|
+
extract_certificate_state(extracted_dir)
|
|
827
|
+
else
|
|
828
|
+
{ present: nil }
|
|
829
|
+
end
|
|
830
|
+
end
|
|
831
|
+
state
|
|
832
|
+
rescue StandardError
|
|
833
|
+
{ present: nil }
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
# -----------------------------------------------------------------------
|
|
837
|
+
# Anonymous web catalog rendering (WC-1/WC-3/WC-4/WC-5)
|
|
838
|
+
# -----------------------------------------------------------------------
|
|
839
|
+
|
|
840
|
+
def render_public_catalog(env)
|
|
841
|
+
params = parse_query(env)
|
|
842
|
+
search = params['search'].to_s
|
|
843
|
+
limit = [[(params['limit'] || '50').to_i, 1].max, 100].min
|
|
844
|
+
|
|
845
|
+
results = @state_mutex.synchronize { @deposited_skillsets.values.dup }
|
|
846
|
+
|
|
847
|
+
# WC-4: search matches depositor-authored metadata only — never
|
|
848
|
+
# certificate-derived fields, which are excluded from any matching.
|
|
849
|
+
unless search.empty?
|
|
850
|
+
q = search.downcase
|
|
851
|
+
results = results.select do |meta|
|
|
852
|
+
meta[:name].to_s.downcase.include?(q) ||
|
|
853
|
+
meta[:description].to_s.downcase.include?(q) ||
|
|
854
|
+
(meta[:tags] || []).any? { |t| t.to_s.downcase.include?(q) } ||
|
|
855
|
+
(meta[:provides] || []).any? { |p| p.to_s.downcase.include?(q) }
|
|
856
|
+
end
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
total = results.size
|
|
860
|
+
# DEE: unordered random sample, identical for certified and uncertified.
|
|
861
|
+
sampled = results.size > limit ? results.sample(limit) : results.shuffle
|
|
862
|
+
|
|
863
|
+
cards = sampled.map { |meta| listing_card_html(meta) }.join("\n")
|
|
864
|
+
body = <<~HTML
|
|
865
|
+
#{public_page_header('SkillSet Catalog')}
|
|
866
|
+
<p>#{total} SkillSet deposit#{total == 1 ? '' : 's'} on this Place. Unordered random sample — no ranking, no recommendation.</p>
|
|
867
|
+
<form method="get" action="#{h(PUBLIC_PREFIX)}">
|
|
868
|
+
<input type="text" name="search" value="#{h(search)}" placeholder="Search name, description, tags" />
|
|
869
|
+
<button type="submit">Search</button>
|
|
870
|
+
</form>
|
|
871
|
+
#{total.zero? ? '<p>No SkillSet deposits are currently listed.</p>' : cards}
|
|
872
|
+
#{public_page_footer}
|
|
873
|
+
HTML
|
|
874
|
+
html_response(200, body)
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
def render_public_detail(address)
|
|
878
|
+
meta = @state_mutex.synchronize { @deposited_skillsets.values.find { |m| m[:listing_address] == address } }
|
|
879
|
+
unless meta
|
|
880
|
+
return html_response(404, <<~HTML)
|
|
881
|
+
#{public_page_header('Listing not found')}
|
|
882
|
+
<p>No listing exists at this address. Listings are per-deposit: a withdrawn or replaced deposit's address ceases to resolve.</p>
|
|
883
|
+
#{public_page_footer}
|
|
884
|
+
HTML
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
body = <<~HTML
|
|
888
|
+
#{public_page_header(meta[:name].to_s)}
|
|
889
|
+
#{listing_card_html(meta, detail: true)}
|
|
890
|
+
<h2>Acquisition</h2>
|
|
891
|
+
<p>This catalog is read-only. Acquisition happens exclusively on the authenticated agent path:
|
|
892
|
+
connect a KairosChain instance to this Place and use the <code>skillset_acquire</code> tool
|
|
893
|
+
(name: <code>#{h(meta[:name])}</code>, depositor: <code>#{h(meta[:depositor_id])}</code>).
|
|
894
|
+
The Place verified format safety, a tar header scan, and depositor identity — review content before use.</p>
|
|
895
|
+
#{public_page_footer}
|
|
896
|
+
HTML
|
|
897
|
+
html_response(200, body)
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
# One listing, uniform structure (WC-4): every listing carries the same
|
|
901
|
+
# provenance register, populated exclusively from certificate-derived
|
|
902
|
+
# state; depositor-authored text stays in its own register.
|
|
903
|
+
def listing_card_html(meta, detail: false)
|
|
904
|
+
addr = h(meta[:listing_address].to_s)
|
|
905
|
+
title = detail ? h(meta[:name].to_s) : %(<a href="#{h(PUBLIC_PREFIX)}/#{addr}">#{h(meta[:name].to_s)}</a>)
|
|
906
|
+
tags = (meta[:tags] || []).map { |t| h(t.to_s) }.join(', ')
|
|
907
|
+
provides = (meta[:provides] || []).map { |p| h(p.to_s) }.join(', ')
|
|
908
|
+
<<~HTML
|
|
909
|
+
<article class="listing">
|
|
910
|
+
<h3>#{title} <small>v#{h(meta[:version].to_s)}</small></h3>
|
|
911
|
+
<p class="depositor-text">#{h(meta[:description].to_s)}</p>
|
|
912
|
+
<dl>
|
|
913
|
+
<dt>Depositor</dt><dd>#{h(meta[:depositor_id].to_s)}</dd>
|
|
914
|
+
<dt>Deposited at</dt><dd>#{h(meta[:deposited_at].to_s)}</dd>
|
|
915
|
+
#{tags.empty? ? '' : "<dt>Tags</dt><dd>#{tags}</dd>"}
|
|
916
|
+
#{provides.empty? ? '' : "<dt>Provides</dt><dd>#{provides}</dd>"}
|
|
917
|
+
<dt>Content hash</dt><dd><code>#{h(meta[:content_hash].to_s)}</code></dd>
|
|
918
|
+
<dt>Files</dt><dd>#{h(meta[:file_count].to_s)} (#{h(meta[:archive_size_bytes].to_s)} bytes)</dd>
|
|
919
|
+
</dl>
|
|
920
|
+
#{provenance_field_html(meta, detail: detail)}
|
|
921
|
+
</article>
|
|
922
|
+
HTML
|
|
923
|
+
end
|
|
924
|
+
|
|
925
|
+
# The provenance register (WC-4 three truthful states, WC-5 honesty).
|
|
926
|
+
def provenance_field_html(meta, detail: false)
|
|
927
|
+
cert = meta[:certificate]
|
|
928
|
+
state_html =
|
|
929
|
+
if cert.nil? || cert[:present].nil?
|
|
930
|
+
'<p>Provenance state unavailable.</p>'
|
|
931
|
+
elsif cert[:present] && cert[:summary]
|
|
932
|
+
summary_html(cert[:summary], detail: detail)
|
|
933
|
+
elsif cert[:present]
|
|
934
|
+
'<p>Provenance claim present — summary unavailable.</p>'
|
|
935
|
+
else
|
|
936
|
+
'<p>No provenance claim.</p>'
|
|
937
|
+
end
|
|
938
|
+
<<~HTML
|
|
939
|
+
<section class="provenance" aria-label="Provenance (certificate-derived)">
|
|
940
|
+
<h4>Provenance</h4>
|
|
941
|
+
#{state_html}
|
|
942
|
+
</section>
|
|
943
|
+
HTML
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
def summary_html(summary, detail: false)
|
|
947
|
+
identity = h(summary[:certificate_identity].to_s)
|
|
948
|
+
unless detail
|
|
949
|
+
return "<p>Provenance certificate <code>#{identity}</code> (deposit-time claims; see detail view).</p>"
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
statuses = summary[:statuses] || {}
|
|
953
|
+
rows = statuses.map do |claim, status|
|
|
954
|
+
"<tr><td>#{h(claim.to_s)}</td><td>#{h(status.to_s)}</td></tr>"
|
|
955
|
+
end.join
|
|
956
|
+
channel = summary[:revocation_channel]
|
|
957
|
+
<<~HTML
|
|
958
|
+
<p>Provenance certificate <code>#{identity}</code>.</p>
|
|
959
|
+
#{rows.empty? ? '' : "<table><thead><tr><th>Claim</th><th>Checkability</th></tr></thead><tbody>#{rows}</tbody></table>"}
|
|
960
|
+
#{channel ? "<p>Revocation channel: <code>#{h(channel.to_s)}</code></p>" : ''}
|
|
961
|
+
<p class="limits">Everything in this summary, including checkability status, reflects deposit-judgment
|
|
962
|
+
state and is silent about everything after. Revocation checking is carrier-side and out of this
|
|
963
|
+
catalog's scope. Provenance describes origin, not quality.</p>
|
|
964
|
+
HTML
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
def public_page_header(title)
|
|
968
|
+
<<~HTML
|
|
969
|
+
<!DOCTYPE html>
|
|
970
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
971
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
972
|
+
<title>#{h(title)} — Meeting Place</title>
|
|
973
|
+
<style>
|
|
974
|
+
body { font-family: system-ui, sans-serif; max-width: 46rem; margin: 2rem auto; padding: 0 1rem; }
|
|
975
|
+
article.listing { border: 1px solid #ccc; border-radius: 6px; padding: 0.8rem 1rem; margin: 1rem 0; }
|
|
976
|
+
section.provenance { border-top: 1px dashed #999; margin-top: 0.6rem; padding-top: 0.4rem; }
|
|
977
|
+
section.provenance h4 { margin: 0 0 0.3rem; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
978
|
+
p.limits { font-size: 0.85rem; color: #555; }
|
|
979
|
+
dl { display: grid; grid-template-columns: max-content 1fr; gap: 0.1rem 0.8rem; }
|
|
980
|
+
dt { font-weight: 600; } dd { margin: 0; overflow-wrap: anywhere; }
|
|
981
|
+
table { border-collapse: collapse; } td, th { border: 1px solid #ccc; padding: 0.2rem 0.5rem; }
|
|
982
|
+
</style></head><body>
|
|
983
|
+
<h1>#{h(title)}</h1>
|
|
984
|
+
HTML
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
def public_page_footer
|
|
988
|
+
<<~HTML
|
|
989
|
+
<hr><p class="limits">Anonymous read-only view of SkillSet deposits. Listings are shown without
|
|
990
|
+
ranking (DEE). Certificate presence is an origin claim, not a quality signal.</p>
|
|
991
|
+
</body></html>
|
|
992
|
+
HTML
|
|
993
|
+
end
|
|
994
|
+
|
|
995
|
+
def html_response(status, body)
|
|
996
|
+
[status, HTML_HEADERS.dup, [body]]
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
def h(value)
|
|
1000
|
+
CGI.escapeHTML(value.to_s)
|
|
1001
|
+
end
|
|
1002
|
+
|
|
632
1003
|
# -----------------------------------------------------------------------
|
|
633
1004
|
# Configuration
|
|
634
1005
|
# -----------------------------------------------------------------------
|
|
@@ -693,8 +1064,13 @@ module SkillsetExchange
|
|
|
693
1064
|
|
|
694
1065
|
def save_state
|
|
695
1066
|
FileUtils.mkdir_p(File.dirname(state_path))
|
|
1067
|
+
# Snapshot the hash under the lock, then serialize the snapshot outside
|
|
1068
|
+
# it, so a concurrent writer's guarded mutation cannot land mid-iteration
|
|
1069
|
+
# of JSON serialization (avoids "modified during iteration" and torn
|
|
1070
|
+
# on-disk state under multi-thread deposit/withdraw).
|
|
1071
|
+
snapshot = @state_mutex.synchronize { @deposited_skillsets.dup }
|
|
696
1072
|
data = {
|
|
697
|
-
deposited_skillsets:
|
|
1073
|
+
deposited_skillsets: snapshot,
|
|
698
1074
|
updated_at: Time.now.utc.iso8601
|
|
699
1075
|
}
|
|
700
1076
|
temp = "#{state_path}.tmp"
|