capsium 0.2.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +17 -1
  3. data/CHANGELOG.md +322 -0
  4. data/README.adoc +643 -7
  5. data/capsium.gemspec +3 -0
  6. data/lib/capsium/cli/formatting.rb +27 -0
  7. data/lib/capsium/cli/package.rb +106 -7
  8. data/lib/capsium/cli/reactor.rb +120 -6
  9. data/lib/capsium/cli.rb +30 -0
  10. data/lib/capsium/log_buffer.rb +47 -0
  11. data/lib/capsium/package/authentication.rb +38 -0
  12. data/lib/capsium/package/authentication_config.rb +78 -0
  13. data/lib/capsium/package/bundle.rb +136 -0
  14. data/lib/capsium/package/cipher.rb +253 -0
  15. data/lib/capsium/package/composition.rb +93 -0
  16. data/lib/capsium/package/dataset.rb +20 -0
  17. data/lib/capsium/package/dependency_resolver.rb +91 -0
  18. data/lib/capsium/package/encryption_config.rb +42 -0
  19. data/lib/capsium/package/manifest.rb +1 -1
  20. data/lib/capsium/package/merged_view.rb +170 -0
  21. data/lib/capsium/package/metadata.rb +1 -1
  22. data/lib/capsium/package/metadata_config.rb +9 -1
  23. data/lib/capsium/package/open_pgp.rb +65 -0
  24. data/lib/capsium/package/open_pgp_cipher.rb +66 -0
  25. data/lib/capsium/package/open_pgp_signer.rb +176 -0
  26. data/lib/capsium/package/preparation.rb +81 -0
  27. data/lib/capsium/package/routes_config.rb +60 -6
  28. data/lib/capsium/package/security.rb +21 -4
  29. data/lib/capsium/package/security_config.rb +6 -2
  30. data/lib/capsium/package/signer.rb +216 -0
  31. data/lib/capsium/package/storage_config.rb +29 -1
  32. data/lib/capsium/package/store.rb +136 -0
  33. data/lib/capsium/package/testing/config_test.rb +70 -0
  34. data/lib/capsium/package/testing/context.rb +11 -0
  35. data/lib/capsium/package/testing/data_validation_test.rb +63 -0
  36. data/lib/capsium/package/testing/file_test.rb +45 -0
  37. data/lib/capsium/package/testing/report.rb +33 -0
  38. data/lib/capsium/package/testing/route_test.rb +64 -0
  39. data/lib/capsium/package/testing/test_case.rb +60 -0
  40. data/lib/capsium/package/testing/test_suite.rb +86 -0
  41. data/lib/capsium/package/testing.rb +20 -0
  42. data/lib/capsium/package/validator.rb +39 -3
  43. data/lib/capsium/package/verification.rb +34 -0
  44. data/lib/capsium/package/version.rb +204 -0
  45. data/lib/capsium/package.rb +78 -75
  46. data/lib/capsium/packager.rb +97 -22
  47. data/lib/capsium/reactor/authenticator.rb +180 -0
  48. data/lib/capsium/reactor/content_api.rb +110 -0
  49. data/lib/capsium/reactor/data_api.rb +154 -0
  50. data/lib/capsium/reactor/deploy.rb +71 -0
  51. data/lib/capsium/reactor/endpoints.rb +37 -0
  52. data/lib/capsium/reactor/graphql_api.rb +123 -0
  53. data/lib/capsium/reactor/graphql_schema.rb +138 -0
  54. data/lib/capsium/reactor/htpasswd.rb +154 -0
  55. data/lib/capsium/reactor/introspection.rb +151 -31
  56. data/lib/capsium/reactor/metrics.rb +30 -0
  57. data/lib/capsium/reactor/mount.rb +206 -0
  58. data/lib/capsium/reactor/oauth2.rb +106 -0
  59. data/lib/capsium/reactor/overlay.rb +248 -0
  60. data/lib/capsium/reactor/package_saver.rb +99 -0
  61. data/lib/capsium/reactor/responses.rb +41 -0
  62. data/lib/capsium/reactor/serving.rb +154 -0
  63. data/lib/capsium/reactor/session.rb +82 -0
  64. data/lib/capsium/reactor.rb +116 -67
  65. data/lib/capsium/registry/local.rb +120 -0
  66. data/lib/capsium/registry/remote.rb +105 -0
  67. data/lib/capsium/registry.rb +190 -0
  68. data/lib/capsium/thor_ext.rb +1 -1
  69. data/lib/capsium/version.rb +1 -1
  70. data/lib/capsium.rb +2 -0
  71. data/sig/capsium/log_buffer.rbs +30 -0
  72. data/sig/capsium/package/authentication.rbs +23 -0
  73. data/sig/capsium/package/authentication_config.rbs +71 -0
  74. data/sig/capsium/package/bundle.rbs +43 -0
  75. data/sig/capsium/package/cipher.rbs +62 -0
  76. data/sig/capsium/package/composition.rbs +21 -0
  77. data/sig/capsium/package/dataset.rbs +8 -0
  78. data/sig/capsium/package/dependency_resolver.rbs +53 -0
  79. data/sig/capsium/package/encryption_config.rbs +39 -0
  80. data/sig/capsium/package/merged_view.rbs +47 -0
  81. data/sig/capsium/package/metadata.rbs +1 -0
  82. data/sig/capsium/package/open_pgp.rbs +38 -0
  83. data/sig/capsium/package/open_pgp_cipher.rbs +15 -0
  84. data/sig/capsium/package/open_pgp_signer.rbs +46 -0
  85. data/sig/capsium/package/preparation.rbs +23 -0
  86. data/sig/capsium/package/routes_config.rbs +36 -3
  87. data/sig/capsium/package/security.rbs +9 -2
  88. data/sig/capsium/package/security_config.rbs +6 -3
  89. data/sig/capsium/package/signer.rbs +66 -0
  90. data/sig/capsium/package/storage_config.rbs +26 -2
  91. data/sig/capsium/package/store.rbs +43 -0
  92. data/sig/capsium/package/testing/config_test.rbs +21 -0
  93. data/sig/capsium/package/testing/context.rbs +14 -0
  94. data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
  95. data/sig/capsium/package/testing/file_test.rbs +17 -0
  96. data/sig/capsium/package/testing/report.rbs +20 -0
  97. data/sig/capsium/package/testing/route_test.rbs +22 -0
  98. data/sig/capsium/package/testing/test_case.rbs +41 -0
  99. data/sig/capsium/package/testing/test_suite.rbs +20 -0
  100. data/sig/capsium/package/testing.rbs +7 -0
  101. data/sig/capsium/package/verification.rbs +26 -0
  102. data/sig/capsium/package/version.rbs +39 -0
  103. data/sig/capsium/package.rbs +29 -16
  104. data/sig/capsium/packager.rbs +9 -0
  105. data/sig/capsium/reactor/authenticator.rbs +40 -0
  106. data/sig/capsium/reactor/content_api.rbs +19 -0
  107. data/sig/capsium/reactor/data_api.rbs +23 -0
  108. data/sig/capsium/reactor/deploy.rbs +34 -0
  109. data/sig/capsium/reactor/endpoints.rbs +16 -0
  110. data/sig/capsium/reactor/graphql_api.rbs +27 -0
  111. data/sig/capsium/reactor/graphql_schema.rbs +20 -0
  112. data/sig/capsium/reactor/htpasswd.rbs +36 -0
  113. data/sig/capsium/reactor/introspection.rbs +27 -7
  114. data/sig/capsium/reactor/metrics.rbs +13 -0
  115. data/sig/capsium/reactor/mount.rbs +83 -0
  116. data/sig/capsium/reactor/oauth2.rbs +31 -0
  117. data/sig/capsium/reactor/overlay.rbs +72 -0
  118. data/sig/capsium/reactor/package_saver.rbs +12 -0
  119. data/sig/capsium/reactor/responses.rbs +23 -0
  120. data/sig/capsium/reactor/serving.rbs +46 -0
  121. data/sig/capsium/reactor/session.rbs +36 -0
  122. data/sig/capsium/reactor.rbs +22 -2
  123. data/sig/capsium/registry/local.rbs +16 -0
  124. data/sig/capsium/registry/remote.rbs +18 -0
  125. data/sig/capsium/registry.rbs +83 -0
  126. data/sig/graphql.rbs +12 -0
  127. metadata +135 -1
