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:
- Fetch catalog — HTTP GET on the
catalog_endpoint, with ETag caching - Verify signatures — the catalog is signed with libsodium
- Compare versions — installed versions against those offered in the catalog
- Download updates — ZIP files for new or updated modules
- Verify ZIP integrity — signature check on the downloaded file
- Install/update — extract to the
modules/directory - Run migrations —
artisan migrate --path=... - 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:
- Fetch the trust list from the server
- Validate the trust list signature against the
root_pubkey - Check for revoked keys
- Verify module manifest and ZIP signatures against the valid keys
- Report trust level:
unsigned,signed,verified, orfailed
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:
- Disable the module (checks reverse dependencies)
- Roll back module migrations
- Clean up settings (
Setting::where('key', 'like', '{slug}.%')->delete()) - Delete the module directory
- 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
- Valid
module.jsonwith all required fields - ServiceProvider must extend
Illuminate\Support\ServiceProvider - Namespace must match the declaration in
module.json - Table names must carry the prefix
mod_{slug}_ - No modifications to core files — extend only through the registries
- Both
deandentranslations 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.