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/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
@@ -55,6 +72,7 @@ manifest.json # auto-generated at load/pack time when absent
55
72
  routes.json # auto-generated when absent
56
73
  storage.json # optional (only when datasets exist)
57
74
  security.json # generated at pack time (SHA-256 checksums)
75
+ authentication.json # optional (user authentication)
58
76
  content/ # served content root (URL space mirrors paths below it)
59
77
  data/ # datasets (optional)
60
78
  ----
@@ -73,9 +91,15 @@ Paths inside the configuration files are package-relative POSIX paths.
73
91
  full filename); the index HTML additionally gets `/`.
74
92
  * **storage.json**: dataset definitions under `storage.dataSets`, either
75
93
  schema-backed files (`source`, `schemaFile`, `schemaType: json-schema`)
76
- or SQLite (`databaseFile`, `table`).
94
+ or SQLite (`databaseFile`, `table`). `storage.layers` declares overlay
95
+ layers (see "Layered storage" below).
77
96
  * **security.json**: `security.integrityChecks.checksums` holds a SHA-256 hex
78
- digest for every file in the package except `security.json` itself.
97
+ digest for every file in the package except `security.json` itself and
98
+ `signature.sig`. When the package is signed, `security.digitalSignatures`
99
+ records `{ "publicKey", "signatureFile" }`.
100
+ * **authentication.json**: optional user authentication (see
101
+ "Authentication" below): `authentication.basicAuth` and/or
102
+ `authentication.oauth2`.
79
103
 
80
104
  Legacy (pre-0.2) configuration files are still accepted on read and
81
105
  normalized to the canonical forms; writers emit only the canonical forms.
@@ -98,7 +122,7 @@ Capsium provides a CLI to help you pack and manage your packages.
98
122
 
99
123
  [source,bash]
100
124
  ----
101
- capsium package pack [--force/-f] path-to-package
125
+ capsium package pack [--force/-f] [--bundle-deps/--bundle] [--store DIR] [--registry REF] path-to-package
102
126
  ----
103
127
 
104
128
  This command generates `manifest.json`/`routes.json` when absent, writes
@@ -106,6 +130,15 @@ This command generates `manifest.json`/`routes.json` when absent, writes
106
130
  directory into `{package-name}-{package-version}.cap` (name and version from
107
131
  `metadata.json`).
108
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
+
109
142
  .Sample `pack` command
110
143
  ====
111
144
  [source,bash]
@@ -151,20 +184,326 @@ Runs a per-check report and exits with status 1 on any failure:
151
184
 
152
185
  [source,bash]
153
186
  ----