data/capsium.gemspec CHANGED
@@ -33,12 +33,15 @@ Gem::Specification.new do |spec|
33
33
 
34
34
  spec.required_ruby_version = ">= 3.2.0"
35
35
 
36
+ spec.add_dependency "bcrypt"
36
37
  spec.add_dependency "csv"
38
+ spec.add_dependency "graphql"
37
39
  spec.add_dependency "json"
38
40
  spec.add_dependency "json-schema"
39
41
  spec.add_dependency "listen"
40
42
  spec.add_dependency "lutaml-model", ">= 0.8", "< 1.0"
41
43
  spec.add_dependency "marcel"
44
+ spec.add_dependency "rnp", ">= 1.0"
42
45
  spec.add_dependency "rubyzip", ">= 2.3.2", "< 4.0"
43
46
  spec.add_dependency "sqlite3"
44
47
  spec.add_dependency "thor"
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capsium
4
+ class Cli < Thor
5
+ # Output formatting helpers for CLI subcommands.
6
+ module Formatting
7
+ private
8
+
9
+ def format_result(result)
10
+ line = "#{result.ok? ? 'PASS' : 'FAIL'} #{result.name}"
11
+ return line if result.messages.empty?
12
+
13
+ "#{line}: #{result.messages.join('; ')}"
14
+ end
15
+
16
+ # The resolved dependency tree (ARCHITECTURE.md section 4a): one
17
+ # line per dependency showing the declared GUID and range, the
18
+ # resolved version and the store .cap it resolved to, nested.
19
+ def print_dependency_tree(package, indent = "")
20
+ package.resolved_dependencies.each do |dep|
21
+ puts "#{indent}- #{dep.guid} (#{dep.range}) => #{dep.version} [#{dep.path}]"
22
+ print_dependency_tree(dep.package, "#{indent} ")
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -6,14 +6,17 @@ module Capsium
6
6
  class Cli
