capsium 0.2.0 → 0.3.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +12 -0
  3. data/CHANGELOG.md +125 -0
  4. data/README.adoc +332 -5
  5. data/capsium.gemspec +1 -0
  6. data/lib/capsium/cli/formatting.rb +27 -0
  7. data/lib/capsium/cli/package.rb +68 -6
  8. data/lib/capsium/cli/reactor.rb +9 -1
  9. data/lib/capsium/cli.rb +1 -0
  10. data/lib/capsium/package/authentication.rb +38 -0
  11. data/lib/capsium/package/authentication_config.rb +78 -0
  12. data/lib/capsium/package/cipher.rb +197 -0
  13. data/lib/capsium/package/composition.rb +77 -0
  14. data/lib/capsium/package/dependency_resolver.rb +61 -0
  15. data/lib/capsium/package/encryption_config.rb +38 -0
  16. data/lib/capsium/package/merged_view.rb +168 -0
  17. data/lib/capsium/package/preparation.rb +81 -0
  18. data/lib/capsium/package/routes_config.rb +60 -6
  19. data/lib/capsium/package/security.rb +21 -4
  20. data/lib/capsium/package/signer.rb +201 -0
  21. data/lib/capsium/package/storage_config.rb +29 -1
  22. data/lib/capsium/package/store.rb +109 -0
  23. data/lib/capsium/package/testing/config_test.rb +70 -0
  24. data/lib/capsium/package/testing/context.rb +11 -0
  25. data/lib/capsium/package/testing/data_validation_test.rb +63 -0
  26. data/lib/capsium/package/testing/file_test.rb +45 -0
  27. data/lib/capsium/package/testing/report.rb +33 -0
  28. data/lib/capsium/package/testing/route_test.rb +64 -0
  29. data/lib/capsium/package/testing/test_case.rb +60 -0
  30. data/lib/capsium/package/testing/test_suite.rb +86 -0
  31. data/lib/capsium/package/testing.rb +20 -0
  32. data/lib/capsium/package/validator.rb +14 -3
  33. data/lib/capsium/package/verification.rb +32 -0
  34. data/lib/capsium/package/version.rb +204 -0
  35. data/lib/capsium/package.rb +59 -75
  36. data/lib/capsium/packager.rb +34 -1
  37. data/lib/capsium/reactor/authenticator.rb +180 -0
  38. data/lib/capsium/reactor/deploy.rb +71 -0
  39. data/lib/capsium/reactor/htpasswd.rb +154 -0
  40. data/lib/capsium/reactor/introspection.rb +4 -1
  41. data/lib/capsium/reactor/oauth2.rb +106 -0
  42. data/lib/capsium/reactor/serving.rb +75 -0
  43. data/lib/capsium/reactor/session.rb +82 -0
  44. data/lib/capsium/reactor.rb +43 -41
  45. data/lib/capsium/thor_ext.rb +1 -1
  46. data/lib/capsium/version.rb +1 -1
  47. data/sig/capsium/package/authentication.rbs +23 -0
  48. data/sig/capsium/package/authentication_config.rbs +71 -0
  49. data/sig/capsium/package/cipher.rbs +51 -0
  50. data/sig/capsium/package/composition.rbs +20 -0
  51. data/sig/capsium/package/dependency_resolver.rbs +49 -0
  52. data/sig/capsium/package/encryption_config.rbs +35 -0
  53. data/sig/capsium/package/merged_view.rbs +46 -0
  54. data/sig/capsium/package/preparation.rbs +23 -0
  55. data/sig/capsium/package/routes_config.rbs +36 -3
  56. data/sig/capsium/package/security.rbs +9 -2
  57. data/sig/capsium/package/signer.rbs +59 -0
  58. data/sig/capsium/package/storage_config.rbs +26 -2
  59. data/sig/capsium/package/store.rbs +37 -0
  60. data/sig/capsium/package/testing/config_test.rbs +21 -0
  61. data/sig/capsium/package/testing/context.rbs +14 -0
  62. data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
  63. data/sig/capsium/package/testing/file_test.rbs +17 -0
  64. data/sig/capsium/package/testing/report.rbs +20 -0
  65. data/sig/capsium/package/testing/route_test.rbs +22 -0
  66. data/sig/capsium/package/testing/test_case.rbs +41 -0
  67. data/sig/capsium/package/testing/test_suite.rbs +20 -0
  68. data/sig/capsium/package/testing.rbs +7 -0
  69. data/sig/capsium/package/verification.rbs +24 -0
  70. data/sig/capsium/package/version.rbs +39 -0
  71. data/sig/capsium/package.rbs +25 -17
  72. data/sig/capsium/packager.rbs +9 -0
  73. data/sig/capsium/reactor/authenticator.rbs +40 -0
  74. data/sig/capsium/reactor/deploy.rbs +34 -0
  75. data/sig/capsium/reactor/htpasswd.rbs +36 -0
  76. data/sig/capsium/reactor/oauth2.rbs +31 -0
  77. data/sig/capsium/reactor/serving.rbs +22 -0
  78. data/sig/capsium/reactor/session.rbs +36 -0
  79. data/sig/capsium/reactor.rbs +6 -1
  80. metadata +70 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9746224c88e5a4a3b2aabe9609af2348235d6efe09dcd37fa71c710f08594829
