capsium 0.3.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +7 -3
  3. data/CHANGELOG.md +197 -0
  4. data/README.adoc +323 -14
  5. data/capsium.gemspec +2 -0
  6. data/lib/capsium/cli/package.rb +62 -25
  7. data/lib/capsium/cli/reactor.rb +112 -6
  8. data/lib/capsium/cli.rb +29 -0
  9. data/lib/capsium/log_buffer.rb +47 -0
  10. data/lib/capsium/package/bundle.rb +136 -0
  11. data/lib/capsium/package/cipher.rb +73 -17
  12. data/lib/capsium/package/composition.rb +28 -12
  13. data/lib/capsium/package/dataset.rb +20 -0
  14. data/lib/capsium/package/dependency_resolver.rb +39 -9
  15. data/lib/capsium/package/encryption_config.rb +7 -3
  16. data/lib/capsium/package/manifest.rb +1 -1
  17. data/lib/capsium/package/merged_view.rb +4 -2
  18. data/lib/capsium/package/metadata.rb +1 -1
  19. data/lib/capsium/package/metadata_config.rb +9 -1
  20. data/lib/capsium/package/open_pgp.rb +65 -0
  21. data/lib/capsium/package/open_pgp_cipher.rb +66 -0
  22. data/lib/capsium/package/open_pgp_signer.rb +176 -0
  23. data/lib/capsium/package/security_config.rb +6 -2
  24. data/lib/capsium/package/signer.rb +18 -3
  25. data/lib/capsium/package/store.rb +27 -0
  26. data/lib/capsium/package/validator.rb +27 -2
  27. data/lib/capsium/package/verification.rb +7 -5
  28. data/lib/capsium/package.rb +24 -5
  29. data/lib/capsium/packager.rb +64 -22
  30. data/lib/capsium/reactor/content_api.rb +110 -0
  31. data/lib/capsium/reactor/data_api.rb +154 -0
  32. data/lib/capsium/reactor/endpoints.rb +37 -0
  33. data/lib/capsium/reactor/graphql_api.rb +123 -0
  34. data/lib/capsium/reactor/graphql_schema.rb +138 -0
  35. data/lib/capsium/reactor/introspection.rb +151 -34
  36. data/lib/capsium/reactor/metrics.rb +30 -0
  37. data/lib/capsium/reactor/mount.rb +206 -0
  38. data/lib/capsium/reactor/overlay.rb +248 -0
  39. data/lib/capsium/reactor/package_saver.rb +99 -0
  40. data/lib/capsium/reactor/responses.rb +41 -0
  41. data/lib/capsium/reactor/serving.rb +90 -11
  42. data/lib/capsium/reactor.rb +96 -49
  43. data/lib/capsium/registry/local.rb +120 -0
  44. data/lib/capsium/registry/remote.rb +105 -0
  45. data/lib/capsium/registry.rb +190 -0
  46. data/lib/capsium/version.rb +1 -1
  47. data/lib/capsium.rb +2 -0
  48. data/sig/capsium/log_buffer.rbs +30 -0
  49. data/sig/capsium/package/bundle.rbs +43 -0
  50. data/sig/capsium/package/cipher.rbs +12 -1
  51. data/sig/capsium/package/composition.rbs +4 -3
  52. data/sig/capsium/package/dataset.rbs +8 -0
  53. data/sig/capsium/package/dependency_resolver.rbs +8 -4
  54. data/sig/capsium/package/encryption_config.rbs +6 -2
  55. data/sig/capsium/package/merged_view.rbs +2 -1
  56. data/sig/capsium/package/metadata.rbs +1 -0
  57. data/sig/capsium/package/open_pgp.rbs +38 -0
  58. data/sig/capsium/package/open_pgp_cipher.rbs +15 -0
  59. data/sig/capsium/package/open_pgp_signer.rbs +46 -0
  60. data/sig/capsium/package/security_config.rbs +6 -3
  61. data/sig/capsium/package/signer.rbs +8 -1
  62. data/sig/capsium/package/store.rbs +6 -0
  63. data/sig/capsium/package/verification.rbs +5 -3
  64. data/sig/capsium/package.rbs +8 -3
  65. data/sig/capsium/reactor/content_api.rbs +19 -0
  66. data/sig/capsium/reactor/data_api.rbs +23 -0
  67. data/sig/capsium/reactor/endpoints.rbs +16 -0
  68. data/sig/capsium/reactor/graphql_api.rbs +27 -0
  69. data/sig/capsium/reactor/graphql_schema.rbs +20 -0
  70. data/sig/capsium/reactor/introspection.rbs +27 -7
  71. data/sig/capsium/reactor/metrics.rbs +13 -0
  72. data/sig/capsium/reactor/mount.rbs +83 -0
  73. data/sig/capsium/reactor/overlay.rbs +72 -0
  74. data/sig/capsium/reactor/package_saver.rbs +12 -0
  75. data/sig/capsium/reactor/responses.rbs +23 -0
  76. data/sig/capsium/reactor/serving.rbs +28 -4
  77. data/sig/capsium/reactor.rbs +17 -2
  78. data/sig/capsium/registry/local.rbs +16 -0
  79. data/sig/capsium/registry/remote.rbs +18 -0
  80. data/sig/capsium/registry.rbs +83 -0
  81. data/sig/graphql.rbs +12 -0
  82. metadata +66 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 188f04acf4c1f13b7a4515798e1183f59473ffba9200d375ea31b7ec2ceb705f
