/specs API · stack · security · numbers

The dry version.

Everything you'd want to read before running Jenni in production: every public endpoint and what it returns, 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.

01 · summary

Jenni in one sentence.

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?".

02 · audience

Who Jenni is for.

AudienceWhy Jenni
Solo developers / indie makers Ship under your own domain without GitHub Pages contortions, without a platform that owns your URLs.
WordPress plugin authors Distribute beta or pro versions outside wp.org — and, with the SVN module, prepare and commit your wp.org releases in the same workflow.
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.
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.

03 · deliberately out

What Jenni doesn't do, and why.

Feature deliberately omittedReason
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.
Frontend build step (React / Vue / …) Server-rendered Jinja2. No webpack, no node_modules, no build pipeline. The container stays small (~617 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.
04 · compared to

Where Jenni fits next to other release tools.

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
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
05 · project types

Four shapes of project, one binary.

TypePublic URLNotable 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.

06 · status model

Four states a project can be in.

StatusVisible toListed in /api/projectsReachable via direct URL
draftAdmin onlyNoNo
publicEveryoneYesYes
hiddenOnly by exact URLNoYes
disabledNo oneNo410 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.

07 · security pipeline

What every upload goes through.

  1. Magic-byte sniff. The first bytes of the uploaded file must match the ZIP magic. Filename extension is not trusted.
  2. Zip-slip protection. Each entry's resolved path must stay inside the extraction root. Symlink and absolute-path entries are rejected.
  3. Zip-bomb cap. Total uncompressed size is capped at 500 MB by default. Configurable per-instance.
  4. ClamAV scan. Every upload is scanned against the local ClamAV daemon. Strict mode is the default: a scanner error aborts the upload. Scan status (clean / infected / error / unscanned) is part of the public API and the info page.
  5. SHA-256 hash. Computed once on disk, returned in the API, displayed on the info page, embeddable in widgets.
  6. Atomic activation. A successful upload writes to a versioned directory; activation is a single symlink swap. There is no read/write race on the active release.

Account and transport hardening

  • Argon2id for password hashing (argon2-cffi).
  • Login rate limit: 5 attempts per minute per IP, including successful logins (defends user enumeration).
  • Public route rate limit: 60 GET requests per minute per IP, in-memory sliding window.
  • CSRF tokens on every admin form.
  • Session cookies: HttpOnly, Secure, SameSite=Lax, signed via itsdangerous.
  • HTTPS-only behind a reverse proxy with --proxy-headers.
  • WordPress SVN credentials are encrypted at rest with Argon2id-stretched master-password + Fernet. A reset wipes all stored creds.

Container hardening

  • Runs as UID 10001, never root.
  • read_only: true root filesystem.
  • cap_drop: [ALL].
  • no-new-privileges security option.
  • Writable mounts only for data/ and downloads/.

Daily defence

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.

08 · tech stack

What's actually inside the binary.

ConcernChoice
LanguagePython 3.12
Web frameworkFastAPI + Uvicorn (ASGI)
DatabaseSQLite (WAL mode, foreign-key constraints active)
TemplatesJinja2 (server-rendered, no JS framework)
Password hashingArgon2id via argon2-cffi
SessionsSigned cookies via itsdangerous
Markdownmarkdown-it-py + nh3 HTML sanitiser
Image validation (module icons)Pillow magic-byte check + nh3 SVG sanitise
Virus scanningClamAV daemon, socket-mounted into the container
DeploymentDocker Compose behind a reverse proxy (Caddy / nginx / Traefik)
Optional toolchain (for wp_plugin projects)Subversion, PHP, Composer, WP-CLI, PHPCS / PHPCBF
Frontend buildNone. Zero node_modules, zero JS framework.
09 · operations

Sizing, deployment, backup.

Minimum host1 vCPU, 1 GB RAM.
Recommended host1 vCPU, 2 GB RAM (gives ClamAV room to breathe).
Image size~617 MB, including the PHP / SVN toolchain for wp_plugin projects.
Resident memory in steady statewell under 200 MB.
Reverse proxy (tested)Caddy 2.x with ACME — example config in the repo.
BackupTwo directories: data/app.db via sqlite3 .backup, and downloads/ via rsync or restic.
Updatesgit pull from the dev host, then docker compose up -d --build. Migrations run idempotently at lifespan-start.
Migrations styleAdditive-only. Schema columns are never destructively renamed.
Live reference instancejenni.noschmarrn.dev — production, currently on the Phase 6 build.
10 · API reference

Every public endpoint, with response shape.

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.

GET/api/health

Liveness and database probe. Returns 200 {"status": "ok", "version": "...", "db": "ok"}, or 503 {"status": "degraded", ...} if the DB probe fails.

GET/api/projects

Lists every project with status public. One object per project.

Response (200)
[
  {
    "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"
  }
]
GET/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.

Response (200)
{
  "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"
}
GET/api/projects/<slug>/latest

The 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?".

Response (200)
{
  "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"
}
GET/api/projects/<slug>/releases

All 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.

GET/api/projects/<slug>/badge.svg

Trust badge SVG for the active release ("Scanned with ClamAV …"). Cache-Control: public, max-age=300.

GET/api/projects/<slug>/releases/<version>/badge.svg

Trust badge SVG for a specific historical version. Cache-Control: public, max-age=3600.

GET/api/modules/<collection-slug>

The module catalog. ETag-based caching, 60 s max-age; returns 304 Not Modified on a quiet request.

Response (200)
{
  "collection": {
    "slug": "schneespur-modules",
    "name": "Schneespur modules",
    "description": "Extensions for the Schneespur app",
    "generated_at": "2026-05-09T11:23:00Z"
  },
  "modules": [
    {
      "slug": "schneespur-export",
      "name": "Export module",
      "description": "GPX/CSV export",
      "current_version": "0.4.1",
      "category": "export",
      "tags": ["gpx", "csv"],
      "image_url": "https://jenni.download/schneespur-export/icon",
      "website_url": "https://example.com/export-module",
      "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"
    }
  ]
}

Direct download URLs

These return application/zip, not JSON. Each download increments the anonymous counter.

GET/<slug>

Active release ZIP. The most-quoted URL in the system.

GET/<slug>/v/<version>

Specific historical version (unless soft-deleted). Same hashing and scanning guarantees as the active release.

GET/<collection-slug>

For projects of type module_collection: 302 Found redirect to /api/modules/<collection-slug>. Collections are catalogs, not ZIPs.

Embed and info endpoints

GET/<slug>/widget.svg

Self-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).