4
- data.tar.gz: fcbf1c6857f12bede912bb25d32dc83ddb84cd97757425dcf642af9e785e9478
3
+ metadata.gz: 188f04acf4c1f13b7a4515798e1183f59473ffba9200d375ea31b7ec2ceb705f
4
+ data.tar.gz: '068cb0fbbfa415b6f07bb299e233c2f27f5161fe82b932c1379883fcba79a36f'
5
5
  SHA512:
6
- metadata.gz: fce42689914eda35768a2dfa4dfa74b5c00e8b760ddb50cb6b44b810a20b1423e0a31392eaefff1270408c6ed83d4b56c10a895a54f609ff71afd117b6950194
7
- data.tar.gz: a45f5ea52966810c8ab183f0ec3407287eb7c87b34f82555242372d08b944edea2c372fdabe836f5e8ef0ff7ee793bed10fcc0a6ca21e486acd2466f7fbb1b93
6
+ metadata.gz: ed6d82167bc38448608ced953d1c906980e2fbbbc1297e1c79debe3aaf67508a9aa14237741c42be113b4daffe4e8c8d3e7a8c710f899acebeece1ce9b4dfde8
7
+ data.tar.gz: c1294b0ec91effddc6cf7da04cb4c128db2a31f2f82625b2a12b92c303be6bcfc566f4bf4a8c6658a187f623e8ba433cab24d275a0d880b82c3717775aec3aa5
data/.rubocop.yml CHANGED
@@ -26,6 +26,18 @@ Metrics/BlockLength:
26
26
  - 'spec/**/*'
27
27
  - 'capsium.gemspec'
28
28
 
29
+ # Spec helpers take the parameters the fixtures need.
30
+ Metrics/ModuleLength:
31
+ Exclude:
32
+ - 'spec/**/*'
33
+
34
+ # Spec helpers take the parameters the fixtures need; the reactor
35
+ # constructor takes one keyword per deploy-time concern.
36
+ Metrics/ParameterLists:
37
+ Max: 6
38
+ Exclude:
39
+ - 'spec/**/*'
40
+
29
41
  Metrics/ClassLength:
30
42
  Max: 120
31
43
 