4
- data.tar.gz: '068cb0fbbfa415b6f07bb299e233c2f27f5161fe82b932c1379883fcba79a36f'
3
+ metadata.gz: 4d5f0f655cb66b0e6b49edcb70ad68b8e6670e0ca2cb0d5a77aef84c609a3c6b
4
+ data.tar.gz: 3f4f17221db2c72db65c14faa2b5e3a31cf026e5c5b1e0ffc331af872b8b0111
5
5
  SHA512:
6
- metadata.gz: ed6d82167bc38448608ced953d1c906980e2fbbbc1297e1c79debe3aaf67508a9aa14237741c42be113b4daffe4e8c8d3e7a8c710f899acebeece1ce9b4dfde8
7
- data.tar.gz: c1294b0ec91effddc6cf7da04cb4c128db2a31f2f82625b2a12b92c303be6bcfc566f4bf4a8c6658a187f623e8ba433cab24d275a0d880b82c3717775aec3aa5
6
+ metadata.gz: 4e3b9777dc6f0c498784959ab66cb89228657b6f8e85a43818c804c311371d86f78d9ffd3cf7f61e1ad743db08608fafdbe35d49d3936073f8d8281d66cd9816
7
+ data.tar.gz: 7cfb11cce09e70b03f94057c68adea6389022f50aa5b9573311f0cf0a8d606013bf60115e4f11bfb45a07460fd3dd5f22e93b34ef5dcb84cb052bc25c93cc6bb
data/.rubocop.yml CHANGED
@@ -32,14 +32,18 @@ Metrics/ModuleLength:
32
32
  - 'spec/**/*'
33
33
 
34
34
  # Spec helpers take the parameters the fixtures need; the reactor
35
- # constructor takes one keyword per deploy-time concern.
35
+ # constructor takes one keyword per deploy-time concern (keyword args
36
+ # are self-documenting and not counted).
36
37
  Metrics/ParameterLists:
37
- Max: 6
38
+ Max: 7
39
+ CountKeywordArgs: false
38
40
  Exclude:
39
41
  - 'spec/**/*'
40
42
 
41
43
  Metrics/ClassLength:
42
- Max: 120
44
+ # The reactor (request dispatch + lifecycle) and the introspection
45
+ # endpoint set grew with the phase-3 follow-ons.
46
+ Max: 150
43
47
 
44
48
  Metrics/MethodLength:
45
49
  Max: 25
