Capsium: Common architecture for portable secure information interchange and unified management.

Capsium is designed to facilitate the creation, management and deployment of content packages with ease.

This gem provides a structured way to handle content, data, and metadata for various applications.

Test it out!

$ wget https://github.com/capsiums/cap-story/releases/download/v0.9.0/story_of_claire-0.9.0.cap
$ capsium reactor serve story_of_claire-0.9.0.cap
$ open http://localhost:8864
# => Read the story!

Installation

To install the Capsium gem, add it to your Gemfile:

gem 'capsium'

Then, run the following command to install it:

bundle install

Alternatively, you can install the gem directly using:

gem install capsium

What is a Capsium package?

A Capsium package is a structured collection of content, data, metadata, and routing information, distributed as a single .cap file (a ZIP archive, MIME application/vnd.capsium.package). The canonical layout:

metadata.json     # REQUIRED, hand-authored
manifest.json     # auto-generated at load/pack time when absent
routes.json       # auto-generated when absent
storage.json      # optional (only when datasets exist)
security.json     # generated at pack time (SHA-256 checksums)
authentication.json # optional (user authentication)
content/          # served content root (URL space mirrors paths below it)
data/             # datasets (optional)

Paths inside the configuration files are package-relative POSIX paths.

  • metadata.json: name (kebab-case), version (semver), description, guid (URI) and uuid are required; author, license, repository, dependencies (an object of guid → semver range) are optional.

  • manifest.json: an object keyed by package-relative resource path, e.g. "content/index.html": { "type": "text/html", "visibility": "exported" }. Generated by scanning content/ (MIME types via marcel) when absent.

  • routes.json: an optional top-level index plus a routes array. Route kinds: {path, resource} for static files, {path, dataset} for datasets (served under /api/v1/data/<id>). HTML files get two routes (basename and full filename); the index HTML additionally gets /.

  • storage.json: dataset definitions under storage.dataSets, either schema-backed files (source, schemaFile, schemaType: json-schema) or SQLite (databaseFile, table). storage.layers declares overlay layers (see "Layered storage" below).

  • security.json: security.integrityChecks.checksums holds a SHA-256 hex digest for every file in the package except security.json itself and signature.sig. When the package is signed, security.digitalSignatures records { "publicKey", "signatureFile" }.

  • authentication.json: optional user authentication (see "Authentication" below): authentication.basicAuth and/or authentication.oauth2.

Legacy (pre-0.2) configuration files are still accepted on read and normalized to the canonical forms; writers emit only the canonical forms.

What is a Capsium reactor?

A Capsium reactor is a runtime environment that serves Capsium packages. It reads the package configuration and starts a server that can handle HTTP requests according to the routes defined in the package. The reactor verifies the package against security.json when present and rejects tampered packages. Dataset routes are served as JSON; static resources are served with Cache-Control: public, max-age=31536000 (configurable). While serving, the reactor also answers the introspection endpoints described below.

CLI: Package

Capsium provides a CLI to help you pack and manage your packages.

Packing a package

capsium package pack [--force/-f] path-to-package

This command generates manifest.json/routes.json when absent, writes security.json with SHA-256 checksums of every package file, and packs the directory into {package-name}-{package-version}.cap (name and version from metadata.json).

Example 1. Sample pack command
capsium package pack -f spec/fixtures/bare-package

Unpacking a package

capsium package unpack bare-package-0.1.0.cap [-o/--output my_bare_package]

When -o is omitted, the package is unpacked into a directory named after the .cap file.

Extraction is zip-slip safe: entries whose names would escape the destination (absolute paths, drive letters, .. segments) are rejected with Capsium::Packager::UnsafeEntryError.

Validating a package

capsium package validate path-to-package-or-cap

Runs a per-check report and exits with status 1 on any failure:

  • metadata: required fields present and well-formed (kebab-case name, semver version, URI guid, UUID)

  • manifest: every resource exists on disk

  • routes: route targets exist; dataset routes live under /api/v1/data/; the index resolves to an existing HTML file

  • storage: dataset sources (and schemas) exist; dataset data passes JSON-schema validation

  • security: checksums match when security.json is present

  • content: no external http(s) references in content/ files

Inspecting a package

capsium package info|manifest|routes|metadata|storage path-to-package [--store DIR]

info additionally prints the resolved dependency tree for composite packages (declared GUID and range, resolved version, store .cap), see "Composite packages" below.

Signing a package

capsium package sign path-to-package-or-cap --key private.pem [--cert cert.pem]

Signs the package with an RSA private key (minimum 2048 bits) using RSA-SHA256, per the packaging standard’s digital signature clause. Signing is a post-pack step: pack drops signing artifacts, so sign the packed directory or the .cap file itself.