data/CHANGELOG.md CHANGED
@@ -1,5 +1,130 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Added
6
+
7
+ - Digital signatures (RSA-SHA256, X.509) per the packaging and security
8
+ standard clauses: `Capsium::Package::Signer` signs a package directory in
9
+ place (`signature.sig` plus the embedded public key PEM, recorded as
10
+ `security.digitalSignatures {publicKey, signatureFile}` in
11
+ `security.json`) and verifies declared signatures. The signed payload is
12
+ the concatenation, in sorted package-relative path order, of the raw
13
+ bytes of every checksum-covered file (everything except `security.json`
14
+ and `signature.sig`) — verifiable independently with
15
+ `openssl dgst -sha256 -verify`.
16
+ - Signed packages are verified automatically on load:
17
+ `Capsium::Package#verify_signature`/`#verify_signature!`/`#signed?`;
18
+ a mismatch raises `Capsium::Package::Signer::SignatureMismatchError`,
19
+ so the reactor refuses to serve tampered signed packages.
20
+ - `capsium package sign PATH --key KEY.pem [--cert CERT.pem]` and
21
+ `capsium package verify-signature PATH [--cert CERT.pem]` (exit status 1
22
+ on unsigned packages and verification failures). Both accept a package
23
+ directory or a `.cap` file.
24
+ - Whole-package encryption (AES-256-GCM, RSA-OAEP-SHA256 wrapped DEK) per
25
+ the packaging standard's encryption clause: `Capsium::Package::Cipher`
26
+ encrypts a package directory or `.cap` into the standard encrypted
27
+ layout (`metadata.json` cleartext, `signature.json` encryption envelope,
28
+ `package.enc` AES-256-GCM of the inner `.cap` zip) and decrypts it back.
29
+ The OCB/OpenPGP alternatives mentioned by the standard are intentionally
30
+ not implemented.
31
+ - `capsium package encrypt PATH --public-key PUB.pem -o OUT.cap` and
32
+ `capsium package decrypt PATH --private-key PRIV.pem [-o OUT.cap]`.
33
+ - `Capsium::Package.new(path, decryption_key:)` transparently decrypts an
34
+ encrypted `.cap` (or uncompressed encrypted directory) on load; without
35
+ a key it raises `Capsium::Package::Cipher::KeyRequiredError` (the
36
+ reactor refuses to serve), and a wrong key or tampered ciphertext raises
37
+ `Capsium::Package::Cipher::DecryptionError` (GCM authentication).
38
+ - `Capsium::Packager#transform_cap`/`#with_unpacked_cap` helpers for
39
+ in-place `.cap` transformations.
40
+ - The Capsium package testing YAML DSL (05x-testing): packages declare
41
+ tests in `tests/*.yaml` files (top-level `tests` list), and
42
+ `capsium package test PATH` runs them with per-test `PASS`/`FAIL`
43
+ output, a summary line and proper exit codes. Supported test types:
44
+ `route` (expected status, optional `response_contains` and
45
+ `expected_content_type` against a reactor started for the run),
46
+ `file` (existence, optional `contains`), `data_validation` (dataset
47
+ rows validated against a JSON schema, JSON and YAML formats) and
48
+ `config` (configuration files parsed and the known package configs
49
+ validated against their canonical models). Implemented as an
50
+ open/closed registry of `Capsium::Package::Testing::TestCase`
51
+ subclasses run by `Capsium::Package::Testing::TestSuite`; invalid
52
+ test definitions are reported as failures instead of aborting the run.
53
+ - Layered storage (ARCHITECTURE.md section 5a): `storage.layers` stacks
54
+ overlay directories over `content/` (bottom -> top in declaration
55
+ order), each mirroring the `content/` tree.
56
+ `Capsium::Package::MergedView` — the merged-view home shared by the
57
+ package (validation) and the reactor (serving) — resolves content
58
+ top-first, honors `.capsium-tombstones` deletions (a tombstoned path
59
+ resolves 404 even when a lower layer holds the file, and a tombstone
60
+ also suppresses dependency content below it), and hides
61
+ `visibility: private` layers and non-exported resources in the
62
+ exported-only view dependents get. Packages without a `layers` config
63
+ behave exactly as before (single implicit `content/` layer).
64
+ - Composite packages (ARCHITECTURE.md section 4a):
65
+ `metadata.dependencies` (GUID -> semver range) resolve against a
66
+ package store (`CAPSIUM_STORE` or `--store`) of
67
+ `<name>-<version>.cap` files plus an optional `index.json` (GUID ->
68
+ file); the newest satisfying version wins via the built-in semver
69
+ matcher `Capsium::Package::Version`/`VersionRange` (`>=`, `^`, `~`,
70
+ exact, `*`, `x`-wildcards/partials, comma/space conjunctions — no new
71
+ runtime dependency). Resolution is recursive with circularity
72
+ detection and raises typed errors:
73
+ `Capsium::Package::DependencyNotFoundError`,
74
+ `UnsatisfiableDependencyError`, `CircularDependencyError`,
75
+ `DependencyVisibilityError`.
76
+ - Resolved dependencies become read-only layers below all of the
77
+ dependent's own layers, and only `exported` resources are visible;
78
+ routes may address dependency content explicitly as
79
+ `"<dependency-guid>/<path>"` and declare the route-inheritance
80
+ attributes of 05x-routing: `remap` (replaces the serving path),
81
+ `responseRewrite` (`body`, `headers`), `responseHeaders` (merged over
82
+ served headers) and `requestHeaders` (parsed and exposed for
83
+ forwarding reactors; a documented no-op for this static reactor).
84
+ Referencing a dependency's private or missing resource is a load-time
85
+ error. `capsium package info` prints the resolved dependency tree;
86
+ `capsium reactor serve` accepts `--store`.
87
+ - Authentication (05x-authentication, ARCHITECTURE.md section 4b):
88
+ `authentication.json` declares `basicAuth {enabled, passwdFile,
89
+ realm}` and `oauth2 {enabled, provider, clientId, authorizationUrl,
90
+ tokenUrl, userinfoUrl, redirectPath, scopes}`; the reactor enforces
91
+ it via `Capsium::Reactor::Authenticator`. Basic authentication
92
+ challenges (401 + `WWW-Authenticate`) and verifies against the
93
+ htpasswd file — bcrypt via the new `bcrypt` runtime dependency, plus
94
+ pure-Ruby md5-crypt (`$apr1$`/`$1$` as deployed by
95
+ htpasswd/OpenSSL/glibc), `{SHA}` and a `crypt(3)` fallback.
96
+ OAuth2 runs the authorization-code flow over net/http: `/auth/login`
97
+ redirects with HMAC-signed state, the callback exchanges the code at
98
+ `tokenUrl`, fetches the userinfo claims and establishes an
99
+ HMAC-SHA256 signed session cookie (`Capsium::Reactor::Session`).
100
+ Secrets never come from the package: `clientSecret`/`sessionSecret`
101
+ and role assignments live in `deploy.json` (`--deploy` or
102
+ `CAPSIUM_DEPLOY`); without a configured session secret the reactor
103
+ generates one and persists it (mode 0600) outside the package.
104
+ Dataset-route `accessControl {roles, authenticationRequired}` is
105
+ enforced after authentication: 401 unauthenticated, 403 unauthorized.
106
+ - The `content-validity` introspection entry now reports `signed` and
107
+ `encrypted` status, plus `signatureValid` when the package declares a
108
+ signature; `Capsium::Package#encrypted?` reports whether the package
109
+ was loaded from an encrypted source.
110
+
111
+ ### Changed
112
+
113
+ - Integrity checksums now exclude `signature.sig` in addition to
114
+ `security.json` (the signature signs the checksum-covered payload, so it
115
+ cannot be part of it).
116
+ - `capsium package pack` drops signing artifacts (`signature.sig`,
117
+ embedded public key) when repacking: signing is a post-pack step.
118
+ - The reactor serves resources through `Package#merged_view` (the
119
+ overlay-aware resolution path); behavior is unchanged for packages
120
+ without layers.
121
+ - Loading a package with declared `metadata.dependencies` now resolves
122
+ them eagerly against the package store (`store:` or `CAPSIUM_STORE`);
123
+ a package whose dependencies cannot resolve (including normalized
124
+ pre-0.2 dependency declarations) fails to load with a typed error.
125
+ - `Metrics/ParameterLists` rubocop budget raised to 6 for the reactor
126
+ constructor's per-concern keywords.
127
+
3
128
  ## 0.2.0