data/CHANGELOG.md CHANGED
@@ -1,5 +1,202 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Added
6
+
7
+ - OpenPGP signatures and encryption through librnp (new `rnp` gem
8
+ dependency, loaded lazily — a typed
9
+ `Capsium::Package::OpenPgp::OpenPgpUnavailableError` with
10
+ installation guidance is raised when the gem or librnp is missing):
11
+ - `Capsium::Package::OpenPgpSigner` (parallel to the RSA-SHA256/X.509
12
+ `Signer`, same construction semantics and `Signer::SignatureError`
13
+ taxonomy): `capsium package sign PATH --openpgp --key SEC.asc`
14
+ writes an armored detached OpenPGP signature (SHA-256) over the
15
+ canonical section-6a payload to `signature.sig`, embeds the armored
16
+ public key as `signature.pub.asc` and records
17
+ `"digitalSignatures": {"certificateType": "OpenPGP", ...}` in
18
+ security.json. Verification auto-detects the scheme from
19
+ `certificateType` (`Signer.verify_package`,
20
+ `Package#verify_signature`, the load-time signature gate and
21
+ `capsium package verify-signature` all dispatch); `--openpgp
22
+ --cert PUB.asc` verifies with an explicit OpenPGP public key.
23
+ - `Capsium::Package::OpenPgpCipher < Cipher` (same encrypted .cap
24
+ layout and AES-256-GCM content encryption): `capsium package
25
+ encrypt PATH --openpgp --recipient PUB.asc -o OUT.cap` protects the
26
+ DEK as an armored OpenPGP message
27
+ (`signature.json`: `{"encryption": {"algorithm": "AES-256-GCM",
28
+ "keyManagement": "OpenPGP", "message", "iv", "authTag"}}`).
29
+ Decryption auto-detects the key management from the envelope
30
+ (`Cipher.for_encrypted`, `Cipher.key_management`), and
31
+ `Package.new(decryption_key:)` accepts OpenPGP secret keys
32
+ (armored or binary) transparently. `Cipher`'s envelope/DEK handling
33
+ was refactored into subclass seams; the RSA-OAEP-SHA256 path is
34
+ unchanged.
35
+ - `Capsium::Package::OpenPgp` is the single lazy loader of the rnp
36
+ binding and the key-file loader (armor/format auto-detection,
37
+ `OpenPgp::KeyError` for unreadable or unsuitable keys).
38
+ - The OpenPGP specs skip cleanly when librnp cannot load, so CI
39
+ without librnp stays green; see README "OpenPGP support (librnp)".
40
+ - Encapsulated packages (bundled dependencies): `capsium package pack
41
+ --bundle-deps` (alias `--bundle`; `Packager#pack` option
42
+ `bundle_deps: true`) resolves every declared `metadata.dependencies`
43
+ entry through the store -> registry chain and embeds the resolved
44
+ `.cap` files inside the parent under `packages/`, producing a fully
45
+ self-contained package that activates with no store or registry. The
46
+ `packages/index.json` manifest maps each dependency GUID to its
47
+ embedded file, resolved version and SHA-256
48
+ (`Capsium::Package::Bundle`). Loading resolves bundled dependencies
49
+ FIRST — including from inside a parent `.cap` — before the store ->
50
+ registry chain, and passes the bundle down so re-declared transitive
51
+ dependencies resolve from it (one-level bundling policy: the parent's
52
+ `metadata.dependencies` must list the transitive closure). The
53
+ manifest SHA-256 is re-verified at resolution
54
+ (`Security::IntegrityError` on mismatch) and each bundled package's
55
+ own `security.json`/signature is verified at activation. `pack`
56
+ gained `--store`/`--registry` options; `capsium package validate`
57
+ resolves bundled dependency layers, so fallthrough routes of
58
+ encapsulated packages validate.
59
+
60
+ ## 0.5.0
61
+
62
+ ### Added
63
+
64
+ - Multiple mounted packages per reactor: `capsium reactor serve`
65
+ accepts multiple sources — positional arguments, repeatable
66
+ `--mount PATH=SOURCE` options and/or a JSON mount config
67
+ (`--config FILE`: `{"mounts": [{"path", "source", "store"?}]}`).
68
+ Default mount points: the first source at `/`, each additional one at
69
+ `/<metadata.name>/`; duplicate prefixes raise
70
+ `Capsium::Reactor::MountConflictError`. Requests dispatch by
71
+ longest-prefix mount matching (`Capsium::Reactor::Mount`); the
72
+ `/api/v1/introspect/*` endpoints aggregate ALL mounted packages,
73
+ `/package/<name>/{status,metadata,logs}` resolve by package name
74
+ (404 for unknown names), metrics/logs stay reactor-global, and
75
+ `Capsium::Reactor#cleanup` cleans up every mounted package.
76
+ Single-source invocation is unchanged.
77
+ - Writable packages (ARCHITECTURE.md section 5a): a mounted package
78
+ whose metadata does not declare `"readOnly": true` gets an
79
+ append-only overlay layer in the reactor workdir (`--workdir DIR`,
80
+ default a temporary directory removed on cleanup), always the
81
+ topmost layer of the merged view (`MergedView` gained
82
+ `extra_layers`). The immutable base never changes on disk and every
83
+ write is visible on the next request (hot-swap); overlay state
84
+ (content files, `.capsium-tombstones`, per-dataset JSON operation
85
+ logs) persists in the workdir.
86
+ - REST CRUD over datasets (`Capsium::Reactor::DataApi`):
87
+ `POST /api/v1/data/<dataset>` appends an item (201 + Location +
88
+ stored item; id convention is the `id` field else the 1-based index
89
+ as a string), `GET /<id>` reads one item, `PUT /<id>` replaces and
90
+ `DELETE /<id>` deletes. Statuses: 400 for malformed JSON, 403 with a
91
+ clear body on read-only packages, 404 for unknown datasets/items,
92
+ 405 for wrong verbs, 409 for duplicate/mismatched ids, 422 with the
93
+ schema errors when the candidate document violates the dataset's
94
+ JSON schema, 501 for SQLite datasets. Undeclared datasets are not
95
+ served and route-level `accessControl` still applies.
96
+ - Content writes (`Capsium::Reactor::ContentApi`): `PUT <route>`
97
+ creates/overwrites a content file (creating its route on demand;
98
+ text bodies only for v1) and `DELETE <route>` records a tombstone so
99
+ the path 404s even when a lower layer holds the file (404 when
100
+ nothing to delete).
101
+ - GraphQL (new `graphql` dependency): every mounted package with
102
+ datasets answers `<mount>/graphql` (POST, or GET with `?query=`)
103
+ with a schema auto-derived from the package's storage
104
+ (`Capsium::Reactor::GraphqlSchema`): a query field `<dataset>`
105
+ (list) with an optional `id:` argument (single item) plus
106
+ `create<Dataset>`/`update<Dataset>`/`delete<Dataset>` mutations
107
+ matching the REST semantics including schema validation. Item types
108
+ are inferred from the dataset's JSON schema when present, else map
109
+ to a permissive JSON scalar; SQLite datasets are skipped. Not-found
110
+ items, schema violations, duplicates and read-only mutations land in
111
+ the `errors` array (no 500s on user error).
112
+ - Save composite: `POST /package/<name>/save`
113
+ (`Capsium::Reactor::PackageSaver`) folds the base package plus its
114
+ overlay into a NEW versioned `.cap` (`<name>-<version+patch>.cap`)
115
+ in the workdir — overlay content files replace, tombstones delete,
116
+ dataset mutation logs replay into the dataset files, and manifest,
117
+ routes and security.json are regenerated — returning the `.cap`
118
+ path and its SHA-256. The saved package passes
119
+ `capsium package validate`. Read-only packages get 403, unknown
120
+ names 404, non-POST 405.
121
+
122
+ ### Changed
123
+
124
+ - `Capsium::Reactor::Introspection.new` now takes the list of served
125
+ packages (aggregation); `/introspect/status` `packagesLoaded`
126
+ reflects the mount count.
127
+ - The reactor dispatch, endpoint and response handling are split into
128
+ `Capsium::Reactor::Serving` (mount request serving),
129
+ `Capsium::Reactor::Endpoints` (reactor-level endpoints) and
130
+ `Capsium::Reactor::Responses` (HTTP response writers).
131
+ - WEBrick's `ProcHandler` (GET/POST/PUT only) is extended with
132
+ `do_DELETE`/`do_PATCH` aliases so the writable-package REST verbs
133
+ reach the mounted procs.
134
+ - `Metrics/ParameterLists` rubocop no longer counts keyword args (the
135
+ reactor constructor takes one keyword per deploy-time concern).
136
+
137
+ ## 0.4.0
138
+
139
+ ### Added
140
+
141
+ - Static registries (ARCHITECTURE.md section 4a follow-on): a registry
142
+ is a directory or a static https base URL holding an `index.json`
143
+ (`packages` -> GUID -> `name` + `versions` -> version -> `file`,
144
+ `sha256`, `size`) with `.cap` files stored relative to the registry
145
+ root, so any static host (GitHub Pages, S3, nginx) can serve one.
146
+ `Capsium::Registry.fetch(ref)` returns `Registry::Local` (read-write
147
+ directory) or `Registry::Remote` (read-only https base URL over
148
+ net/http with redirect following and timeouts; plain http for
149
+ loopback hosts only). `Registry#resolve` picks the newest version
150
+ satisfying a semver constraint via the built-in
151
+ `Version`/`VersionRange` matcher.
152
+ - `Registry::Local#push`: validates the `.cap` with
153
+ `Capsium::Package::Validator` (`Registry::InvalidPackageError` on
154
+ failure), copies it into the registry directory and atomically
155
+ rewrites `index.json` (tmp + rename) with recomputed sha256 and size.
156
+ - `Registry#install`: fetches the resolved `.cap`, verifies it against
157
+ the sha256 declared in the index (`Registry::ChecksumMismatchError`
158
+ on mismatch) and installs it into the package store as
159
+ `<name>-<version>.cap`, atomically updating the store's own
160
+ `index.json` (`Package::Store#install`). All registry failures are
161
+ typed `Registry::RegistryError` subclasses
162
+ (`RegistryNotConfiguredError`, `InvalidRegistryError`,
163
+ `InvalidPackageError`, `PackageNotFoundError`,
164
+ `UnsatisfiableConstraintError`, `ChecksumMismatchError`,
165
+ `FetchError`).
166
+ - `capsium package push PACKAGE --registry DIR` and
167
+ `capsium install GUID [--constraint RANGE] [--registry DIR_OR_URL]
168
+ [--store DIR]`; the registry defaults to `CAPSIUM_REGISTRY`, the
169
+ store to `CAPSIUM_STORE` (typed errors when unconfigured).
170
+ - `capsium reactor serve capsium://GUID` (install-then-serve with
171
+ `--registry`/`--store`/`--constraint`), and dependency-resolution
172
+ fallback for composite packages: `Package::DependencyResolver` (and
173
+ `Capsium::Package.new`/`Capsium::Reactor.new` through their new
174
+ `registry:` keyword) installs a dependency from the configured
175
+ registry when the store has no package for its GUID — fallback chain
176
+ store -> registry -> typed error (`DependencyNotFoundError` /
177
+ `UnsatisfiableDependencyError`).
178
+ - Reactor-level and per-package introspection endpoints (ARCHITECTURE.md
179
+ section 7 follow-ons), extending `Capsium::Reactor::Introspection`:
180
+ `GET /introspect/status` (`{status, uptime, packagesLoaded}`),
181
+ `GET /introspect/config` (port, store dir, cacheControl, authEnabled,
182
+ registry — deploy.json values and registry URL credentials are never
183
+ exposed) and `GET /introspect/metrics`
184
+ (`{uptime, requestsTotal, requestsByStatus}`) backed by the new
185
+ thread-safe in-memory `Capsium::Reactor::Metrics` counter wired into
186
+ the serving path, plus `GET /package/<name>/status`,
187
+ `GET /package/<name>/metadata` and `GET /package/<name>/logs` (the
188
+ reactor serves one package; any other name is a 404). `logs` returns
189
+ the last N lines (`?lines=N`, default 100) from the new
190
+ `Capsium::LogBuffer`, a small thread-safe ring buffer recording key
191
+ serving events (reactor start, package reload, one line per served
192
+ request). All endpoints are GET-only JSON (405 otherwise) and gated
193
+ when `authentication.json` enables authentication.
194
+
195
+ ### Changed
196
+
197
+ - `Metrics/ClassLength` rubocop budget raised to 150 for the reactor
198
+ and introspection classes grown by the phase-3 endpoints.
199
+
3
200
  ## 0.3.0