154
- capsium package info|manifest|routes|metadata|storage path-to-package
187
+ capsium package info|manifest|routes|metadata|storage path-to-package [--store DIR]
188
+ ----
189
+
190
+ `info` additionally prints the resolved dependency tree for composite
191
+ packages (declared GUID and range, resolved version, store `.cap`), see
192
+ "Composite packages" below.
193
+
194
+ === Signing a package
195
+
196
+ [source,bash]
197
+ ----
198
+ capsium package sign path-to-package-or-cap --key private.pem [--cert cert.pem]
199
+ ----
200
+
201
+ Signs the package with an RSA private key (minimum 2048 bits) using
202
+ RSA-SHA256, per the packaging standard's digital signature clause. Signing
203
+ is a post-pack step: `pack` drops signing artifacts, so sign the packed
204
+ directory or the `.cap` file itself.
205
+
206
+ The signed payload is the concatenation, in sorted package-relative path
207
+ order, of the raw bytes of every file covered by the `security.json`
208
+ integrity checksums — i.e. every package file except `security.json` and
209
+ `signature.sig`. The signature over that payload is stored as raw bytes in
210
+ `signature.sig`, the signer's public key PEM is embedded as
211
+ `signature.pub.pem`, and `security.json` records:
212
+
213
+ [source,json]
214
+ ----
215
+ {
216
+ "security": {
217
+ "digitalSignatures": {
218
+ "publicKey": "signature.pub.pem",
219
+ "signatureFile": "signature.sig"
220
+ }
221
+ }
222
+ }
223
+ ----
224
+
225
+ When `--cert` is given, the X.509 certificate must match the private key,
226
+ and the certificate's public key is embedded instead.
227
+
228
+ The payload construction is openssl-compatible; to verify a signed package
229
+ independently, concatenate the checksum-covered files in sorted order and
230
+ run:
231
+
232
+ [source,bash]
233
+ ----
234
+ openssl dgst -sha256 -verify signature.pub.pem -signature signature.sig payload.bin
235
+ ----
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
+
263
+ === Verifying a package signature
264
+
265
+ [source,bash]
266
+ ----
267
+ capsium package verify-signature path-to-package-or-cap [--cert cert-or-public-key] [--openpgp]
268
+ ----
269
+
270
+ Verifies the declared signature against the package contents (using the
271
+ embedded public key, or the given certificate/public key) and exits with
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.
276
+ Packages that declare a signature are also verified automatically on load —
277
+ a mismatch raises `Capsium::Package::Signer::SignatureMismatchError`, so a
278
+ tampered signed package cannot be served by the reactor.
279
+
280
+ === Encrypting a package
281
+
282
+ [source,bash]
283
+ ----
284
+ capsium package encrypt path-to-package-or-cap --public-key public.pem -o encrypted.cap
285
+ ----
286
+
287
+ Encrypts the whole package for the recipient's RSA public key (or X.509
288
+ certificate), per the packaging standard's encryption clause: the
289
+ encrypted `.cap` is a zip containing `metadata.json` (cleartext, so
290
+ name/version stay readable), `signature.json` (the encryption envelope)
291
+ and `package.enc` — the AES-256-GCM ciphertext of the inner plaintext
292
+ `.cap` zip (content, configuration files, data). A random 256-bit data
293
+ encryption key (DEK) encrypts the inner zip and is wrapped with the
294
+ recipient's public key using RSA-OAEP with SHA-256:
295
+
296
+ [source,json]
297
+ ----
298
+ {
299
+ "encryption": {
300
+ "algorithm": "AES-256-GCM",
301
+ "keyManagement": "RSA-OAEP-SHA256",
302
+ "encryptedDek": "<base64>",
303
+ "iv": "<base64>",
304
+ "authTag": "<base64>"
305
+ }
306
+ }
307
+ ----
308
+
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.
335
+
336
+ === Decrypting a package
337
+
338
+ [source,bash]
339
+ ----
340
+ capsium package decrypt encrypted.cap [--private-key private.pem | --key secret.asc] [--openpgp] [-o decrypted.cap]
341
+ ----
342
+
343
+ Decrypts an encrypted package with the recipient's private key and
344
+ writes the plaintext `.cap` (default output: `<name>-decrypted.cap`).
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.
353
+
354
+ === Testing a package
355
+
356
+ [source,bash]
357
+ ----
358
+ capsium package test path-to-package-or-cap
359
+ ----
360
+
361
+ Runs the package's test suite declared in the Capsium testing YAML DSL
362
+ (05x-testing): all `tests/*.yaml` files in the package, each holding a
363
+ top-level `tests` list. Every test has a `name` and a `type`; the four
364
+ supported types:
365
+
366
+ * `route` — requests the URL path from a reactor started for the run and
367
+ checks `expected_status`, plus optional `response_contains` (body
368
+ substring) and `expected_content_type`. Absolute URLs are accepted;
369
+ only their path (and query) is used.
370
+ * `file` — the file at `path` must exist in the package; optional
371
+ `contains` checks a content substring.
372
+ * `data_validation` — the rows of `data_file` (format `json` or `yaml`)
373
+ must validate against the JSON schema at `schema_file`. Array data
374
+ validates row by row.
375
+ * `config` — `config_file` (format `json` or `yaml`) must exist and
376
+ parse; the known package configs (`metadata.json`, `manifest.json`,
377
+ `routes.json`, `storage.json`, `security.json`) are additionally
378
+ validated against their canonical models.
379
+
380
+ Example:
381
+
382
+ [source,yaml]
383
+ ----
384
+ tests:
385
+ - name: Home Route Test
386
+ type: route
387
+ url: "/home"
388
+ expected_status: 200
389
+ response_contains: "Welcome"
390
+
391
+ - name: Config File Exists
392
+ type: file
393
+ path: "metadata.json"
394
+
395
+ - name: JSON Data Validation
396
+ type: data_validation
397
+ format: json
398
+ data_file: "data/animals.json"
399
+ schema_file: "data/animals.schema.json"
400
+
401
+ - name: Metadata Config Validation
402
+ type: config
403
+ format: json
404
+ config_file: "metadata.json"
155
405
  ----
156
406
 
407
+ Each test is reported as `PASS`/`FAIL` with messages; the command exits
408
+ with status 1 when any test fails (or a test definition is invalid).
409
+
157
410
  == CLI: Reactor
158
411
 
159
412
  === Starting a reactor on your package
160
413
 
161
414
  [source,bash]
162
415
  ----
163
- capsium reactor serve my_package.cap [--port 8864]
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"]
418
+ ----
419
+
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`)
423
+ resolves composite-package dependencies; `--deploy` (or
424
+ `CAPSIUM_DEPLOY`) points at the reactor-side `deploy.json` with
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]
164
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.
165
484
 
166
- The reactor takes a local package directory or `.cap` file; serving
167
- directly from a URL is not supported.
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`.
168
507
 