4
129
 
5
130
  ### Changed
data/README.adoc CHANGED
@@ -55,6 +55,7 @@ manifest.json # auto-generated at load/pack time when absent
55
55
  routes.json # auto-generated when absent
56
56
  storage.json # optional (only when datasets exist)
57
57
  security.json # generated at pack time (SHA-256 checksums)
58
+ authentication.json # optional (user authentication)
58
59
  content/ # served content root (URL space mirrors paths below it)
59
60
  data/ # datasets (optional)
60
61
  ----
@@ -73,9 +74,15 @@ Paths inside the configuration files are package-relative POSIX paths.
73
74
  full filename); the index HTML additionally gets `/`.
74
75
  * **storage.json**: dataset definitions under `storage.dataSets`, either
75
76
  schema-backed files (`source`, `schemaFile`, `schemaType: json-schema`)
76
- or SQLite (`databaseFile`, `table`).
77
+ or SQLite (`databaseFile`, `table`). `storage.layers` declares overlay
78
+ layers (see "Layered storage" below).
77
79
  * **security.json**: `security.integrityChecks.checksums` holds a SHA-256 hex
78
- digest for every file in the package except `security.json` itself.
80
+ digest for every file in the package except `security.json` itself and
81
+ `signature.sig`. When the package is signed, `security.digitalSignatures`
82
+ records `{ "publicKey", "signatureFile" }`.
83
+ * **authentication.json**: optional user authentication (see
84
+ "Authentication" below): `authentication.basicAuth` and/or
85
+ `authentication.oauth2`.
79
86
 
80
87
  Legacy (pre-0.2) configuration files are still accepted on read and
81
88
  normalized to the canonical forms; writers emit only the canonical forms.
@@ -151,20 +158,186 @@ Runs a per-check report and exits with status 1 on any failure:
151
158
 
152
159
  [source,bash]
153
160
  ----
154
- capsium package info|manifest|routes|metadata|storage path-to-package
161
+ capsium package info|manifest|routes|metadata|storage path-to-package [--store DIR]
155
162
  ----
156
163
 