4
201
 
5
202
  ### Added
data/README.adoc CHANGED
@@ -42,6 +42,23 @@ Alternatively, you can install the gem directly using:
42
42
  gem install capsium
43
43
  ----
44
44
 
45
+ === OpenPGP support (librnp)
46
+
47
+ OpenPGP signatures and encryption are provided through the `rnp` gem
48
+ (a declared dependency) bound to the *librnp* shared library, which
49
+ must be installed separately:
50
+
51
+ [source,bash]
52
+ ----
53
+ brew install rnp # homebrew-core formula, provides librnp
54
+ # or build librnp from source: https://github.com/rnpgp/rnp
55
+ ----
56
+
57
+ The binding is loaded lazily, only when an OpenPGP feature is used, so
58
+ the gem works without librnp until then; OpenPGP commands raise a
59
+ typed `Capsium::Package::OpenPgp::OpenPgpUnavailableError` with
60
+ installation guidance when the library is missing.
61
+
45
62
  == What is a Capsium package?
46
63
 
47
64
  A Capsium package is a structured collection of content, data, metadata, and
@@ -105,7 +122,7 @@ Capsium provides a CLI to help you pack and manage your packages.
105
122
 
106
123
  [source,bash]
107
124
  ----
108
- capsium package pack [--force/-f] path-to-package
125
+ capsium package pack [--force/-f] [--bundle-deps/--bundle] [--store DIR] [--registry REF] path-to-package
109
126
  ----