7
7
  class Package < Thor
8
8
  extend ThorExt::Start
9
+ include Formatting
9
10
 
10
11
  desc "info PACKAGE_PATH", "Display information about the package"
12
+ option :store, type: :string, desc: "Package store directory (default: CAPSIUM_STORE)"
11
13
 
12
14
  def info(path_to_package)
13
- package = Capsium::Package.new(path_to_package)
15
+ package = Capsium::Package.new(path_to_package, store: options[:store])
14
16
  puts "Package Path: #{package.path}"
15
17
  puts "Routes: #{package.routes.to_hash}"
16
18
  puts "Manifest: #{package.manifest.to_hash}"
19
+ print_dependency_tree(package)
17
20
  end
18
21
 
19
22
  desc "manifest PATH_TO_PACKAGE",
@@ -47,9 +50,14 @@ module Capsium
47
50
 
48
51
  desc "pack PATH_TO_PACKAGE_FOLDER", "Package the files into the package"
49
52
  option :force, type: :boolean, default: false, aliases: "-f"
53
+ option :bundle_deps, type: :boolean, default: false, aliases: "--bundle",
54
+ desc: "Embed the resolved dependencies under packages/ " \
55
+ "so the .cap activates with no store or registry"
56
+ option :store, type: :string, desc: "Package store directory (default: CAPSIUM_STORE)"
57
+ option :registry, type: :string, desc: "Registry reference (default: CAPSIUM_REGISTRY)"
50
58
 
51
59
  def pack(path_to_package)
52
- package = Capsium::Package.new(path_to_package)
60
+ package = Capsium::Package.new(path_to_package, store: options[:store])
53
61
  packager = Capsium::Packager.new
54
62
  packager.pack(package, options)
55
63
  end
@@ -63,6 +71,18 @@ module Capsium
63
71
  puts "Package unpacked to: #{destination}"
64
72
  end
65
73
 
74
+ desc "push PACKAGE_FILE", "Push a .cap into a static registry directory"
75
+ option :registry, type: :string,
76
+ desc: "Registry directory (default: CAPSIUM_REGISTRY)"
77
+
78
+ def push(path_to_package)
79
+ registry = Capsium::Registry.fetch(options[:registry] || ENV.fetch("CAPSIUM_REGISTRY", nil))
80
+ entry = registry.push(path_to_package)
81
+ puts "Pushed #{entry.name} #{entry.version} to #{registry.location}"
82
+ rescue Capsium::Registry::RegistryError => e
83
+ raise Thor::Error, e.message
84
+ end
85
+
66
86
  desc "validate PACKAGE_PATH", "Validate a package directory or .cap file"
67
87
 
68
88
  def validate(path_to_package)
@@ -73,13 +93,92 @@ module Capsium
73
93
  raise Thor::Error, "Package validation failed"
74
94
  end
75
95
 
76
- private
96
+ desc "sign PACKAGE_PATH --key KEY [--cert CERT.pem] [--openpgp]",
97
+ "Sign the package (RSA-SHA256/X.509, or OpenPGP) into security.json"
98
+ option :key, type: :string, required: true,
99
+ desc: "RSA private key PEM (min 2048 bits); OpenPGP secret key with --openpgp"
100
+ option :cert, type: :string, desc: "X.509 certificate PEM (must match the key)"
101
+ option :openpgp, type: :boolean, default: false, desc: "OpenPGP detached signature"
102
+
103
+ def sign(path_to_package)
104
+ if options[:openpgp] && options[:cert]
105
+ raise Thor::Error, "--cert is only used with X.509 signing"
106
+ end
107
+
108
+ if options[:openpgp]
109
+ Capsium::Package::OpenPgpSigner.sign_package(path_to_package, options[:key])
110
+ else
111
+ Capsium::Package::Signer.sign_package(path_to_package, options[:key], options[:cert])
112
+ end
113
+ puts "Package signed: #{path_to_package}"
114
+ end
115
+
116
+ desc "verify-signature PACKAGE_PATH [--cert CERT-or-PUB] [--openpgp]",
117
+ "Verify the declared digital signature (auto-detected from security.json)"
118
+ option :cert, type: :string, desc: "certificate or public key (default: embedded)"
119
+ option :openpgp, type: :boolean, default: false, desc: "verify as OpenPGP"
77
120
 