164
+ `info` additionally prints the resolved dependency tree for composite
165
+ packages (declared GUID and range, resolved version, store `.cap`), see
166
+ "Composite packages" below.
167
+
168
+ === Signing a package
169
+
170
+ [source,bash]
171
+ ----
172
+ capsium package sign path-to-package-or-cap --key private.pem [--cert cert.pem]
173
+ ----
174
+
175
+ Signs the package with an RSA private key (minimum 2048 bits) using
176
+ RSA-SHA256, per the packaging standard's digital signature clause. Signing
177
+ is a post-pack step: `pack` drops signing artifacts, so sign the packed
178
+ directory or the `.cap` file itself.
179
+
180
+ The signed payload is the concatenation, in sorted package-relative path
181
+ order, of the raw bytes of every file covered by the `security.json`
182
+ integrity checksums — i.e. every package file except `security.json` and
183
+ `signature.sig`. The signature over that payload is stored as raw bytes in
184
+ `signature.sig`, the signer's public key PEM is embedded as
185
+ `signature.pub.pem`, and `security.json` records:
186
+
187
+ [source,json]
188
+ ----
189
+ {
190
+ "security": {
191
+ "digitalSignatures": {
192
+ "publicKey": "signature.pub.pem",
193
+ "signatureFile": "signature.sig"
194
+ }
195
+ }
196
+ }
197
+ ----
198
+
199
+ When `--cert` is given, the X.509 certificate must match the private key,
200
+ and the certificate's public key is embedded instead.
201
+
202
+ The payload construction is openssl-compatible; to verify a signed package
203
+ independently, concatenate the checksum-covered files in sorted order and
204
+ run:
205
+
206
+ [source,bash]
207
+ ----
208
+ openssl dgst -sha256 -verify signature.pub.pem -signature signature.sig payload.bin
209
+ ----
210
+
211
+ === Verifying a package signature
212
+
213
+ [source,bash]
214
+ ----
215
+ capsium package verify-signature path-to-package-or-cap [--cert cert-or-public-key.pem]
216
+ ----
217
+
218
+ Verifies the declared signature against the package contents (using the
219
+ embedded public key, or the given certificate/public key) and exits with
220
+ status 1 when the package is unsigned or the signature does not match.
221
+ Packages that declare a signature are also verified automatically on load —
222
+ a mismatch raises `Capsium::Package::Signer::SignatureMismatchError`, so a
223
+ tampered signed package cannot be served by the reactor.
224
+
225
+ === Encrypting a package
226
+
227
+ [source,bash]
228
+ ----
229
+ capsium package encrypt path-to-package-or-cap --public-key public.pem -o encrypted.cap
230
+ ----
231
+
232
+ Encrypts the whole package for the recipient's RSA public key (or X.509
233
+ certificate), per the packaging standard's encryption clause: the
234
+ encrypted `.cap` is a zip containing `metadata.json` (cleartext, so
235
+ name/version stay readable), `signature.json` (the encryption envelope)
236
+ and `package.enc` — the AES-256-GCM ciphertext of the inner plaintext
237
+ `.cap` zip (content, configuration files, data). A random 256-bit data
238
+ encryption key (DEK) encrypts the inner zip and is wrapped with the
239
+ recipient's public key using RSA-OAEP with SHA-256:
240
+
241
+ [source,json]
242
+ ----
243
+ {
244
+ "encryption": {
245
+ "algorithm": "AES-256-GCM",
246
+ "keyManagement": "RSA-OAEP-SHA256",
247
+ "encryptedDek": "<base64>",
248
+ "iv": "<base64>",
249
+ "authTag": "<base64>"
250
+ }
251
+ }
252
+ ----
253
+
254
+ The OCB/OpenPGP alternatives mentioned by the standard are intentionally
255
+ not implemented.
256
+
257
+ === Decrypting a package
258
+
259
+ [source,bash]
260
+ ----
261
+ capsium package decrypt encrypted.cap --private-key private.pem [-o decrypted.cap]
262
+ ----
263
+
264
+ Decrypts an encrypted package with the recipient's RSA private key and
265
+ 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.
270
+
271
+ === Testing a package
272
+
273
+ [source,bash]
274
+ ----
275
+ capsium package test path-to-package-or-cap
276
+ ----
277
+
278
+ Runs the package's test suite declared in the Capsium testing YAML DSL
279
+ (05x-testing): all `tests/*.yaml` files in the package, each holding a
280
+ top-level `tests` list. Every test has a `name` and a `type`; the four
281
+ supported types:
282
+
283
+ * `route` — requests the URL path from a reactor started for the run and
284
+ checks `expected_status`, plus optional `response_contains` (body
285
+ substring) and `expected_content_type`. Absolute URLs are accepted;
286
+ only their path (and query) is used.
287
+ * `file` — the file at `path` must exist in the package; optional
288
+ `contains` checks a content substring.
289
+ * `data_validation` — the rows of `data_file` (format `json` or `yaml`)
290
+ must validate against the JSON schema at `schema_file`. Array data
291
+ validates row by row.
292
+ * `config` — `config_file` (format `json` or `yaml`) must exist and
293
+ parse; the known package configs (`metadata.json`, `manifest.json`,
294
+ `routes.json`, `storage.json`, `security.json`) are additionally
295
+ validated against their canonical models.
296
+
297
+ Example:
298
+
299
+ [source,yaml]
300
+ ----
301
+ tests:
302
+ - name: Home Route Test
303
+ type: route
304
+ url: "/home"
305
+ expected_status: 200
306
+ response_contains: "Welcome"
307
+
308
+ - name: Config File Exists
309
+ type: file
310
+ path: "metadata.json"
311
+
312
+ - name: JSON Data Validation
313
+ type: data_validation
314
+ format: json
315
+ data_file: "data/animals.json"
316
+ schema_file: "data/animals.schema.json"
317
+
318
+ - name: Metadata Config Validation
319
+ type: config
320
+ format: json
321
+ config_file: "metadata.json"
322
+ ----
323
+
324
+ Each test is reported as `PASS`/`FAIL` with messages; the command exits
325
+ with status 1 when any test fails (or a test definition is invalid).
326
+
157
327
  == CLI: Reactor