110
127
 
111
128
  This command generates `manifest.json`/`routes.json` when absent, writes
@@ -113,6 +130,15 @@ This command generates `manifest.json`/`routes.json` when absent, writes
113
130
  directory into `{package-name}-{package-version}.cap` (name and version from
114
131
  `metadata.json`).
115
132
 
133
+ With `--bundle-deps` (alias `--bundle`) a package that declares
134
+ `metadata.dependencies` is packed as an *encapsulated* package: every
135
+ declared dependency is resolved through the store -> registry chain
136
+ (`--store`/`CAPSIUM_STORE`, `--registry`/`CAPSIUM_REGISTRY`) and embedded
137
+ under `packages/`, so the resulting `.cap` activates with no store or
138
+ registry at all (see <<_encapsulated_packages_bundled_dependencies>>).
139
+ A declared dependency that cannot be resolved aborts the pack with a
140
+ typed dependency error.
141
+
116
142
  .Sample `pack` command
117
143
  ====
118
144
  [source,bash]
@@ -208,16 +234,45 @@ run:
208
234
  openssl dgst -sha256 -verify signature.pub.pem -signature signature.sig payload.bin
209
235
  ----
210
236
 
237
+ ==== OpenPGP signatures
238
+
239
+ [source,bash]
240
+ ----
241
+ capsium package sign path-to-package-or-cap --openpgp --key secret.asc
242
+ ----
243
+
244
+ With `--openpgp`, `--key` is an OpenPGP secret key file (armored or
245
+ binary, auto-detected) and the package is signed through librnp with the
246
+ same canonical payload: `signature.sig` holds the armored detached
247
+ OpenPGP signature (SHA-256), the armored public key is embedded as
248
+ `signature.pub.asc`, and `security.json` records the scheme:
249
+
250
+ [source,json]
251
+ ----
252
+ {
253
+ "security": {
254
+ "digitalSignatures": {
255
+ "certificateType": "OpenPGP",
256
+ "publicKey": "signature.pub.asc",
257
+ "signatureFile": "signature.sig"
258
+ }
259
+ }
260
+ }
261
+ ----
262
+
211
263
  === Verifying a package signature
212
264
 
213
265
  [source,bash]
214
266
  ----
215
- capsium package verify-signature path-to-package-or-cap [--cert cert-or-public-key.pem]
267
+ capsium package verify-signature path-to-package-or-cap [--cert cert-or-public-key] [--openpgp]
216
268
  ----
217
269
 
218
270
  Verifies the declared signature against the package contents (using the
219
271
  embedded public key, or the given certificate/public key) and exits with
220
272
  status 1 when the package is unsigned or the signature does not match.
273
+ The signature scheme is auto-detected from the declared
274
+ `certificateType` (RSA-SHA256/X.509 by default, OpenPGP when recorded);
275
+ `--openpgp` forces OpenPGP verification with an OpenPGP public key.
221
276
  Packages that declare a signature are also verified automatically on load —
222
277
  a mismatch raises `Capsium::Package::Signer::SignatureMismatchError`, so a
223
278
  tampered signed package cannot be served by the reactor.
@@ -251,22 +306,50 @@ recipient's public key using RSA-OAEP with SHA-256:
251
306
  }
252
307
  ----
253
308
 
254
- The OCB/OpenPGP alternatives mentioned by the standard are intentionally
255
- not implemented.
309
+ ==== OpenPGP encryption
310
+
311
+ [source,bash]
312
+ ----
313
+ capsium package encrypt path-to-package-or-cap --openpgp --recipient public.asc -o encrypted.cap
314
+ ----
315
+
316
+ With `--openpgp`, the DEK is protected as an armored OpenPGP message to
317
+ the recipient's public key (through librnp) instead of the RSA-OAEP
318
+ wrap; the content encryption and layout are identical, and the
319
+ envelope records the key management:
320
+
321
+ [source,json]
322
+ ----
323
+ {
324
+ "encryption": {
325
+ "algorithm": "AES-256-GCM",
326
+ "keyManagement": "OpenPGP",
327
+ "message": "<armored OpenPGP message containing the DEK>",
328
+ "iv": "<base64>",
329
+ "authTag": "<base64>"
330
+ }
331
+ }
332
+ ----
333
+
334
+ The OCB alternative mentioned by the standard remains out of scope.
256
335
 
257
336
  === Decrypting a package
258
337
 
259
338
  [source,bash]
260
339
  ----