78
- def format_result(result)
79
- line = "#{result.ok? ? 'PASS' : 'FAIL'} #{result.name}"
80
- return line if result.messages.empty?
121
+ def verify_signature(path_to_package)
122
+ verifier = options[:openpgp] ? Capsium::Package::OpenPgpSigner : Capsium::Package::Signer
123
+ unless verifier.verify_package(path_to_package, options[:cert])
124
+ raise Thor::Error, "Signature verification failed: #{path_to_package}"
125
+ end
81
126
 
82
- "#{line}: #{result.messages.join('; ')}"
127
+ puts "Signature valid: #{path_to_package}"
128
+ rescue Capsium::Package::Signer::SignatureError => e
129
+ raise Thor::Error, e.message
130
+ end
131
+
132
+ desc "encrypt PACKAGE_PATH -o OUT.cap [--public-key PUB.pem | --openpgp --recipient PUB]",
133
+ "Encrypt a package (AES-256-GCM; RSA or OpenPGP key management)"
134
+ option :public_key, type: :string, desc: "recipient RSA public key or X.509 certificate PEM"
135
+ option :recipient, type: :string, desc: "recipient OpenPGP public key (with --openpgp)"
136
+ option :openpgp, type: :boolean, default: false, desc: "encrypt for an OpenPGP recipient"
137
+ option :output, type: :string, required: true, aliases: "-o",
138
+ desc: "Output path for the encrypted .cap"
139
+
140
+ def encrypt(path_to_package)
141
+ key = options[:public_key] || options[:recipient]
142
+ raise Thor::Error, "encrypt requires --public-key or --openpgp --recipient" if key.nil?
143
+
144
+ cipher = options[:openpgp] ? Capsium::Package::OpenPgpCipher.new : Capsium::Package::Cipher.new
145
+ cipher.encrypt(path_to_package, key, options[:output])
146
+ puts "Package encrypted: #{options[:output]}"
147
+ end
148
+
149
+ desc "decrypt PACKAGE_PATH [--private-key PRIV.pem | --openpgp --key SEC.asc] [-o OUT.cap]",
150
+ "Decrypt an encrypted package (key management auto-detected from the envelope)"
151
+ option :private_key, type: :string, desc: "recipient RSA private key PEM"
152
+ option :key, type: :string, desc: "recipient OpenPGP secret key"
153
+ option :openpgp, type: :boolean, default: false, desc: "decrypt with an OpenPGP key"
154
+ option :output, type: :string, aliases: "-o",
155
+ desc: "Output path (default: <name>-decrypted.cap)"
156
+
157
+ def decrypt(path_to_package)
158
+ key = options[:private_key] || options[:key]
159
+ raise Thor::Error, "decrypt requires --private-key (RSA) or --key (OpenPGP)" if key.nil?
160
+
161
+ output = options[:output] || "#{File.basename(path_to_package, '.cap')}-decrypted.cap"
162
+ cipher = if options[:openpgp]
163
+ Capsium::Package::OpenPgpCipher.new
164
+ else
165
+ Capsium::Package::Cipher.for_encrypted(path_to_package)
166
+ end
167
+ cipher.decrypt(path_to_package, key, output)
168
+ puts "Package decrypted: #{output}"
169
+ end
170
+
171
+ desc "test PACKAGE_PATH", "Run the package's tests/*.yaml suite (05x-testing DSL)"
172
+
173
+ def test(path_to_package)
174
+ package = Capsium::Package.new(path_to_package)
175
+ report = Capsium::Package::Testing::TestSuite.new(package).run
176
+ report.results.each { |result| puts format_result(result) }
177
+ if report.results.empty?
178
+ puts "No tests found (#{Capsium::Package::Testing::TestSuite::TESTS_DIR}/*.yaml)"
179
+ end
180
+ puts report.summary
181
+ raise Thor::Error, "Package tests failed" unless report.ok?
83
182
  end
84
183
  end
85
184
  end
@@ -5,20 +5,134 @@ module Capsium
5
5
  class Reactor < Thor
6
6
  extend ThorExt::Start
7
7
 
