Skip to main content
Wintertrace
Browse the documentation

Building modules

Module signing & distribution

How modules are signed with libsodium and distributed as verifiable packages, and what that means when you publish your own.

Modules are not pulled openly from a repository — they are distributed as signed packages through a catalog server. The application verifies every package against a trust list before installing it, so you can confirm that a module genuinely comes from an authorised key and was not altered in transit.

How modules are distributed

Distribution runs through a catalog server. The application’s update system handles fetching, verifying, installing, and updating modules — you trigger the process; the individual steps run automatically.

Configure the catalog server in config/schneespur_modules.php:

'server_url'       => 'https://jenni.noschmarrn.dev',
'collection_slug'  => 'schneespur-module',
'catalog_endpoint' => '/api/modules/{slug}',

The sync process

The command schneespur:modules-sync reconciles your installation with the catalog. It runs these steps:

  1. Fetch catalog — HTTP GET on the catalog_endpoint, with ETag caching
  2. Verify signatures — the catalog is signed with libsodium
  3. Compare versions — installed versions against those offered in the catalog
  4. Download updates — ZIP files for new or updated modules
  5. Verify ZIP integrity — signature check on the downloaded file
  6. Install/update — extract to the modules/ directory
  7. Run migrationsartisan migrate --path=...
  8. Detect orphans — modules present locally but removed from the catalog

The ordering here is deliberate: signatures are verified before extraction. A package that does not pass the check is never written to disk.

Use --dry-run to see what would happen without making any changes — useful before a real sync to review which modules are due for an update:

php artisan schneespur:modules-sync --dry-run

Signature verification

For a package to be installed, its signature must match a key the installation trusts. That trust is not hardwired — it is structured as a chain, from the one root key in your configuration down to the individual module ZIP.

The trust chain

Root public key (in config)
  → fetches trust list from server (/api/signing/trust)
    → trust list contains valid signing keys + revoked keys
      → module ZIPs/catalogs are signed with a valid key

The root key is the only trust anchor stored locally. Everything else — which signing keys are currently valid and which have been revoked — comes from the signed trust list served by the catalog server. This means a key can be revoked without any change to your local configuration.

ModuleSignatureVerifier

The ModuleSignatureVerifier service runs the check in this order:

  1. Fetch the trust list from the server
  2. Validate the trust list signature against the root_pubkey
  3. Check for revoked keys
  4. Verify module manifest and ZIP signatures against the valid keys
  5. Report trust level: unsigned, signed, verified, or failed

The reported level makes the state of a package legible: verified means a valid signature from a trusted key; failed means the check did not pass — such a package is not installed.

Root public key

The root key is stored as a base64-encoded libsodium public key in the configuration:

'root_pubkey_b64' => 'bbYkDrjwTapdcONvnhB3tfcwe0aA+lAcgnd0dLMlkmg=',

This is the same key used by the application’s own update system — modules and application updates share a single trust anchor.

Module installation

SchneespurModuleInstaller

Extraction is handled by the SchneespurModuleInstaller. It does not treat a ZIP archive as inherently trustworthy — it applies several safeguards:

  • Path traversal prevention — rejects entries containing .. or a leading /
  • macOS metadata filtering — skips __MACOSX/ directories
  • Auto-prefix detection — strips a common top-level directory (a typical artefact of ZIP exports)
  • Atomic updates — backs up the current version before extracting and rolls back on failure

The path traversal check is not incidental: it prevents an archive entry from writing outside the module directory. Every entry is therefore validated for path safety before it is written.

Installation flow

install(zipPath, slug)
  1. Extract ZIP to modules/{slug}/
  2. Detect and strip common prefix directory
  3. Validate all entries for path safety
  4. Write files to disk

update(zipPath, slug)
  1. Backup current module to modules/{slug}.bak/
  2. Extract new version
  3. On failure: rollback from .bak

remove(slug)
  1. Delete modules/{slug}/

The backup step in an update is why a failed update does not damage a running installation: if extraction fails, the installer restores the backed-up version from .bak.

Module state file

The application tracks which modules are installed and what trust state is current in storage/app/schneespur_modules_state.json:

{
    "catalog_etag": "\"abc123\"",
    "catalog_cache": [...],
    "synced_at": "2026-05-26T10:00:00Z",
    "installed": ["telegram", "documents"],
    "orphans": [],
    "trust_version": 1,
    "valid_keys": ["key1_b64", "key2_b64"],
    "revoked_keys": [],
    "trust_expires_at": "2026-12-31T23:59:59Z"
}

This file is written atomically (temporary file plus rename) so that an interrupted write cannot corrupt it. The ETag avoids a full re-download on the next sync when the catalog has not changed.

Removing a module

Remove a module with schneespur:modules-remove:

php artisan schneespur:modules-remove telegram
php artisan schneespur:modules-remove telegram --force

The command works through these steps:

  1. Disable the module (checks reverse dependencies)
  2. Roll back module migrations
  3. Clean up settings (Setting::where('key', 'like', '{slug}.%')->delete())
  4. Delete the module directory
  5. Remove the public symlink

The reverse-dependency check prevents you from removing a module that another module still depends on. --force skips this check — use it only when you know the consequences.

For module publishers

If you are publishing a module for the catalog, your package must follow a fixed structure so that the installer and signature verification can process it correctly.

ZIP structure

my-module/
  module.json
  src/
    MyModuleServiceProvider.php
  resources/views/
  database/migrations/
  lang/
  dist/

Requirements

  1. Valid module.json with all required fields
  2. ServiceProvider must extend Illuminate\Support\ServiceProvider
  3. Namespace must match the declaration in module.json
  4. Table names must carry the prefix mod_{slug}_
  5. No modifications to core files — extend only through the registries
  6. Both de and en translations are recommended

The requirement to leave core files untouched is the foundation of the module system: modules extend the core through the registries rather than rewriting it. This keeps application updates conflict-free. How these extension points work is covered in ServiceProvider and Registries.

Version numbering

Follow semantic versioning MAJOR.MINOR.PATCH:

  • MAJOR — breaking changes (renamed settings, removed features)
  • MINOR — new features, backward-compatible
  • PATCH — bug fixes, no new features

Clean version numbers are not an end in themselves: the sync process uses the version comparison to decide whether to offer an update. Shipping a breaking change as a PATCH update surprises every installation that syncs automatically.

Local development without a catalog server

During development, you do not need signatures or a catalog server. Place your module directly in modules/:

modules/my-module/
  module.json
  src/
  ...

Enable it through the module management page in the admin interface, or set default_enabled: true in module.json. How to structure a module from scratch is in the Quick start; a complete runnable example ships with the application as the example module.