261
- capsium package decrypt encrypted.cap --private-key private.pem [-o decrypted.cap]
340
+ capsium package decrypt encrypted.cap [--private-key private.pem | --key secret.asc] [--openpgp] [-o decrypted.cap]
262
341
  ----
263
342
 
264
- Decrypts an encrypted package with the recipient's RSA private key and
343
+ Decrypts an encrypted package with the recipient's private key and
265
344
  writes the plaintext `.cap` (default output: `<name>-decrypted.cap`).
266
- Decryption fails with a typed error when the key does not match or the
267
- ciphertext was tampered with (GCM authentication). Loading an encrypted
268
- package without a key raises `Capsium::Package::Cipher::KeyRequiredError`
269
- the reactor refuses to serve it.
345
+ The key management is auto-detected from the envelope `--private-key`
346
+ for RSA-OAEP, `--key` for an OpenPGP secret key (armored or binary);
347
+ `--openpgp` forces the OpenPGP cipher. Decryption fails with a typed
348
+ error when the key does not match or the ciphertext was tampered with
349
+ (GCM authentication). Loading an encrypted package without a key raises
350
+ `Capsium::Package::Cipher::KeyRequiredError` — the reactor refuses to
351
+ serve it. `Capsium::Package.new(path, decryption_key:)` accepts either
352
+ key format transparently.
270
353
 
271
354
  === Testing a package
272
355
 
@@ -330,14 +413,97 @@ with status 1 when any test fails (or a test definition is invalid).
330
413
 
331
414
  [source,bash]
332
415
  ----
333
- capsium reactor serve my_package.cap [--port 8864] [--store DIR] [--deploy deploy.json]
416
+ capsium reactor serve my_package.cap [--port 8864] [--store DIR] [--deploy deploy.json] [--workdir DIR]
417
+ capsium reactor serve capsium://example.com/story [--registry DIR_OR_URL] [--store DIR] [--constraint ">=1.0"]
334
418
  ----
335
419
 
336
- The reactor takes a local package directory or `.cap` file; serving
337
- directly from a URL is not supported. `--store` (or `CAPSIUM_STORE`)
420
+ The reactor takes a local package directory or `.cap` file, or a
421
+ `capsium://` GUID which is installed from a registry first (see
422
+ "Static registries" below). `--store` (or `CAPSIUM_STORE`)
338
423
  resolves composite-package dependencies; `--deploy` (or
339
424
  `CAPSIUM_DEPLOY`) points at the reactor-side `deploy.json` with