The signed payload is the concatenation, in sorted package-relative path order, of the raw bytes of every file covered by the security.json integrity checksums — i.e. every package file except security.json and signature.sig. The signature over that payload is stored as raw bytes in signature.sig, the signer’s public key PEM is embedded as signature.pub.pem, and security.json records:

{
  "security": {
    "digitalSignatures": {
      "publicKey": "signature.pub.pem",
      "signatureFile": "signature.sig"
    }
  }
}

When --cert is given, the X.509 certificate must match the private key, and the certificate’s public key is embedded instead.

The payload construction is openssl-compatible; to verify a signed package independently, concatenate the checksum-covered files in sorted order and run:

openssl dgst -sha256 -verify signature.pub.pem -signature signature.sig payload.bin

Verifying a package signature

capsium package verify-signature path-to-package-or-cap [--cert cert-or-public-key.pem]

Verifies the declared signature against the package contents (using the embedded public key, or the given certificate/public key) and exits with status 1 when the package is unsigned or the signature does not match. Packages that declare a signature are also verified automatically on load — a mismatch raises Capsium::Package::Signer::SignatureMismatchError, so a tampered signed package cannot be served by the reactor.

Encrypting a package

capsium package encrypt path-to-package-or-cap --public-key public.pem -o encrypted.cap

Encrypts the whole package for the recipient’s RSA public key (or X.509 certificate), per the packaging standard’s encryption clause: the encrypted .cap is a zip containing metadata.json (cleartext, so name/version stay readable), signature.json (the encryption envelope) and package.enc — the AES-256-GCM ciphertext of the inner plaintext .cap zip (content, configuration files, data). A random 256-bit data encryption key (DEK) encrypts the inner zip and is wrapped with the recipient’s public key using RSA-OAEP with SHA-256:

{
  "encryption": {
    "algorithm": "AES-256-GCM",
    "keyManagement": "RSA-OAEP-SHA256",
    "encryptedDek": "<base64>",
    "iv": "<base64>",
    "authTag": "<base64>"
  }
}

The OCB/OpenPGP alternatives mentioned by the standard are intentionally not implemented.

Decrypting a package

capsium package decrypt encrypted.cap --private-key private.pem [-o decrypted.cap]

Decrypts an encrypted package with the recipient’s RSA private key and writes the plaintext .cap (default output: <name>-decrypted.cap). Decryption fails with a typed error when the key does not match or the ciphertext was tampered with (GCM authentication). Loading an encrypted package without a key raises Capsium::Package::Cipher::KeyRequiredError — the reactor refuses to serve it.

Testing a package

capsium package test path-to-package-or-cap