8
- desc "serve PACKAGE_PATH",
9
- "Start the Capsium reactor to serve the package"
8
+ desc "serve PACKAGE_PATH ...",
9
+ "Start the Capsium reactor to serve one or more packages " \
10
+ "(local packages, or capsium:// GUIDs installed from a registry)"
10
11
  option :port, type: :numeric, default: Capsium::Reactor::DEFAULT_PORT
11
12
  option :do_not_listen, type: :boolean, default: false
13
+ option :store, type: :string,
14
+ desc: "Package store directory for dependency " \
15
+ "resolution (default: CAPSIUM_STORE)"
16
+ option :deploy, type: :string,
17
+ desc: "deploy.json with reactor-side secrets " \
18
+ "(default: CAPSIUM_DEPLOY)"
19
+ option :registry, type: :string,
20
+ desc: "Registry directory or https base URL for " \
21
+ "capsium:// GUIDs and dependency fallback " \
22
+ "(default: CAPSIUM_REGISTRY)"
23
+ option :constraint, type: :string, default: "*",
24
+ desc: "Semver constraint for a capsium:// GUID"
25
+ option :mount, type: :array,
26
+ desc: "Mount a source at a URL prefix, PATH=SOURCE " \
27
+ "(repeatable; sources without a prefix default " \
28
+ "to / for the first and /<name>/ for the rest)"
29
+ option :config, type: :string,
30
+ desc: "JSON mount config: " \
31
+ '{"mounts": [{"path": "/", "source": "...", "store": "..."}]}'
32
+ option :workdir, type: :string,
33
+ desc: "Reactor work directory for writable overlays " \
34
+ "and saved packages (default: a temporary directory)"
12
35
 
13
- def serve(path_to_package)
36
+ # Thor array options are last-wins when the flag repeats; collect
37
+ # every --mount value (both "--mount V" and "--mount=V" forms)
38
+ # and re-emit one trailing occurrence so repetition accumulates.
39
+ # (Subcommands are dispatched in-process, so this hooks dispatch,
40
+ # not start; merging is idempotent for the direct-start flow.)
41
+ def self.dispatch(meth, given_args, given_opts, config)
42
+ super(meth, merge_mount_options(given_args),
43
+ given_opts && merge_mount_options(given_opts), config)
44
+ end
45
+
46
+ def self.merge_mount_options(args)
47
+ values = []
48
+ rest = []
49
+ index = 0
50
+ while index < args.length
51
+ arg = args[index]
52
+ if arg == "--mount"
53
+ index += 1
54
+ while index < args.length && !args[index].start_with?("-")
55
+ values << args[index]
56
+ index += 1
57
+ end
58
+ elsif arg.start_with?("--mount=")
59
+ values << arg.split("=", 2).last
60
+ index += 1
61
+ else
62
+ rest << arg
63
+ index += 1
64
+ end
65
+ end
66
+ values.empty? ? args : rest + ["--mount"] + values
67
+ end
68
+
69
+ def serve(*sources)
70
+ entries = mount_entries(sources)
71
+ if entries.empty?
72
+ raise Thor::Error, "no package source given (positional " \
73
+ "arguments, --mount or --config)"
74
+ end
75
+
76
+ mounts = Capsium::Reactor::Mount.build(
77
+ entries, store: options[:store], registry: options[:registry]
78
+ )
14
79
  reactor = Capsium::Reactor.new(
15
- package: path_to_package,
80
+ mounts: mounts,
16
81
  port: options[:port],
17
- do_not_listen: options[:do_not_listen]
82
+ do_not_listen: options[:do_not_listen],
83
+ store: options[:store],
84
+ deploy: options[:deploy],
85
+ registry: options[:registry],
86
+ workdir: options[:workdir]
18
87
  )
19
88
  reactor.serve
89
+ rescue Capsium::Error => e
90
+ raise Thor::Error, e.message
20
91
  ensure
