/api/healthLiveness and database probe. Returns 200 {"status": "ok", "version": "...", "db": "ok"}, or 503 {"status": "degraded", ...} if the DB probe fails.
Everything you'd want to read before running Jenni in production: every endpoint and what it returns (the public ones, the Ed25519 signing & trust-list endpoints, and the authenticated author API), the upload pipeline and why each step is there, the tech stack, the sizing, what she deliberately doesn't do, and where she fits next to GitHub Releases, WordPress.org, and a plain S3 bucket. For the full client-verification protocol see the trust page.
A self-hosted download server for versioned software releases — as ZIP, with a changelog, a virus-scan badge, anonymous statistics, and a JSON API. Single admin, small, fast, transparent.
She runs in production at jenni.noschmarrn.dev, ships every release of every project in this stack since the start of 2026, and is the source of the JSON your apps poll when they ask "is there a new version?".
| Audience | Why Jenni |
|---|---|
| Solo developers / indie makers | Ship under your own domain without GitHub Pages contortions, without a platform that owns your URLs. |
| App vendors with auto-update | Your app pins one 32-byte root pubkey, polls /api/signing/trust + /api/projects/<slug>/manifest daily, verifies the Ed25519 signature, checks the rollback counter — and is structurally safe against a server compromise. |
| WordPress plugin authors | Distribute beta or pro versions outside wp.org — and, with the SVN module, prepare and commit your wp.org releases (including the separate banner / icon / screenshot asset workflow) in the same pipeline. |
| Vendors with a module ecosystem | Your application loads a JSON catalog (/api/modules/<collection>) and shows users which extensions are available, with version, hash, icon, and category. |
| Portals / marketplace operators | Third parties submit modules through an authenticated REST API (collection-bound tokens, least privilege); you review and approve. Optional self-service frontend via the upcoming JenniHUB. |
| Small companies and clubs | Internal tools, training ZIPs, templates — admin login for the operator, public direct URLs for end users, audit log for everyone in between. |
| Self-hosters | Single-binary character, SQLite, no external dependencies beyond Python and Docker. Backup = copy two folders. |
Not for: multi-user SaaS, paid-download marketplaces with checkout, or massive multi-GB binaries. Multi-GB releases work but are not the sweet spot.
| Feature deliberately omitted | Reason |
|---|---|
| Multi-user / teams | Single-admin by design. If you need several co-maintainers, this is the wrong tool. |
| E-mail flow / "forgot password" | Password reset runs through the host shell (docker compose exec download python -m app.cli reset-password <user>). No SMTP setup, no phishing surface. A lost 2FA device is covered by 10 single-use recovery codes, with the CLI as the final fallback. |
| OAuth / SSO / multi-user accounts | Single-admin with TOTP-2FA. Third-party API access uses collection-bound tokens (least privilege), not full user accounts. |
| Frontend build step (React / Vue / …) | Server-rendered Jinja2. No webpack, no node_modules, no build pipeline. The container stays small (~620 MB including the PHP+SVN toolchain). |
| IP logging / user tracking | The downloads table stores only a timestamp and a release foreign key. No IP hash. No user-agent. GDPR-strict by construction. |
| DRM / paid-download gates | Out of scope. If you want paid downloads, put Jenni behind an auth proxy or a CDN layer. |
| Self-update for the app itself | Updates run through git pull + docker compose up --build. Single-admin hygiene, not a platform. |
Each of these tools fits a different shape of project. Jenni is the answer when you want one URL per software under your own domain, automated update endpoints across multiple platforms (not only WordPress), and the freedom to add module catalogs or a wp.org bridge without gluing four services together. The other tools win on platform discoverability or built-in edge caching — pick the one that matches what you actually need.
| Capability | Jenni | GitHub Releases | WordPress.org | S3 + CDN |
|---|---|---|---|---|
| One URL per software, under your own domain | Yes | — (lives at github.com) | — (lives at wp.org) | Yes |
| Discoverability via the host platform | — | Yes (GitHub network) | Yes (wp.org plugin directory) | — |
| Versioned permalinks | Atomic symlink switch | Git tags + assets | SVN tags | Self-managed |
| JSON update endpoint for your own apps | Yes (/api/projects/<slug>/latest) |
Yes (Releases API) | Yes (WP plugin update API) | — |
| Virus scan on upload | Yes (ClamAV per upload, status in API) | — | Manual review only | — |
| Cryptographic signing of releases | Yes (Ed25519 manifests, rollback counter, offline root key) | Optional (gh attestation; not widely used) |
— | — |
| Revocable signing keys without client update | Yes (signed trust.json with valid_keys + revoked_keys) |
— | — | — |
| Anonymous download statistics (no IP, no UA) | Yes (GDPR-strict) | Aggregate only | Aggregate only | Provider-dependent |
| Embeddable widget for external sites | Yes (themed SVG) | — | — | — |
| Module catalog for app plug-in ecosystems | Yes (/api/modules/<collection>) |
Partial (org / repo listings) | WordPress only | — |
| WordPress wp.org SVN bridge | Yes (optional, atomic trunk + tags/ commit) |
— | Native | — |
| Edge caching globally | Reverse-proxy dependent | Built-in | Built-in | Built-in |
| Auto-update prompts in host ecosystem | — (your app polls) | — (your app polls) | Yes (wp-admin) | — |
| Cost model | Hosting only (~5 €/mo VPS) | Free (with LFS limits) | Free | Per-GB egress |
| Vendor lock-in | None (own VPS, own DB) | GitHub-hosted URLs | wp.org rules + queue | Provider |
| Type | Public URL | Notable behaviour |
|---|---|---|
standard |
/<slug> → ZIP |
The default. Any ZIP, any version sequence. Auto-detects version from filename or readme.txt. |
wp_plugin |
/<slug> → ZIP |
Adds a per-plugin settings panel for the wp.org SVN bridge. Optional pre-flight (PHPCompatibility, plugin-check, PHPCS), atomic trunk + tags/<version> commit as one revision, diff preview before push, optional initial pull from an existing wp.org plugin. |
module_collection |
/<slug> → 302 → JSON catalog |
A container, not a release. Lists its child modules through /api/modules/<slug> with ETag caching. ZIP uploads to a collection are rejected. |
module |
/<slug> → ZIP, /<slug>/icon → image |
Child of a collection. Activation requires category, image, and an active release. Cascade-protected: a collection cannot be deleted while modules still reference it. |
All four types share the same release table, the same scan pipeline, the same authentication, the same statistics, and the same audit log. The difference is in the public URL shape and the activation rules.
| Status | Visible to | Listed in /api/projects | Reachable via direct URL |
|---|---|---|---|
draft | Admin only | No | No |
public | Everyone | Yes | Yes |
hidden | Only by exact URL | No | Yes |
disabled | No one | No | 410 Gone |
Status changes are recorded in the audit log. Releases inside any project are soft-deleted — they leave the active release set but stay in the database, recoverable from the admin UI for as long as you want.
clean / infected / error / unscanned) is part of the public API and the info page.symlink swap. There is no read/write race on the active release.argon2-cffi).HKDF(SECRET_KEY)→Fernet. Login is constant-time — a dummy verify runs on the user-not-found path so timing can't enumerate accounts.HttpOnly, Secure, SameSite=Lax, signed via itsdangerous.--proxy-headers.10001, never root.read_only: true root filesystem.cap_drop: [ALL].no-new-privileges security option.data/ and downloads/.
rescan-all is a CLI command intended to run nightly. It re-checks every existing release against the current ClamAV signature set, so a release that was clean on day one but matches a signature added later flips to infected in the public API the next morning.
Want the dated, per-phase ledger? The security changelog lists every hardening choice with the deploy date it went live on production — from Argon2id and ZIP-slip in the MVP, through the wp.org SVN bridge and ClamAV strict mode, to Ed25519 manifests and the offline trust anchor. Same machine, no marketing prose.
| Concern | Choice |
|---|---|
| Language | Python 3.12 |
| Web framework | FastAPI + Uvicorn (ASGI) |
| Database | SQLite (WAL mode, foreign-key constraints active) |
| Templates | Jinja2 (server-rendered, no JS framework) |
| Password hashing | Argon2id via argon2-cffi |
| Sessions | Signed cookies via itsdangerous |
| Markdown | markdown-it-py + nh3 HTML sanitiser |
| Image validation (module icons) | Pillow magic-byte check + nh3 SVG sanitise |
| Cryptography | cryptography — Ed25519 sign / verify for manifests and trust lists; Fernet (AES-128-CBC + HMAC) over Argon2id-stretched bytes for encrypted-at-rest signing privkeys and SVN credentials; HKDF over SECRET_KEY for the 2FA secret key |
| Two-factor auth | pyotp (TOTP) + qrcode[pil] (enrolment QR PNG); secret encrypted at rest via HKDF→Fernet |
| HTTP client | httpx (author-API spool and internal calls) |
| Virus scanning | ClamAV daemon, socket-mounted into the container |
| Deployment | Docker Compose behind a reverse proxy (Caddy / nginx / Traefik) |
Optional toolchain (for wp_plugin projects) | Subversion, PHP, Composer, WP-CLI, PHPCS / PHPCBF |
| Frontend build | None. Zero node_modules, zero JS framework. |
| Minimum host | 1 vCPU, 1 GB RAM. |
| Recommended host | 1 vCPU, 2 GB RAM (gives ClamAV room to breathe). |
| Image size | ~620 MB, including the PHP / SVN toolchain for wp_plugin projects. |
| Resident memory in steady state | well under 200 MB. |
| Reverse proxy (tested) | Caddy 2.x with ACME — example config in the repo. |
| Backup | Two directories on the server (data/app.db via sqlite3 .backup, downloads/ via rsync or restic) plus the offline root privkey on two physically separated media (passphrase stored separately). Losing the root privkey is the doomsday scenario for auto-update clients — back it up like you mean it. |
| Updates | git pull from the dev host, then docker compose up -d --build. Migrations run idempotently at lifespan-start. |
| Migrations style | Additive-only. Schema columns are never destructively renamed. |
| Initial trust-layer setup | Operator runs tools/trust-tool/trust_tool.py keygen on a laptop, writes the resulting pubkey into the server's TRUST_ROOT_PUBKEY environment variable, then signs and uploads the initial trust.json with trust-tool sign --initial --upload …. Full walkthrough in tools/trust-tool/README.md. |
| Live reference instance | jenni.noschmarrn.dev — production, currently on the Phase C build: trust layer at trust_version=1, TOTP-2FA, DE/EN content, and the authenticated author API all live. |
All endpoints return UTF-8 JSON unless noted otherwise. Timestamps are ISO-8601. Hashes are hex-encoded SHA-256. Base URL throughout: https://jenni.download.
/api/healthLiveness and database probe. Returns 200 {"status": "ok", "version": "...", "db": "ok"}, or 503 {"status": "degraded", ...} if the DB probe fails.
/api/projectsLists every project with status public. One object per project.
[
{
"slug": "breznflow",
"name": "BreznFlow",
"description": "Short description",
"current_version": "1.2.3",
"size_bytes": 184320,
"sha256": "abc123…",
"uploaded_at": "2026-05-09T11:23:00",
"download_url": "https://jenni.download/breznflow"
}
]
/api/projects/<slug>Project detail. Returns also for hidden projects (only when accessed by exact slug). Adds full changelog (Markdown source + sanitised HTML) and scanner metadata.
{
"slug": "breznflow", "name": "BreznFlow",
"current_version": "1.2.3",
"size_bytes": 184320,
"sha256": "abc123…",
"uploaded_at": "2026-05-09T11:23:00",
"download_url": "https://jenni.download/breznflow",
"changelog": "## 1.2.3\n- Fix: …",
"changelog_html": "<h2>1.2.3</h2><ul><li>Fix: …</li></ul>",
"scan_status": "clean",
"scanner_name": "clamav",
"scanner_version": "1.4.3",
"scanned_at": "2026-05-09T11:23:05",
"scanner_signature_date": "2026-05-09T08:00:00"
}
/api/projects/<slug>/latestThe auto-updater endpoint. Same fields as /<slug> but only the release-specific subset — version, size, hash, download URL, changelog, scan status. The minimum your app needs to decide "do I update?".
{
"version": "1.0.4",
"size_bytes": 107226,
"sha256": "d09e7e20442b789a4890beb2a8b2dd23a0adec62030d80a1828d533647eda13e",
"download_url": "https://jenni.download/breznflow",
"scan_status": "clean",
"scanned_at": "2026-05-09T02:03:20",
"scanner_name": "clamav",
"scanner_version": "1.4.3",
"scanner_signature_date": "2026-05-08T08:28:30"
}
/api/projects/<slug>/releasesAll non-deleted releases of a project, in upload order. Each entry carries version, size, hash, upload time, versioned download URL, changelog (raw + HTML), and scan fields.
/api/projects/<slug>/badge.svgTrust badge SVG for the active release ("Scanned with ClamAV …"). Cache-Control: public, max-age=300.
/api/projects/<slug>/releases/<version>/badge.svgTrust badge SVG for a specific historical version. Cache-Control: public, max-age=3600.
Endpoints that carry Ed25519 signatures over canonical JSON payloads. The signature is base64-encoded; the payload itself is the source of truth — recompute its canonical form on the client side, do not trust whitespace or key ordering on the wire. The full client-verification protocol is at jenni.download/trust.
/api/signing/trust
The signed trust list — the canonical pinning surface. Verify the signature against the root pubkey you pinned at install time. Cache: 5 min. ETag: "trust-v<N>", 304-capable on If-None-Match. X-Trust-Expired: true is set when expires_at < now() as a courtesy header; the client is responsible for the actual refusal. 404 if TRUST_ROOT_PUBKEY is not configured on the server.
{
"trust": {
"trust_version": 1,
"expires_at": "2028-05-12T20:29:04.108727Z",
"signed_at": "2026-05-13T20:29:04.108727Z",
"valid_keys": [
{
"key_id": "5af2613e6e92c56d",
"pubkey_b64": "mg6FbywzvR…",
"valid_from": "2026-05-13T20:29:04.108727Z"
}
],
"revoked_keys": []
},
"signature": "BASE64 — Ed25519 over canonical(trust) with root privkey"
}
/api/signing/pubkey
The currently active server signing pubkey. Sanity check only — never pin against this endpoint, because the server controls it. The canonical pinning surface is /api/signing/trust. Cache: 24 h.
{
"algorithm": "ed25519",
"pubkey_b64": "mg6FbywzvR…",
"key_id": "5af2613e6e92c56d",
"created_at": "2026-05-13T14:01:46"
}
/api/projects/<slug>/manifest
The signed manifest of the active public release. Cache: 60 s. ETag: "<key_id>-<counter>", 304-capable. Verify the signature using the pubkey from /api/signing/trust whose key_id matches the key_id in the manifest. Refuse the manifest if its counter is less than or equal to the largest counter you have already accepted for this project.
{
"manifest": {
"project": "breznflow",
"version": "1.2.3",
"sha256": "abc123…",
"size_bytes": 184320,
"counter": 7,
"signed_at": "2026-05-14T09:21:33.451Z",
"key_id": "5af2613e6e92c56d",
"url": "/breznflow/v/1.2.3"
},
"signature": "BASE64 — Ed25519 over canonical(manifest) with the signing privkey of key_id"
}
/api/projects/<slug>/manifest/<version>
The signed manifest for a historical version. Same shape as /manifest, but for a specific past release. Cache: 5 min. Useful for mirror verification and audit trails — the signature stays valid even after the release is no longer current.
/api/modules/<collection-slug>The module catalog. ETag-based caching, 60 s max-age; returns 304 Not Modified on a quiet request. Optional filters narrow the module list: ?type=, ?license=, ?capability=, ?min_app_version= (matched against each module's manifest).
Content is bilingual. Since Phase 9, name, description, category and tags are i18n objects ({"de": …, "en": …}). The catalog returns them raw — the consuming client picks the language. (HTML pages do the opposite: they resolve server-side via ?lang= → Accept-Language → primary locale.) Each module also carries its validated module.json as manifest (or null). website_url is a deprecated alias for author_url and is sent with Deprecation / Sunset headers.
{
"collection": {
"slug": "schneespur-modules",
"name": { "de": "Schneespur-Module", "en": "Wintertrace Modules" },
"description": { "de": "Erweiterungen für die Schneespur-App",
"en": "Add-ons for the Wintertrace app" },
"generated_at": "2026-05-27T11:23:00Z"
},
"modules": [
{
"slug": "schneespur-export",
"name": { "de": "Export-Modul", "en": "Export module" },
"description": { "de": "GPX/CSV-Export", "en": "GPX/CSV export" },
"primary_locale": "de",
"locales": ["de", "en"],
"current_version": "0.4.1",
"category": { "de": "Export", "en": "Export" },
"tags": [ { "de": "GPX", "en": "GPX" }, { "de": "CSV", "en": "CSV" } ],
"image_url": "https://jenni.download/schneespur-export/icon",
"author_name": "Acme GmbH",
"author_url": "https://example.com",
"download_url": "https://jenni.download/schneespur-export",
"info_url": "https://jenni.download/schneespur-export/info",
"sha256": "abc123…",
"size_bytes": 51200,
"released_at": "2026-05-08T09:00:00Z",
"manifest": {
"schema_version": 1,
"type": "plugin",
"license": "GPL-3.0-or-later",
"minimum_app_version": "2.0.0",
"capabilities": ["export"],
"dependencies": []
}
}
]
}
/api/schema/module-jsonThe public JSON Schema of the module.json author contract. Module authors validate their manifest locally against it; the server re-validates on submit and on every release upload. Author docs live in docs/module-json-spec.md in the repo.
For third parties who submit modules into one of your collections. Auth is a Bearer token bound to exactly one collection (Authorization: Bearer <token>); tokens are created, paused, and soft-revoked in the admin under /admin/projects/<collection>/api-tokens (every action step-up-TOTP-gated). Submissions land as draft — publication is the operator's call, or automatic per a collection's auto_publish flag. The scan and Ed25519-signing pipeline is identical to the admin UI: there is no second code path (see Phase B). Every write is scope-checked — a token can only touch modules in its collection, otherwise 403.
/api/auth/whoamiToken introspection: the bound collection and the token's scope. Used by JenniHUB and by authors sanity-checking their credentials.
/api/modules/<coll>/submitCreate a new module in the collection — i18n metadata plus a module.json. Lands as draft.
/api/modules/<coll>/<slug>Update module metadata (merge semantics — send only the fields you change).
/api/modules/<coll>/<slug>/imageSet or remove the module icon. Validated with the same Pillow magic-byte + nh3 SVG-sanitise pipeline as the admin UI.
/api/modules/<coll>/<slug>/screenshots[/<id>]Add or remove screenshots, each with an i18n caption.
/api/modules/<coll>/<slug>/releasesUpload a release ZIP (multipart). Runs the full ClamAV scan and, on activation, manifest signing — exactly as an admin upload would.
/api/modules/<coll>/<slug>/releases/<version>/activateActivate a previously uploaded release. Honours the collection's auto_publish flag.
These return application/zip, not JSON. Each download increments the anonymous counter.
/<slug>Active release ZIP. The most-quoted URL in the system.
/<slug>/v/<version>Specific historical version (unless soft-deleted). Same hashing and scanning guarantees as the active release.
/<collection-slug>For projects of type module_collection: 302 Found redirect to /api/modules/<collection-slug>. Collections are catalogs, not ZIPs.
/<slug>/widget.svgSelf-contained SVG widget. Themable via ?theme=light|dark, language-aware via Accept-Language + ?lang=de|en. Vary: Accept-Language, Cache-Control: public, max-age=300. CSP frame-ancestors: * (intended for embedding).
/<slug>/infoPublic HTML landing page for a project — metadata, changelog, size, hash, scan badge, manual download button. The non-API counterpart of the direct download URL.
/<slug>/v/<version>/infoSame shape, but for one specific historical version.
/<module-slug>/iconModule icon image. Strong sha256 ETag, Cache-Control: public, max-age=300. Pillow validates the magic bytes; nh3 sanitises any SVG.
trunk + tags/<version> commit as one revision, diff preview before push.assets/ path for banner, icon, screenshots — stage-and-batch pattern with diff preview.module_collection) and fourth (module), ETag-cached JSON catalog at /api/modules/<slug>, icon endpoint with sha256 ETag, cascade-protected deletion.tools/trust-tool/) signs trust.json with a root key that never touches the server. Signing-key rotation and revocation without client code updates. Trust-list expiry and replay protection. Full protocol →{"de": …, "en": …}) with a per-project locale list and primary locale. Public pages and the catalog resolve language independently of the admin chrome.session_epoch force-re-auth.module.json contract. A generic, server-validated author manifest format with a public JSON Schema at /api/schema/module-json.app/core/ use-cases — one validation and scan path, no self-HTTP.Caddyfile, security CHANGELOG.Jenni is currently a personal project run in production for the operator's own releases. An open-source release under MIT is planned, gated on the self-install story being good enough for someone who isn't the operator to stand her up in an afternoon.
For now: if you want to run her, talk to me. If you want to evaluate her without running her, the live reference is jenni.noschmarrn.dev and every API endpoint above is reachable there.
Questions, integration ideas, audit notes — info@noschmarrn.dev.