158
328
 
159
329
  === Starting a reactor on your package
160
330
 
161
331
  [source,bash]
162
332
  ----
163
- capsium reactor serve my_package.cap [--port 8864]
333
+ capsium reactor serve my_package.cap [--port 8864] [--store DIR] [--deploy deploy.json]
164
334
  ----
165
335
 
166
336
  The reactor takes a local package directory or `.cap` file; serving
167
- directly from a URL is not supported.
337
+ directly from a URL is not supported. `--store` (or `CAPSIUM_STORE`)
338
+ resolves composite-package dependencies; `--deploy` (or
339
+ `CAPSIUM_DEPLOY`) points at the reactor-side `deploy.json` with
340
+ authentication secrets (see "Authentication" below).
168
341
 
169
342
  === Reactor introspection API
170
343
 
@@ -189,6 +362,116 @@ content checksums — the same data `security.json` integrityChecks carry.
189
362
  `content-validity` re-verifies the package against `security.json` on
190
363
  every request and reports the outcome with a UTC `lastChecked`
191
364
  timestamp; `reason` lists the integrity errors when `valid` is false.
365
+ The entry also reports `signed` and `encrypted` status, plus
366
+ `signatureValid` when the package declares a signature.
367
+
368
+ == Layered storage
369
+
370
+ `storage.layers` stacks overlay directories over `content/` (bottom ->
371
+ top in declaration order), each mirroring the `content/` tree:
372
+
373
+ [source,json]
374
+ ----
375
+ { "storage": { "layers": [
376
+ { "path": "base", "writable": false, "visibility": "exported" },
377
+ { "path": "updates", "writable": true, "visibility": "private" }
378
+ ] } }
379
+ ----
380
+
381
+ A request resolves against layers from the TOP down; the first hit
382
+ wins. Deletions are recorded as tombstones: a `.capsium-tombstones`
383
+ JSON file (an array of `content/`-relative paths) in a writable layer;
384
+ a tombstoned path resolves 404 even when a lower layer holds the file,
385
+ while a file reappearing above the tombstone is served again. Packages
386
+ without a `layers` config behave exactly as before (single implicit
387
+ `content/` layer). `visibility: private` layers are hidden from
388
+ dependent packages (see below), as are resources whose manifest
389
+ visibility is `private`.
390
+
391
+ == Composite packages
392
+
393
+ `metadata.dependencies` maps a dependency GUID to a semver range
394
+ (`>=1.0.0`, `^1.2.3`, `~1.2.3`, exact, `*`, `1.x`/`1.2.x`, partials,
395
+ and comma/space conjunctions like `>=1.0.0, <2.0.0`). Dependencies
396
+ resolve against a *package store* — a directory of
397
+ `<name>-<version>.cap` files plus an optional `index.json` (GUID ->
398
+ file) — given via `CAPSIUM_STORE` or `--store`; the newest satisfying
399
+ version wins.
400
+
401
+ On load, each resolved dependency's exported content becomes read-only
402
+ layers below all of the dependent's own layers. Routes may address
403
+ dependency content explicitly (`"resource": "<dependency-guid>/content/app.js"`)
404
+ or declare route-inheritance attributes per 05x-routing: `remap`
405
+ (replaces the serving path), `responseRewrite` (`body`, `headers`),
406
+ `responseHeaders` (merged over served headers) and `requestHeaders`
407
+ (parsed and exposed for forwarding reactors; this reactor serves
408
+ statically, so they do not alter its responses). Referencing a
409
+ dependency's private or missing resource is a load-time error, as are
410
+ circular, missing or unsatisfiable dependencies.
411
+
412
+ [source,bash]
413
+ ----
414
+ capsium package info my-composite-package --store ./store
415
+ capsium reactor serve my-composite-package --store ./store
416
+ ----
417
+
418
+ == Authentication
419
+
420
+ `authentication.json` enables user authentication (05x-authentication):
421
+
422
+ [source,json]
423
+ ----
424
+ { "authentication": {
425
+ "basicAuth": { "enabled": true, "passwdFile": "auth/.htpasswd", "realm": "capsium" },
426
+ "oauth2": { "enabled": true, "provider": "google", "clientId": "...",
427
+ "authorizationUrl": "...", "tokenUrl": "...", "userinfoUrl": "...",
428
+ "redirectPath": "/auth/callback", "scopes": ["openid", "email"] }
429
+ } }
430
+ ----
431
+
432
+ When enabled, the reactor challenges every unauthenticated request
433
+ (`401`, with `WWW-Authenticate: Basic realm="..."` when basicAuth is
434
+ enabled) and verifies credentials against the htpasswd file. Supported
435
+ hashes: bcrypt (`htpasswd -B`, via the `bcrypt` gem), Apache apr1 MD5
436
+ and md5-crypt (`$apr1$`/`$1$`, pure Ruby), unsalted SHA-1 (`{SHA}`),
437
+ and a platform `crypt(3)` fallback for the rest.
438
+
439
+ With OAuth2, `/auth/login` redirects to the provider with HMAC-signed
440
+ state; the callback exchanges the code at `tokenUrl`, fetches the
441
+ userinfo claims and establishes an HMAC-SHA256 signed session cookie
442
+ (`capsium_session`, `HttpOnly; SameSite=Lax`). Provider errors during
443
+ the exchange answer `502`; a tampered state answers `401`.
444
+
445
+ Route-level `accessControl` on dataset routes is enforced after
446
+ authentication — `401` unauthenticated, `403` when the identity lacks a
447
+ required role:
448
+
449
+ [source,json]
450
+ ----
451
+ { "path": "/api/v1/data/animals", "dataset": "animals",
452
+ "accessControl": { "roles": ["admin"], "authenticationRequired": true } }
453
+ ----
454
+
455
+ Secrets NEVER come from the package. They live in `deploy.json`
456
+ (`--deploy` or `CAPSIUM_DEPLOY`):
457
+
458
+ [source,json]
459
+ ----
460
+ {
461
+ "baseUrl": "http://localhost:8864",
462
+ "authentication": {
463
+ "basicAuth": { "passwdFile": "/secure/outside/package/.htpasswd" },
464
+ "oauth2": { "clientSecret": "..." },
465
+ "sessionSecret": "...",
466
+ "roles": { "alice": ["admin"] }
467
+ }
468
+ }
469
+ ----
470
+
471
+ `roles` assigns roles by identity name (basic-auth username, OAuth2
472
+ email or subject); userinfo `roles` claims are honored too. Without a
473
+ configured `sessionSecret`, the reactor generates one and persists it
474
+ (mode `0600`) outside the package.
192
475
 