Runs the package’s test suite declared in the Capsium testing YAML DSL (05x-testing): all tests/*.yaml files in the package, each holding a top-level tests list. Every test has a name and a type; the four supported types:

  • route — requests the URL path from a reactor started for the run and checks expected_status, plus optional response_contains (body substring) and expected_content_type. Absolute URLs are accepted; only their path (and query) is used.

  • file — the file at path must exist in the package; optional contains checks a content substring.

  • data_validation — the rows of data_file (format json or yaml) must validate against the JSON schema at schema_file. Array data validates row by row.

  • configconfig_file (format json or yaml) must exist and parse; the known package configs (metadata.json, manifest.json, routes.json, storage.json, security.json) are additionally validated against their canonical models.

Example:

tests:
  - name: Home Route Test
    type: route
    url: "/home"
    expected_status: 200
    response_contains: "Welcome"

  - name: Config File Exists
    type: file
    path: "metadata.json"

  - name: JSON Data Validation
    type: data_validation
    format: json
    data_file: "data/animals.json"
    schema_file: "data/animals.schema.json"

  - name: Metadata Config Validation
    type: config
    format: json
    config_file: "metadata.json"

Each test is reported as PASS/FAIL with messages; the command exits with status 1 when any test fails (or a test definition is invalid).

CLI: Reactor

Starting a reactor on your package

capsium reactor serve my_package.cap [--port 8864] [--store DIR] [--deploy deploy.json]

The reactor takes a local package directory or .cap file; serving directly from a URL is not supported. --store (or CAPSIUM_STORE) resolves composite-package dependencies; --deploy (or CAPSIUM_DEPLOY) points at the reactor-side deploy.json with authentication secrets (see "Authentication" below).

Reactor introspection API

While serving, the reactor answers the Monitoring HTTP API (ARCHITECTURE.md section 7) as application/json:

GET /api/v1/introspect/metadata         # => {"packages": [{"name", "version", "author", "description"}]}
GET /api/v1/introspect/routes           # => {"routes": [{"package", "routes": [{"method", "path"}]}]}
GET /api/v1/introspect/content-hashes   # => {"contentHashes": [{"package", "hash"}]}
GET /api/v1/introspect/content-validity # => {"contentValidity": [{"package", "valid", "lastChecked", "reason"?}]}

Non-GET methods on these paths are answered 405 Method Not Allowed.

content-hashes is the SHA-256 of the .cap blob when the package was served from one. For a directory source there is no blob, so the hash covers the canonical (sorted-key) JSON serialization of the package content checksums — the same data security.json integrityChecks carry.

content-validity re-verifies the package against security.json on every request and reports the outcome with a UTC lastChecked timestamp; reason lists the integrity errors when valid is false. The entry also reports signed and encrypted status, plus signatureValid when the package declares a signature.

Layered storage

storage.layers stacks overlay directories over content/ (bottom → top in declaration order), each mirroring the content/ tree:

{ "storage": { "layers": [
    { "path": "base",    "writable": false, "visibility": "exported" },
    { "path": "updates", "writable": true,  "visibility": "private" }
] } }

A request resolves against layers from the TOP down; the first hit wins. Deletions are recorded as tombstones: a .capsium-tombstones JSON file (an array of content/-relative paths) in a writable layer; a tombstoned path resolves 404 even when a lower layer holds the file, while a file reappearing above the tombstone is served again. Packages without a layers config behave exactly as before (single implicit content/ layer). visibility: private layers are hidden from dependent packages (see below), as are resources whose manifest visibility is private.

Composite packages

metadata.dependencies maps a dependency GUID to a semver range (>=1.0.0, ^1.2.3, ~1.2.3, exact, , 1.x/1.2.x, partials, and comma/space conjunctions like >=1.0.0, <2.0.0). Dependencies resolve against a *package store — a directory of <name>-<version>.cap files plus an optional index.json (GUID → file) — given via CAPSIUM_STORE or --store; the newest satisfying version wins.

On load, each resolved dependency’s exported content becomes read-only layers below all of the dependent’s own layers. Routes may address dependency content explicitly ("resource": "<dependency-guid>/content/app.js") or declare route-inheritance attributes per 05x-routing: remap (replaces the serving path), responseRewrite (body, headers), responseHeaders (merged over served headers) and requestHeaders (parsed and exposed for forwarding reactors; this reactor serves statically, so they do not alter its responses). Referencing a dependency’s private or missing resource is a load-time error, as are circular, missing or unsatisfiable dependencies.

capsium package info my-composite-package --store ./store
capsium reactor serve my-composite-package --store ./store

Authentication

authentication.json enables user authentication (05x-authentication):

{ "authentication": {
    "basicAuth": { "enabled": true, "passwdFile": "auth/.htpasswd", "realm": "capsium" },
    "oauth2": { "enabled": true, "provider": "google", "clientId": "...",
                "authorizationUrl": "...", "tokenUrl": "...", "userinfoUrl": "...",
                "redirectPath": "/auth/callback", "scopes": ["openid", "email"] }
} }

When enabled, the reactor challenges every unauthenticated request (401, with WWW-Authenticate: Basic realm="…​" when basicAuth is enabled) and verifies credentials against the htpasswd file. Supported hashes: bcrypt (htpasswd -B, via the bcrypt gem), Apache apr1 MD5 and md5-crypt ($apr1$/$1$, pure Ruby), unsalted SHA-1 ({SHA}), and a platform crypt(3) fallback for the rest.

With OAuth2, /auth/login redirects to the provider with HMAC-signed state; the callback exchanges the code at tokenUrl, fetches the userinfo claims and establishes an HMAC-SHA256 signed session cookie (capsium_session, HttpOnly; SameSite=Lax). Provider errors during the exchange answer 502; a tampered state answers 401.

Route-level accessControl on dataset routes is enforced after authentication — 401 unauthenticated, 403 when the identity lacks a required role:

{ "path": "/api/v1/data/animals", "dataset": "animals",
  "accessControl": { "roles": ["admin"], "authenticationRequired": true } }

Secrets NEVER come from the package. They live in deploy.json (--deploy or CAPSIUM_DEPLOY):

{
  "baseUrl": "http://localhost:8864",
  "authentication": {
    "basicAuth": { "passwdFile": "/secure/outside/package/.htpasswd" },
    "oauth2": { "clientSecret": "..." },
    "sessionSecret": "...",
    "roles": { "alice": ["admin"] }
  }
}

roles assigns roles by identity name (basic-auth username, OAuth2 email or subject); userinfo roles claims are honored too. Without a configured sessionSecret, the reactor generates one and persists it (mode 0600) outside the package.

Programmatically managing packages

The public API below ships with RBS signatures in sig/ (bundle exec rbs -I sig validate).

Loading packages

require 'capsium'

# Read a package directory or a .cap file
package = Capsium::Package.new(path)

# Read an encrypted .cap (raises Capsium::Package::Cipher::KeyRequiredError
# without a key, Capsium::Package::Cipher::DecryptionError for a wrong key
# or tampered ciphertext)
package = Capsium::Package.new('encrypted.cap', decryption_key: 'private.pem')

When security.json is present, the package is verified on load and Capsium::Package::Security::IntegrityError is raised on mismatch.

Using packages in your program

# Accessing package metadata
puts "Package Name: #{package..name}"
puts "Package Version: #{package..version}"

# Accessing manifest resources
package.manifest.resources.each do |path, resource|
  puts "Resource: #{path}, Type: #{resource.type}"
end

# Accessing routes
route = package.routes.resolve('/')
puts route.resource

# Accessing datasets
animals = package.storage.dataset('animals')
puts animals.data.inspect

# Verifying integrity (returns a list of typed errors, empty when valid)
errors = package.verify_integrity

# Digital signatures (RSA-SHA256)
package.signed?           # => security.json declares digitalSignatures
package.verify_signature  # => true/false

signer = Capsium::Package::Signer.new(package_dir)
signer.sign('private.pem')                    # or sign('private.pem', 'cert.pem')
signer.verify                                 # embedded public key
signer.verify('cert-or-public-key.pem')       # explicit key
signer.verify!                                # raises SignatureMismatchError

# Whole-package encryption (AES-256-GCM, RSA-OAEP-SHA256 wrapped DEK)
cipher = Capsium::Package::Cipher.new
cipher.encrypt('pkg-1.0.0.cap', 'public.pem', 'encrypted.cap')
cipher.decrypt('encrypted.cap', 'private.pem', 'decrypted.cap')
Capsium::Package::Cipher.encrypted?('encrypted.cap')  # => true

# Running the package's tests/*.yaml suite (05x-testing DSL)
report = Capsium::Package::Testing::TestSuite.new(package).run
report.ok?       # => true/false
report.failures  # => failed TestCase::Result list
report.summary   # => "7 tests, 0 failures"

# Layered storage (5a): the merged overlay view shared with the reactor
view = package.merged_view
view.resolve('content/app.js')   # => absolute path of the topmost hit, or nil

# Composite packages (4a): resolve metadata.dependencies against a store
package = Capsium::Package.new(dir, store: './store')  # or CAPSIUM_STORE
package.resolved_dependencies.each do |dep|
  puts "#{dep.guid} (#{dep.range}) => #{dep.version} [#{dep.path}]"
end
view = package.merged_view                # own layers over dependency layers
view = package.merged_view(exported_only: true)  # what dependents may see

# Serving with authentication and a store
reactor = Capsium::Reactor.new(package: dir, store: './store',
                              deploy: 'deploy.json')
reactor.serve

Packing and unpacking programmatically

packager = Capsium::Packager.new
cap_file = packager.pack(package, force: true)
packager.unpack(cap_file, 'output-directory')

Building the mn-samples-iso cap package

Download the mn-samples-iso built site: mn-samples-iso-Linux.

Then run these commands:

$ unzip mn-samples-iso-Linux.zip
$ cd mn-samples-iso-Linux
$ mkdir content
$ mv index.html documents.xml documents content
$ cat > metadata.json <<'JSON'
{
  "name": "mn-samples-iso",
  "version": "0.1.0",
  "description": "Metanorma ISO sample documents",
  "guid": "https://github.com/metanorma/mn-samples-iso",
  "uuid": "123e4567-e89b-12d3-a456-426614174000"
}
JSON
$ cd ..
$ bundle exec capsium package pack -f mn-samples-iso-Linux
Package created: mn-samples-iso-0.1.0.cap
$ bundle exec capsium reactor serve mn-samples-iso-0.1.0.cap
Starting server on http://localhost:8864
...

Contributing

We welcome contributions to the Capsium gem. If you would like to contribute, please fork the repository and submit a pull request.

Running tests

To run the tests, use the following command:

bundle exec rspec

The linter and the RBS signature validation are part of the default rake task (bundle exec rake), or run them individually:

bundle exec rubocop
bundle exec rbs -I sig validate

License

Copyright Ribose.

Capsium is released under the MIT License. See the LICENSE file for more details.