169
508
  === Reactor introspection API
170
509
 
@@ -189,6 +528,251 @@ content checksums — the same data `security.json` integrityChecks carry.
189
528
  `content-validity` re-verifies the package against `security.json` on
190
529
  every request and reports the outcome with a UTC `lastChecked`
191
530
  timestamp; `reason` lists the integrity errors when `valid` is false.
531
+ The entry also reports `signed` and `encrypted` status, plus
532
+ `signatureValid` when the package declares a signature.
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
+
557
+ == Layered storage
558
+
559
+ `storage.layers` stacks overlay directories over `content/` (bottom ->
560
+ top in declaration order), each mirroring the `content/` tree:
561
+
562
+ [source,json]
563
+ ----
564
+ { "storage": { "layers": [
565
+ { "path": "base", "writable": false, "visibility": "exported" },
566
+ { "path": "updates", "writable": true, "visibility": "private" }
567
+ ] } }
568
+ ----
569
+
570
+ A request resolves against layers from the TOP down; the first hit
571
+ wins. Deletions are recorded as tombstones: a `.capsium-tombstones`
572
+ JSON file (an array of `content/`-relative paths) in a writable layer;
573
+ a tombstoned path resolves 404 even when a lower layer holds the file,
574
+ while a file reappearing above the tombstone is served again. Packages
575
+ without a `layers` config behave exactly as before (single implicit
576
+ `content/` layer). `visibility: private` layers are hidden from
577
+ dependent packages (see below), as are resources whose manifest
578
+ visibility is `private`.
579
+
580
+ == Composite packages
581
+
582
+ `metadata.dependencies` maps a dependency GUID to a semver range
583
+ (`>=1.0.0`, `^1.2.3`, `~1.2.3`, exact, `*`, `1.x`/`1.2.x`, partials,
584
+ and comma/space conjunctions like `>=1.0.0, <2.0.0`). Dependencies
585
+ resolve against a *package store* — a directory of
586
+ `<name>-<version>.cap` files plus an optional `index.json` (GUID ->
587
+ file) — given via `CAPSIUM_STORE` or `--store`; the newest satisfying
588
+ version wins.
589
+
590
+ On load, each resolved dependency's exported content becomes read-only
591
+ layers below all of the dependent's own layers. Routes may address
592
+ dependency content explicitly (`"resource": "<dependency-guid>/content/app.js"`)
593
+ or declare route-inheritance attributes per 05x-routing: `remap`
594
+ (replaces the serving path), `responseRewrite` (`body`, `headers`),
595
+ `responseHeaders` (merged over served headers) and `requestHeaders`
596
+ (parsed and exposed for forwarding reactors; this reactor serves
597
+ statically, so they do not alter its responses). Referencing a
598
+ dependency's private or missing resource is a load-time error, as are
599
+ circular, missing or unsatisfiable dependencies.
600
+
601
+ [source,bash]
602
+ ----
603
+ capsium package info my-composite-package --store ./store
604
+ capsium reactor serve my-composite-package --store ./store
605
+ ----
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
+
719
+ == Authentication
720
+
721
+ `authentication.json` enables user authentication (05x-authentication):
722
+
723
+ [source,json]
724
+ ----
725
+ { "authentication": {
726
+ "basicAuth": { "enabled": true, "passwdFile": "auth/.htpasswd", "realm": "capsium" },
727
+ "oauth2": { "enabled": true, "provider": "google", "clientId": "...",
728
+ "authorizationUrl": "...", "tokenUrl": "...", "userinfoUrl": "...",
729
+ "redirectPath": "/auth/callback", "scopes": ["openid", "email"] }
730
+ } }
731
+ ----
732
+
733
+ When enabled, the reactor challenges every unauthenticated request
734
+ (`401`, with `WWW-Authenticate: Basic realm="..."` when basicAuth is
735
+ enabled) and verifies credentials against the htpasswd file. Supported
736
+ hashes: bcrypt (`htpasswd -B`, via the `bcrypt` gem), Apache apr1 MD5
737
+ and md5-crypt (`$apr1$`/`$1$`, pure Ruby), unsalted SHA-1 (`{SHA}`),
738
+ and a platform `crypt(3)` fallback for the rest.
739
+
740
+ With OAuth2, `/auth/login` redirects to the provider with HMAC-signed
741
+ state; the callback exchanges the code at `tokenUrl`, fetches the
742
+ userinfo claims and establishes an HMAC-SHA256 signed session cookie
743
+ (`capsium_session`, `HttpOnly; SameSite=Lax`). Provider errors during
744
+ the exchange answer `502`; a tampered state answers `401`.
745
+
746
+ Route-level `accessControl` on dataset routes is enforced after
747
+ authentication — `401` unauthenticated, `403` when the identity lacks a
748
+ required role:
749
+
750
+ [source,json]
751
+ ----
752
+ { "path": "/api/v1/data/animals", "dataset": "animals",
753
+ "accessControl": { "roles": ["admin"], "authenticationRequired": true } }
754
+ ----
755
+
756
+ Secrets NEVER come from the package. They live in `deploy.json`
757
+ (`--deploy` or `CAPSIUM_DEPLOY`):
758
+
759
+ [source,json]
760
+ ----
761
+ {
762
+ "baseUrl": "http://localhost:8864",
763
+ "authentication": {
764
+ "basicAuth": { "passwdFile": "/secure/outside/package/.htpasswd" },
765
+ "oauth2": { "clientSecret": "..." },
766
+ "sessionSecret": "...",
767
+ "roles": { "alice": ["admin"] }
768
+ }
769
+ }
770
+ ----
771
+
772
+ `roles` assigns roles by identity name (basic-auth username, OAuth2
773
+ email or subject); userinfo `roles` claims are honored too. Without a
774
+ configured `sessionSecret`, the reactor generates one and persists it
775
+ (mode `0600`) outside the package.
192
776
 