21
- reactor&.package&.cleanup
92
+ reactor&.cleanup
93
+ end
94
+
95
+ private
96
+
97
+ # The combined mount entries from --config, --mount and the
98
+ # positional sources (in that order), with capsium:// GUIDs
99
+ # installed from the registry into the store.
100
+ def mount_entries(sources)
101
+ entries = []
102
+ entries.concat(Capsium::Reactor::Mount.config_entries(options[:config])) if options[:config]
103
+ entries.concat(Array(options[:mount]).map do |spec|
104
+ Capsium::Reactor::Mount.parse_spec(spec)
105
+ end)
106
+ entries.concat(sources.map do |source|
107
+ Capsium::Reactor::Mount::Entry.new(path: nil, source: source, store: nil)
108
+ end)
109
+ entries.map do |entry|
110
+ entry.with(source: resolve_source(entry.source))
111
+ end
112
+ end
113
+
114
+ def resolve_source(source)
115
+ return source unless source.start_with?("capsium://")
116
+
117
+ install_from_registry(source)
118
+ end
119
+
120
+ # Install-then-serve for capsium:// GUIDs: resolve and install
121
+ # from the registry into the package store, returning the
122
+ # installed store .cap path.
123
+ def install_from_registry(guid)
124
+ registry = Capsium::Registry.fetch(options[:registry] || ENV.fetch("CAPSIUM_REGISTRY", nil))
125
+ store = options[:store] || ENV.fetch("CAPSIUM_STORE", nil)
126
+ if store.nil? || store.empty?
127
+ raise Thor::Error,
128
+ "no package store configured (pass --store or set CAPSIUM_STORE)"
129
+ end
130
+
131
+ path = registry.install(guid, options[:constraint], store: store)
132
+ puts "Installed #{guid} to #{path}"
133
+ path
134
+ rescue Capsium::Registry::RegistryError => e
135
+ raise Thor::Error, e.message
22
136
  end
23
137
  end
24
138
  end
data/lib/capsium/cli.rb CHANGED
@@ -7,6 +7,7 @@ module Capsium
7
7
  extend ThorExt::Start
8
8
 
9
9
  autoload :Convert, "capsium/cli/convert"
10
+ autoload :Formatting, "capsium/cli/formatting"
10
11
  autoload :Package, "capsium/cli/package"
11
12
  autoload :Reactor, "capsium/cli/reactor"
12
13
 
@@ -18,5 +19,34 @@ module Capsium
18
19
 
19
20
  desc "convert SUBCOMMAND ...ARGS", "Convert from another format"
20
21
  subcommand "convert", Capsium::Cli::Convert
22
+
23
+ desc "install GUID", "Install a package from a registry into the package store"
24
+ option :constraint, type: :string, default: "*",
25
+ desc: "Semver constraint the installed version must satisfy"
26
+ option :registry, type: :string,
27
+ desc: "Registry directory or https base URL " \
28
+ "(default: CAPSIUM_REGISTRY)"
29
+ option :store, type: :string,
30
+ desc: "Package store directory (default: CAPSIUM_STORE)"
31
+
32
+ def install(guid)
33
+ registry = Capsium::Registry.fetch(options[:registry] || ENV.fetch("CAPSIUM_REGISTRY", nil))
34
+ path = registry.install(guid, options[:constraint], store: store_dir!)
35
+ puts "Installed #{guid} to #{path}"
36
+ rescue Capsium::Registry::RegistryError => e
37
+ raise Thor::Error, e.message
38
+ end
39
+
40
+ private
41
+
42
+ # The store directory for install-like commands: --store or
43
+ # CAPSIUM_STORE, otherwise a typed CLI error.
44
+ def store_dir!
45
+ store = options[:store] || ENV.fetch("CAPSIUM_STORE", nil)
46
+ return store unless store.nil? || store.empty?
47
+
48
+ raise Thor::Error,
49
+ "no package store configured (pass --store or set CAPSIUM_STORE)"
50
+ end
21
51
  end