340
- authentication secrets (see "Authentication" below).
425
+ authentication secrets (see "Authentication" below); `--registry`
426
+ (or `CAPSIUM_REGISTRY`) supplies the registry for `capsium://` GUIDs
427
+ and for dependency fallback; `--workdir` holds the writable overlays
428
+ and saved packages (default: a temporary directory — see "Writable
429
+ packages" below).
430
+
431
+ === Serving multiple packages
432
+
433
+ `capsium reactor serve` accepts multiple sources at once: positional
434
+ arguments, repeatable `--mount PATH=SOURCE` options, a JSON mount
435
+ config via `--config`, or any combination:
436
+
437
+ [source,bash]
438
+ ----
439
+ capsium reactor serve first.cap second.cap
440
+ capsium reactor serve --mount /=first.cap --mount /api=second.cap
441
+ capsium reactor serve --config mounts.json # {"mounts": [{"path": "/", "source": "first.cap", "store": "..."}]}
442
+ ----
443
+
444
+ Default mount points: the first source mounts at `/`, each additional
445
+ source at `/<metadata.name>/`. Requests dispatch to mounts by
446
+ longest-prefix matching, and every package's routes answer below its
447
+ prefix (a package mounted at `/api` serves its `/index.html` route at
448
+ `/api/index.html`). Two mounts claiming the same prefix fail with a
449
+ `MountConflictError`. The introspection endpoints aggregate ALL
450
+ mounted packages, `/package/<name>/...` resolves by name (404 for
451
+ unknown names), and metrics/logs stay reactor-global. On shutdown the
452
+ reactor cleans up every mounted package.
453
+
454
+ === Writable packages
455
+
456
+ A mounted package whose metadata does not declare `"readOnly": true`
457
+ is writable: the reactor keeps an append-only overlay layer for it in
458
+ the workdir (`--workdir DIR`, default a temporary directory) which is
459
+ always the topmost layer of the merged view (ARCHITECTURE.md section
460
+ 5a). The immutable `.cap` never changes on disk, and every write is
461
+ visible on the next request (hot-swap — no restart needed). Overlay
462
+ state persists in the workdir, so a later reactor over the same
463
+ workdir sees it again.
464
+
465
+ Dataset CRUD (JSON) under the dataset's route:
466
+
467
+ [source]
468
+ ----
469
+ POST /api/v1/data/<dataset> # append an item => 201 + Location + stored item
470
+ GET /api/v1/data/<dataset>/<id> # one item => 200 (404 when absent)
471
+ PUT /api/v1/data/<dataset>/<id> # replace an item => 200 (404 absent, 422 schema fail)
472
+ DELETE /api/v1/data/<dataset>/<id> # delete an item => 204 (404 absent)
473
+ GET /api/v1/data/<dataset> # the merged collection (as before)
474
+ ----
475
+
476
+ Item ids follow the `id` field when present, else the 1-based index as
477
+ a string; a duplicate `id` on POST (or a mismatched one on PUT) is a
478
+ `409`. The request body must satisfy the dataset's JSON schema when
479
+ one is declared (violations are `422` with the schema errors);
480
+ malformed JSON is `400`. Writes on a `"readOnly": true` package are
481
+ `403` with a clear body; writes on a SQLite dataset are `501`;
482
+ wrong verbs are `405`. Mutations persist as a per-dataset JSON
483
+ operation log in the overlay.
484
+
485
+ Content writes work on any route path (text bodies for v1):
486
+
487
+ [source]
488
+ ----
489
+ PUT /<route> # create/overwrite a content file (route created on demand) => 200
490
+ DELETE /<route> # tombstone: the path 404s even if a lower layer has it => 204
491
+ ----
492
+
493
+ Every mounted package with datasets also answers GraphQL at
494
+ `<mount>/graphql` (POST, or GET with `?query=`): the schema is derived
495
+ from the package's datasets — a query field `<dataset>` (list) with an
496
+ optional `id:` argument (single item) plus `create<Dataset>`,
497
+ `update<Dataset>` and `delete<Dataset>` mutations matching the REST
498
+ semantics. Item types are inferred from the dataset's JSON schema when
499
+ present, else map to a permissive JSON scalar; SQLite datasets are
500
+ skipped. Not-found items, schema violations and read-only mutations
501
+ land in the GraphQL `errors` array (never a 500).
502
+
503
+ `POST /package/<name>/save` folds base plus overlays into a NEW
504
+ versioned `.cap` (`<name>-<version+patch>.cap`) in the workdir and
505
+ returns its path and SHA-256; the saved package passes
506
+ `capsium package validate`.
341
507
 
342
508
  === Reactor introspection API
343
509
 
@@ -365,6 +531,29 @@ timestamp; `reason` lists the integrity errors when `valid` is false.
365
531
  The entry also reports `signed` and `encrypted` status, plus
366
532
  `signatureValid` when the package declares a signature.
367
533
 
534
+ The reactor also answers reactor-level and per-package introspection
535
+ endpoints (07-reactor follow-ons), likewise GET-only JSON:
536
+
537
+ [source]
538
+ ----
539
+ GET /introspect/status # => {"status": "running", "uptime": <seconds>, "packagesLoaded": 1}
540
+ GET /introspect/config # => {"port", "storeDir", "cacheControl", "authEnabled", "registry"}
541
+ GET /introspect/metrics # => {"uptime", "requestsTotal", "requestsByStatus": {"200": n, ...}}
542
+ GET /package/<name>/status # => {"package", "version", "status": "loaded", "valid": true}
543
+ GET /package/<name>/metadata # => {"name", "version", "description", "author", "guid"}
544
+ GET /package/<name>/logs[?lines=N] # => {"package", "logs": ["<utc> GET / -> 200", ...]}
545
+ ----
546
+
547
+ `/introspect/config` never exposes secrets (deploy.json values, or
548
+ credentials embedded in a registry URL, are redacted). With multiple
549
+ mounted packages the `/api/v1/introspect/*` endpoints aggregate all of
550
+ them, and `/package/<name>/...` resolves by name (404 for unknown
551
+ names). `logs` returns the last N lines (default 100) from a small
552
+ in-memory ring buffer (`Capsium::LogBuffer`) recording key serving
553
+ events; `metrics` counts requests by status in memory
554
+ (`Capsium::Reactor::Metrics`). When authentication is enabled these
555
+ endpoints are gated like any other route.
556
+
368
557
  == Layered storage
369
558
 
370
559
  `storage.layers` stacks overlay directories over `content/` (bottom ->
@@ -415,6 +604,118 @@ capsium package info my-composite-package --store ./store
415
604
  capsium reactor serve my-composite-package --store ./store
416
605
  ----
417
606
 
607
+ [[_encapsulated_packages_bundled_dependencies]]
608
+ == Encapsulated packages (bundled dependencies)
609
+
610
+ `capsium package pack --bundle-deps` (alias `--bundle`) produces a
611
+ *self-contained* package: the resolved dependency `.cap` files are
612
+ embedded inside the parent, so the whole tree activates with no store
613
+ and no registry. Layout inside the `.cap`:
614
+
615
+ ----
616
+ packages/
617
+ index.json # bundled-dependencies manifest
618
+ <name>-<version>.cap # one per declared dependency
619
+ ----
620
+
621
+ `packages/index.json` maps each dependency GUID to its embedded file,
622
+ the resolved version and the SHA-256 of the embedded `.cap`:
623
+
624
+ [source,json]
625
+ ----
626
+ {
627
+ "https://example.com/capsiums/base-package": {
628
+ "file": "packages/base-package-1.2.0.cap",
629
+ "version": "1.2.0",
630
+ "sha256": "<hex sha256 of the .cap>"
631
+ }
632
+ }
633
+ ----
634
+
635
+ Resolution order on load is *bundle first*: a dependency GUID found in
636
+ `packages/index.json` resolves to the embedded `.cap` (this also works
637
+ when the parent itself was loaded from a `.cap` — the embedded files
638
+ are read out of the parent's extraction); anything not bundled falls
639
+ back to the usual store -> registry chain. The bundle is passed down
640
+ to the dependencies' own resolution, so a dependency's dependency
641
+ resolves from the parent's bundle when the parent re-declares it.
642
+
643
+ Bundling follows a *one-level policy*: only the dependencies declared
644
+ in the parent's own `metadata.dependencies` are embedded. To make a
645
+ whole tree self-contained, the parent's `metadata.dependencies` must
646
+ list the transitive closure; a bundled dependency whose own
647
+ dependencies are not re-declared by the parent still needs a store or
648
+ registry at activation.
649
+
650
+ Security: bundled `.cap` files are covered by the parent package's
651
+ `security.json` checksums like every other file; the manifest SHA-256
652
+ is re-verified when a bundled dependency is resolved
653
+ (`Security::IntegrityError` on mismatch), the bundled version must
654
+ still satisfy the declared range, and each bundled package's own
655
+ `security.json` (and any declared signature) is verified when it is
656
+ loaded — the usual activation checks. Serving (layers, visibility,
657
+ route inheritance) is unchanged: bundled sources behave exactly like
658
+ store-resolved ones.
659
+
660
+ == Static registries
661
+
662
+ A *registry* is a directory or a static https base URL holding an
663
+ `index.json` plus `.cap` files stored relative to the registry root,
664
+ so any static host (GitHub Pages, S3, nginx) can serve one:
665
+
666
+ [source,json]
667
+ ----
668
+ {
669
+ "packages": {
670
+ "https://github.com/capsiums/cap-story": {
671
+ "name": "story-of-claire",
672
+ "versions": {
673
+ "1.0.0": {
674
+ "file": "story-of-claire-1.0.0.cap",
675
+ "sha256": "<hex sha256 of the .cap>",
676
+ "size": 642047
677
+ }
678
+ }
679
+ }
680
+ }
681
+ }
682
+ ----
683
+
684
+ `Capsium::Registry.fetch(ref)` returns the implementation for a
685
+ reference: `Registry::Local` for a directory (read-write) or
686
+ `Registry::Remote` for an https base URL (read-only; plain http is
687
+ accepted for loopback hosts only). Both resolve a GUID to the newest
688
+ version satisfying a semver constraint and install it: the `.cap` is
689
+ fetched, verified against the sha256 declared in the index
690
+ (`Registry::ChecksumMismatchError` on mismatch) and recorded in the
691
+ package store as `<name>-<version>.cap` with an updated store
692
+ `index.json`. Failures are typed (`RegistryError` subclasses):
693
+ `RegistryNotConfiguredError`, `InvalidRegistryError`,
694
+ `InvalidPackageError`, `PackageNotFoundError`,
695
+ `UnsatisfiableConstraintError`, `ChecksumMismatchError`, `FetchError`.
696
+
697
+ [source,bash]
698
+ ----
699
+ # Validate a .cap, copy it into a registry directory and record it in
700
+ # index.json (atomically rewritten; sha256+size recomputed):
701
+ capsium package push story-of-claire-1.0.0.cap --registry ./registry
702
+
703
+ # Resolve, download, sha256-verify and install into the package store:
704
+ capsium install https://github.com/capsiums/cap-story \
705
+ [--constraint ">=1.0"] [--registry DIR_OR_URL] [--store DIR]
706
+
707
+ # Install-then-serve a capsium:// GUID:
708
+ capsium reactor serve capsium://example.com/story \
709
+ [--registry DIR_OR_URL] [--store DIR] [--constraint ">=1.0"]
710
+ ----
711
+
712
+ The registry comes from `--registry` or `CAPSIUM_REGISTRY` (a typed
713
+ error when neither is set); the store from `--store` or
714
+ `CAPSIUM_STORE`. Composite-package dependency resolution uses the same
715
+ fallback chain: when the store has no package for a dependency GUID,
716
+ the resolver installs it from the configured registry into the store
717
+ (store -> registry -> typed error).
718
+
418
719
  == Authentication
419
720
 
420
721
  `authentication.json` enables user authentication (05x-authentication):
@@ -620,6 +921,14 @@ bundle exec rubocop
620
921
  bundle exec rbs -I sig validate
621
922
  ----
622
923
 
924
+ The OpenPGP specs run only when librnp is available (`brew install rnp`,
925
+ or built from https://github.com/rnpgp/rnp); without it they skip
926
+ cleanly, so environments without librnp (e.g. CI images that do not
927
+ install it) stay green. The CI workflow is the shared
928
+ cimas/metanorma `generic-rake` workflow and does not install librnp —
929
+ install it in a custom workflow step (macOS: `brew install rnp`) to run
930
+ the OpenPGP specs there.
931
+
623
932
  == License
624
933
 
625
934
  Copyright Ribose.
data/capsium.gemspec CHANGED
@@ -35,11 +35,13 @@ Gem::Specification.new do |spec|
35
35
 
36
36
  spec.add_dependency "bcrypt"
37
37
  spec.add_dependency "csv"
38
+ spec.add_dependency "graphql"
38
39
  spec.add_dependency "json"
39
40
  spec.add_dependency "json-schema"
40
41
  spec.add_dependency "listen"
41
42
  spec.add_dependency "lutaml-model", ">= 0.8", "< 1.0"
42
43
  spec.add_dependency "marcel"
44
+ spec.add_dependency "rnp", ">= 1.0"
43
45
  spec.add_dependency "rubyzip", ">= 2.3.2", "< 4.0"
44
46
  spec.add_dependency "sqlite3"
45
47
  spec.add_dependency "thor"