193
476
  == Programmatically managing packages
194
477
 
@@ -203,6 +486,11 @@ require 'capsium'
203
486
 
204
487
  # Read a package directory or a .cap file
205
488
  package = Capsium::Package.new(path)
489
+
490
+ # Read an encrypted .cap (raises Capsium::Package::Cipher::KeyRequiredError
491
+ # without a key, Capsium::Package::Cipher::DecryptionError for a wrong key
492
+ # or tampered ciphertext)
493
+ package = Capsium::Package.new('encrypted.cap', decryption_key: 'private.pem')
206
494
  ----
207
495
 
208
496
  When `security.json` is present, the package is verified on load and
@@ -231,6 +519,45 @@ puts animals.data.inspect
231
519
 
232
520
  # Verifying integrity (returns a list of typed errors, empty when valid)
233
521
  errors = package.verify_integrity
522
+
523
+ # Digital signatures (RSA-SHA256)
524
+ package.signed? # => security.json declares digitalSignatures
525
+ package.verify_signature # => true/false
526
+
527
+ signer = Capsium::Package::Signer.new(package_dir)
528
+ signer.sign('private.pem') # or sign('private.pem', 'cert.pem')
529
+ signer.verify # embedded public key
530
+ signer.verify('cert-or-public-key.pem') # explicit key
531
+ signer.verify! # raises SignatureMismatchError
532
+
533
+ # Whole-package encryption (AES-256-GCM, RSA-OAEP-SHA256 wrapped DEK)
534
+ cipher = Capsium::Package::Cipher.new
535
+ cipher.encrypt('pkg-1.0.0.cap', 'public.pem', 'encrypted.cap')
536
+ cipher.decrypt('encrypted.cap', 'private.pem', 'decrypted.cap')
537
+ Capsium::Package::Cipher.encrypted?('encrypted.cap') # => true
538
+
539
+ # Running the package's tests/*.yaml suite (05x-testing DSL)
540
+ report = Capsium::Package::Testing::TestSuite.new(package).run
541
+ report.ok? # => true/false
542
+ report.failures # => failed TestCase::Result list
543
+ report.summary # => "7 tests, 0 failures"
544
+
545
+ # Layered storage (5a): the merged overlay view shared with the reactor
546
+ view = package.merged_view
547
+ view.resolve('content/app.js') # => absolute path of the topmost hit, or nil
548
+
549
+ # Composite packages (4a): resolve metadata.dependencies against a store
550
+ package = Capsium::Package.new(dir, store: './store') # or CAPSIUM_STORE
551
+ package.resolved_dependencies.each do |dep|
552
+ puts "#{dep.guid} (#{dep.range}) => #{dep.version} [#{dep.path}]"
553
+ end
554
+ view = package.merged_view # own layers over dependency layers
555
+ view = package.merged_view(exported_only: true) # what dependents may see
556
+
557
+ # Serving with authentication and a store
558
+ reactor = Capsium::Reactor.new(package: dir, store: './store',
559
+ deploy: 'deploy.json')
560
+ reactor.serve
234
561
  ----