22
52
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Capsium
6
+ # A small thread-safe ring buffer of timestamped log entries with a
7
+ # fixed capacity: when full, the oldest entry is dropped. The reactor
8
+ # records key serving events here and exposes the most recent lines
9
+ # through the /package/:id/logs introspection endpoint.
10
+ class LogBuffer
11
+ DEFAULT_CAPACITY = 500
12
+
13
+ # One buffer entry: a UTC timestamp and a message.
14
+ Entry = Data.define(:timestamp, :message) do
15
+ # "2026-07-19T15:00:00Z message"
16
+ def line = "#{timestamp.utc.iso8601} #{message}"
17
+ end
18
+
19
+ attr_reader :capacity
20
+
21
+ def initialize(capacity: DEFAULT_CAPACITY)
22
+ raise ArgumentError, "capacity must be at least 1" if capacity < 1
23
+
24
+ @capacity = capacity
25
+ @entries = []
26
+ @mutex = Mutex.new
27
+ end
28
+
29
+ def add(message, timestamp: Time.now)
30
+ @mutex.synchronize do
31
+ @entries.shift if @entries.size >= @capacity
32
+ @entries << Entry.new(timestamp: timestamp, message: message)
33
+ end
34
+ self
35
+ end
36
+
37
+ # The last count entries, oldest first.
38
+ def last(count)
39
+ @mutex.synchronize { @entries.last(count) }
40
+ end
41
+
42
+ # The last count entries as formatted lines, oldest first.
43
+ def lines(count)
44
+ last(count).map(&:line)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Capsium
6
+ class Package
7
+ # Loads a package's authentication.json (ARCHITECTURE.md section 4b).
8
+ class Authentication
9
+ extend Forwardable
10
+
11
+ attr_reader :path, :config
12
+
13
+ def_delegators :@config, :to_json, :to_hash
14
+
15
+ def initialize(path)
16
+ @path = path
17
+ @config = File.exist?(path) ? AuthenticationConfig.from_json(File.read(path)) : nil
18
+ end
19
+
20
+ def present?
21
+ !@config.nil?
22
+ end
23
+
24
+ def basic_auth
25
+ @config&.authentication&.basic_auth
26
+ end
27
+
28
+ def oauth2
29
+ @config&.authentication&.oauth2
30
+ end
31
+
32
+ # Whether any authentication method is enabled.
33
+ def enabled?
34
+ !!(basic_auth&.enabled? || oauth2&.enabled?)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "lutaml/model"
5
+
6
+ module Capsium
7
+ class Package
8
+ # The "basicAuth" object of authentication.json (ARCHITECTURE.md
9
+ # section 4b): Apache-style Basic authentication verified against an
10
+ # htpasswd file. "passwdFile" is a package-relative path (password
11
+ # hashes, not plaintext secrets, so it may ship in the package);
12
+ # deploy.json may override it with a reactor-side file.
13
+ class BasicAuthConfig < Lutaml::Model::Serializable
14
+ attribute :enabled, :boolean, default: false
15
+ attribute :passwd_file, :string
16
+ attribute :realm, :string, default: "capsium"
17
+
18
+ json do
19
+ map :enabled, to: :enabled
20
+ map "passwdFile", to: :passwd_file
21
+ map :realm, to: :realm
22
+ end
23
+
24
+ def enabled?
25
+ !!enabled
26
+ end
27
+ end
28
+
29
+ # The "oauth2" object of authentication.json: an OAuth2
30
+ # authorization-code flow. The client secret is NEVER read from the
31
+ # package — it comes from deploy.json or reactor configuration.
32
+ class OAuth2Config < Lutaml::Model::Serializable
33
+ attribute :enabled, :boolean, default: false
34
+ attribute :provider, :string
35
+ attribute :client_id, :string
36
+ attribute :authorization_url, :string
37
+ attribute :token_url, :string
38
+ attribute :userinfo_url, :string
39
+ attribute :redirect_path, :string, default: "/auth/callback"
40
+ attribute :scopes, :string, collection: true, default: []
41
+
42
+ json do
43
+ map :enabled, to: :enabled
44
+ map :provider, to: :provider
45
+ map "clientId", to: :client_id
46
+ map "authorizationUrl", to: :authorization_url
47
+ map "tokenUrl", to: :token_url
48
+ map "userinfoUrl", to: :userinfo_url
49
+ map "redirectPath", to: :redirect_path
50
+ map :scopes, to: :scopes
51
+ end
52
+
53
+ def enabled?
54
+ !!enabled
55
+ end
56
+ end
57
+
58
+ # The "authentication" object of authentication.json.
59
+ class AuthenticationData < Lutaml::Model::Serializable
60
+ attribute :basic_auth, BasicAuthConfig
61
+ attribute :oauth2, OAuth2Config
62
+
63
+ json do
64
+ map "basicAuth", to: :basic_auth
65
+ map :oauth2, to: :oauth2
66
+ end
67
+ end
68
+
69
+ # Canonical authentication.json model (ARCHITECTURE.md section 4b).
70
+ class AuthenticationConfig < Lutaml::Model::Serializable
71
+ attribute :authentication, AuthenticationData
72
+
73
+ json do
74
+ map :authentication, to: :authentication
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "zip"
7
+
8
+ module Capsium
9
+ class Package
10
+ # Bundled dependencies of an encapsulated (self-contained) package
11
+ # (ARCHITECTURE.md section 4a extension): dependency .cap files
12
+ # embedded under packages/ with a packages/index.json manifest
13
+ # mapping each dependency GUID to its file, version and SHA-256:
14
+ #
15
+ # packages/
16
+ # index.json # guid => {"file", "version", "sha256"}
17
+ # <name>-<version>.cap # one per declared dependency
18
+ #
19
+ # One-level bundling policy: only the dependencies DECLARED in the
20
+ # package's own metadata.dependencies are bundled; to make a whole
21
+ # tree self-contained the parent must declare the transitive
22
+ # closure. At load time bundled dependencies resolve FIRST, before
23
+ # the store -> registry chain, and a package's bundle is passed down
24
+ # to its dependencies so their re-declared dependencies resolve from
25
+ # it too (recursive-capable).
26
+ #
27
+ # Security: bundled .cap files are covered by the parent package's
28
+ # security.json checksums like every other file; the manifest
29
+ # SHA-256 is re-verified when a bundled dependency is resolved, and
30
+ # each bundled package's own security.json is verified when it is
31
+ # loaded (the usual Capsium::Package activation checks).
32
+ class Bundle
33
+ DIRECTORY = "packages"
34
+ INDEX_FILE = "packages/index.json"
35
+
36
+ # One bundled dependency: the absolute .cap path, its version and
37
+ # the SHA-256 recorded in the manifest.
38
+ Entry = Data.define(:file, :version, :sha256)
39
+
40
+ attr_reader :entries
41
+
42
+ def initialize(package_path, entries = nil)
43
+ @entries = entries || load_entries(package_path)
44
+ end
45
+
46
+ # Embeds the resolved dependency .cap files (guid => .cap path
47
+ # pairs, in declaration order) into the package directory at
48
+ # package_path: copies each under packages/ and writes the
49
+ # packages/index.json manifest. Returns the manifest hash.
50
+ def self.write(package_path, resolutions)
51
+ FileUtils.mkdir_p(File.join(package_path, DIRECTORY))
52
+ index = resolutions.to_h do |guid, cap_path|
53
+ file = File.join(DIRECTORY, File.basename(cap_path))
54
+ FileUtils.cp(cap_path, File.join(package_path, file))
55
+ [guid, { "file" => file,
56
+ "version" => cap_version(cap_path),
57
+ "sha256" => Digest::SHA256.file(cap_path).hexdigest }]
58
+ end
59
+ File.write(File.join(package_path, INDEX_FILE), JSON.pretty_generate(index))
60
+ index
61
+ end
62
+
63
+ def self.cap_version(cap_path)
64
+ Zip::File.open(cap_path) do |zip|
65
+ entry = zip.find_entry(METADATA_FILE)
66
+ raise DependencyError, "no #{METADATA_FILE} in #{cap_path}" unless entry
67
+
68
+ JSON.parse(entry.get_input_stream.read)["version"].to_s
69
+ end
70
+ end
71
+ private_class_method :cap_version
72
+
73
+ # Whether this bundle embeds any dependency.
74
+ def present? = !@entries.empty?
75
+
76
+ # Merges an inherited (ancestor) bundle underneath this one; own
77
+ # entries win on GUID conflicts. Entries carry absolute paths, so
78
+ # merged entries keep pointing into their source packages.
79
+ def merged_with(other)
80
+ return self if other.nil? || !other.present?
81
+ return other unless present?
82
+
83
+ self.class.new(nil, other.entries.merge(@entries))
84
+ end
85
+
86
+ # The absolute .cap path for a bundled dependency, or nil when the
87
+ # GUID is not bundled (the caller falls back to the store ->
88
+ # registry chain). Raises CircularDependencyError when the GUID is
89
+ # already being resolved up-chain, UnsatisfiableDependencyError
90
+ # when the bundled version does not satisfy the declared range and
91
+ # Security::IntegrityError when the bundled file fails the
92
+ # manifest SHA-256.
93
+ def resolve_path(guid, range, chain:)
94
+ entry = @entries[guid]
95
+ return nil if entry.nil?
96
+
97
+ if chain.include?(guid)
98
+ raise CircularDependencyError,
99
+ "circular dependency: #{(chain + [guid]).join(' -> ')}"
100
+ end
101
+
102
+ unless VersionRange.parse(range).satisfied_by?(Version.parse(entry.version))
103
+ raise UnsatisfiableDependencyError,
104
+ "bundled #{guid} #{entry.version} does not satisfy '#{range}'"
105
+ end
106
+
107
+ verify_sha256(guid, entry)
108
+ entry.file
109
+ end
110
+
111
+ private
112
+
113
+ def verify_sha256(guid, entry)
114
+ actual = Digest::SHA256.file(entry.file).hexdigest
115
+ return if actual == entry.sha256
116
+
117
+ raise Security::IntegrityError,
118
+ "bundled package failed manifest SHA-256 verification: " \
119
+ "#{guid} (#{entry.file})"
120
+ end
121
+
122
+ def load_entries(package_path)
123
+ index_path = File.join(package_path, INDEX_FILE)
124
+ return {} unless File.file?(index_path)
125
+
126
+ JSON.parse(File.read(index_path)).to_h do |guid, entry|
127
+ [guid, Entry.new(file: File.join(package_path, entry.fetch("file")),
128
+ version: entry.fetch("version"),
129
+ sha256: entry.fetch("sha256"))]
130
+ end
131
+ rescue JSON::ParserError, KeyError => e
132
+ raise DependencyError, "invalid #{INDEX_FILE}: #{e.message}"
133
+ end
134
+ end
135
+ end
136
+ end