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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e6bc15d467e1fbef211be82d39ec89bfdc760d7bdd67286635967c95c2acd2ce
4
- data.tar.gz: b761078e7adc2798988a2fb0154bbbf998b37eed14d9979e87e7656194f03b76
3
+ metadata.gz: 31e74313d89bf76d795f74dd21f641fd702a1c186cec186f7425b8af53d2e012
4
+ data.tar.gz: 0f325ce8ac8876685f7aa266fabf539e228e8fe5b57cfbfc5b6a395d82a8290c
5
5
  SHA512:
6
- metadata.gz: 604f315facca09bdb89e8abe403cf2873abe6a201cf9771181f04a813c323685da0749237e0f3f547379f9ce1dc7a8976b3e12f657c4718604ef694368f15f27
7
- data.tar.gz: 67b55d37e2ba35259b1df7e42c96222cf637a9b295eac63b877b715f060310ff19841c2152de9fad1a92345a8f40738803b47a68b020e74ad9405c923a17de6b
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
- router.register_extension(
378
- ext_class.new(router),
379
- route_action_map: ext_def['route_actions'] || {}
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
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.52.1"
2
+ VERSION = "3.53.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -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
- def register_extension(extension, route_action_map: {})
91
- return if @extensions.any? { |e| e.class == extension.class }
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
- deposited_at: Time.now.utc.iso8601
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
- @deposited_skillsets[deposit_key] = metadata
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: @deposited_skillsets,
1073
+ deposited_skillsets: snapshot,
698
1074
  updated_at: Time.now.utc.iso8601
699
1075
  }
700
1076
  temp = "#{state_path}.tmp"
@@ -26,7 +26,8 @@
26
26
  "skillset_browse": "browse",
27
27
  "skillset_content": "browse",
28
28
  "skillset_withdraw": "deposit_skill"
29
- }
29
+ },
30
+ "public_routes": ["/place/web/skillsets"]
30
31
  }
31
32
  ],
32
33
  "plugin": {
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kairos-chain
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.52.1
4
+ version: 3.53.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Masaomi Hatakeyama