235
562
 
236
563
  === Packing and unpacking programmatically
data/capsium.gemspec CHANGED
@@ -33,6 +33,7 @@ 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"
37
38
  spec.add_dependency "json"
38
39
  spec.add_dependency "json-schema"
@@ -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",
@@ -73,13 +76,72 @@ module Capsium
73
76
  raise Thor::Error, "Package validation failed"
74
77
  end
75
78
 
76
- private
79
+ desc "sign PACKAGE_PATH --key KEY.pem [--cert CERT.pem]",
80
+ "Sign the package (RSA-SHA256) and record the signature in security.json"
81
+ option :key, type: :string, required: true,
82
+ desc: "Path to the RSA private key PEM (min 2048 bits)"
83
+ option :cert, type: :string,
84
+ desc: "Path to the X.509 certificate PEM (must match the key)"
85
+
86
+ def sign(path_to_package)
87
+ Capsium::Package::Signer.sign_package(path_to_package, options[:key], options[:cert])
88
+ puts "Package signed: #{path_to_package}"
89
+ end
90
+
91
+ desc "verify-signature PACKAGE_PATH [--cert CERT.pem]",
92
+ "Verify the package digital signature declared in security.json"
93
+ option :cert, type: :string,
94
+ desc: "Path to the X.509 certificate or public key PEM " \
95
+ "(defaults to the key embedded in the package)"
96
+
97
+ def verify_signature(path_to_package)
98
+ unless Capsium::Package::Signer.verify_package(path_to_package, options[:cert])
99
+ raise Thor::Error, "Signature verification failed: #{path_to_package}"
100
+ end
101
+
102
+ puts "Signature valid: #{path_to_package}"
103
+ rescue Capsium::Package::Signer::SignatureError => e
104
+ raise Thor::Error, e.message
105
+ end
106
+
107
+ desc "encrypt PACKAGE_PATH --public-key PUB.pem -o OUT.cap",
108
+ "Encrypt a package (AES-256-GCM, RSA-OAEP-SHA256 wrapped DEK)"
109
+ option :public_key, type: :string, required: true,
110
+ desc: "Path to the recipient RSA public key or X.509 certificate PEM"
111
+ option :output, type: :string, required: true, aliases: "-o",
112
+ desc: "Output path for the encrypted .cap"
113
+
114
+ def encrypt(path_to_package)
115
+ Capsium::Package::Cipher.new.encrypt(
116
+ path_to_package, options[:public_key], options[:output]
117
+ )
118
+ puts "Package encrypted: #{options[:output]}"
119
+ end
120
+
121
+ desc "decrypt PACKAGE_PATH --private-key PRIV.pem [-o OUT.cap]",
122
+ "Decrypt an encrypted package"
123
+ option :private_key, type: :string, required: true,
124
+ desc: "Path to the recipient RSA private key PEM"
125
+ option :output, type: :string, aliases: "-o",
126
+ desc: "Output path (default: <name>-decrypted.cap)"
127
+
128
+ def decrypt(path_to_package)
129
+ output = options[:output] || "#{File.basename(path_to_package, '.cap')}-decrypted.cap"
130
+ Capsium::Package::Cipher.new.decrypt(path_to_package, options[:private_key], output)
131
+ puts "Package decrypted: #{output}"
132
+ end
77
133
 
78
- def format_result(result)
79
- line = "#{result.ok? ? 'PASS' : 'FAIL'} #{result.name}"
80
- return line if result.messages.empty?
134
+ desc "test PACKAGE_PATH", "Run the package's tests/*.yaml suite (05x-testing DSL)"
81
135
 
82
- "#{line}: #{result.messages.join('; ')}"
136
+ def test(path_to_package)
137
+ package = Capsium::Package.new(path_to_package)
138
+ report = Capsium::Package::Testing::TestSuite.new(package).run
139
+ report.results.each { |result| puts format_result(result) }
140
+ if report.results.empty?
141
+ puts "No tests found (#{Capsium::Package::Testing::TestSuite::TESTS_DIR}/*.yaml)"
142
+ end
143
+ puts report.summary
144
+ raise Thor::Error, "Package tests failed" unless report.ok?
83
145
  end
84
146
  end
85
147
  end