193
777
  == Programmatically managing packages
194
778
 
@@ -203,6 +787,11 @@ require 'capsium'
203
787
 
204
788
  # Read a package directory or a .cap file
205
789
  package = Capsium::Package.new(path)
790
+
791
+ # Read an encrypted .cap (raises Capsium::Package::Cipher::KeyRequiredError
792
+ # without a key, Capsium::Package::Cipher::DecryptionError for a wrong key
793
+ # or tampered ciphertext)
794
+ package = Capsium::Package.new('encrypted.cap', decryption_key: 'private.pem')
206
795
  ----
207
796
 
208
797
  When `security.json` is present, the package is verified on load and
@@ -231,6 +820,45 @@ puts animals.data.inspect
231
820
 
232
821
  # Verifying integrity (returns a list of typed errors, empty when valid)
233
822
  errors = package.verify_integrity
823
+
824
+ # Digital signatures (RSA-SHA256)
825
+ package.signed? # => security.json declares digitalSignatures
826
+ package.verify_signature # => true/false
827
+
828
+ signer = Capsium::Package::Signer.new(package_dir)
829
+ signer.sign('private.pem') # or sign('private.pem', 'cert.pem')
830
+ signer.verify # embedded public key
831
+ signer.verify('cert-or-public-key.pem') # explicit key
832
+ signer.verify! # raises SignatureMismatchError
833
+
834
+ # Whole-package encryption (AES-256-GCM, RSA-OAEP-SHA256 wrapped DEK)
835
+ cipher = Capsium::Package::Cipher.new
836
+ cipher.encrypt('pkg-1.0.0.cap', 'public.pem', 'encrypted.cap')
837
+ cipher.decrypt('encrypted.cap', 'private.pem', 'decrypted.cap')
838
+ Capsium::Package::Cipher.encrypted?('encrypted.cap') # => true
839
+
840
+ # Running the package's tests/*.yaml suite (05x-testing DSL)
841
+ report = Capsium::Package::Testing::TestSuite.new(package).run
842
+ report.ok? # => true/false
843
+ report.failures # => failed TestCase::Result list
844
+ report.summary # => "7 tests, 0 failures"
845
+
846
+ # Layered storage (5a): the merged overlay view shared with the reactor
847
+ view = package.merged_view
848
+ view.resolve('content/app.js') # => absolute path of the topmost hit, or nil
849
+
850
+ # Composite packages (4a): resolve metadata.dependencies against a store
851
+ package = Capsium::Package.new(dir, store: './store') # or CAPSIUM_STORE
852
+ package.resolved_dependencies.each do |dep|
853
+ puts "#{dep.guid} (#{dep.range}) => #{dep.version} [#{dep.path}]"
854
+ end
855
+ view = package.merged_view # own layers over dependency layers
856
+ view = package.merged_view(exported_only: true) # what dependents may see
857
+
858
+ # Serving with authentication and a store
859
+ reactor = Capsium::Reactor.new(package: dir, store: './store',
860
+ deploy: 'deploy.json')
861
+ reactor.serve
234
862
  ----
235
863
 
236
864
  === Packing and unpacking programmatically
@@ -293,6 +921,14 @@ bundle exec rubocop
293
921
  bundle exec rbs -I sig validate
294
922
  ----
295
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
+
296
932
  == License
297
933
 
298
934
  Copyright Ribose.