GET/<slug>/info

Public HTML landing page for a project — metadata, changelog, size, hash, scan badge, manual download button. The non-API counterpart of the direct download URL.

GET/<slug>/v/<version>/info

Same shape, but for one specific historical version.

GET/<module-slug>/icon

Module icon image. Strong sha256 ETag, Cache-Control: public, max-age=300. Pillow validates the magic bytes; nh3 sanitises any SVG.

11 · roadmap

What's done, what's still cooking.

Shipped and live

  • Phase 1 — MVP. Upload, auth, public download, JSON API.
  • Phase 2 — comfort. Statistics, audit log, status-change UI, CSV export, rate limits.
  • Phase 3 — embed. Widget, info page, public-side i18n DE/EN.
  • Phase 4 — trust. ClamAV pipeline, trust badge.
  • Phase 5 — WordPress bridge. Per-plugin SVN settings, master-password-encrypted credentials, pre-flight checks (PHPCompatibility, plugin-check, PHPCS), atomic trunk + tags/ commit.
  • Phase 6 — module catalogs. Third project type (module_collection) and fourth (module), ETag-cached JSON catalog at /api/modules/<slug>, icon endpoint with sha256 ETag, cascade-protected deletion.

Still cooking

  • Phase 5.3 — wp.org asset workflow (banner, icon, screenshots through the separate SVN assets/ path).
  • Self-install packages — the actual open-source release: setup wizard, documented ENV defaults, sample Caddyfile, security CHANGELOG.
12 · license & support

Where Jenni stands legally.

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.