/specs API · stack · security · numbers

The dry version.

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.

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

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. 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.
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
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
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).
  • TOTP-2FA is mandatory for the admin: enrolment with QR + manual secret, 10 single-use recovery codes (Argon2id-hashed), TOTP secret encrypted at rest via HKDF(SECRET_KEY)→Fernet. Login is constant-time — a dummy verify runs on the user-not-found path so timing can't enumerate accounts.
  • Step-up TOTP on writes: a fresh TOTP code is cached for 5 minutes for ordinary writes; destructive actions (master-password, signing-key, trust-list operations) demand an inline code per submit with no cache.
  • 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.

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.

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
Cryptographycryptography — 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 authpyotp (TOTP) + qrcode[pil] (enrolment QR PNG); secret encrypted at rest via HKDF→Fernet
HTTP clienthttpx (author-API spool and internal calls)
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~620 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 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.
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.
Initial trust-layer setupOperator 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 instancejenni.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.
10 · API reference

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

Signing & trust

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.

GET/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.

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

Response (200)
{
  "algorithm":  "ed25519",
  "pubkey_b64": "mg6FbywzvR…",
  "key_id":     "5af2613e6e92c56d",
  "created_at": "2026-05-13T14:01:46"
}
GET/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.

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

GET/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.

Response (200)
{
  "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": []
      }
    }
  ]
}
GET/api/schema/module-json

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

Author API (authenticated)

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.

GET/api/auth/whoami

Token introspection: the bound collection and the token's scope. Used by JenniHUB and by authors sanity-checking their credentials.

POST/api/modules/<coll>/submit

Create a new module in the collection — i18n metadata plus a module.json. Lands as draft.

PATCH/api/modules/<coll>/<slug>

Update module metadata (merge semantics — send only the fields you change).

POST · DELETE/api/modules/<coll>/<slug>/image

Set or remove the module icon. Validated with the same Pillow magic-byte + nh3 SVG-sanitise pipeline as the admin UI.

POST · DELETE/api/modules/<coll>/<slug>/screenshots[/<id>]

Add or remove screenshots, each with an i18n caption.

POST/api/modules/<coll>/<slug>/releases

Upload a release ZIP (multipart). Runs the full ClamAV scan and, on activation, manifest signing — exactly as an admin upload would.

POST/api/modules/<coll>/<slug>/releases/<version>/activate

Activate a previously uploaded release. Honours the collection's auto_publish flag.

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 badge. ClamAV pipeline, trust-badge SVG, daily re-scan.
  • Phase 5.1 — WordPress bridge, part one. Per-plugin SVN settings, master-password-encrypted credentials.
  • Phase 5.2 — WordPress bridge, part two. Pre-flight checks (PHPCompatibility, plugin-check, PHPCS), atomic trunk + tags/<version> commit as one revision, diff preview before push.
  • Phase 5.3 — wp.org asset workflow. Separate SVN commit against the wp.org assets/ path for banner, icon, screenshots — stage-and-batch pattern with diff preview.
  • 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.
  • Phase 7 — Ed25519 release manifests. Per-release signed manifest with rollback counter, signed timestamp, signed SHA-256. Server-side signing key management with encrypted-at-rest privkey. Reference verifier in the repo.
  • Phase 8 — offline trust anchor. Operator-laptop CLI (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 →
  • Phase 9 — multilingual content. Project and module names, descriptions, changelogs, categories, and tags become i18n objects ({"de": …, "en": …}) with a per-project locale list and primary locale. Public pages and the catalog resolve language independently of the admin chrome.
  • Phase 10 — admin hardening. Mandatory TOTP-2FA, 10 single-use recovery codes, step-up TOTP for write actions, constant-time login, session_epoch force-re-auth.
  • Phase 11 — module.json contract. A generic, server-validated author manifest format with a public JSON Schema at /api/schema/module-json.
  • Phase 12 — headless schema cleanup. Module screenshots with i18n captions, author / support URLs, pre-rendered changelog HTML cached at write-time.
  • Phase B — shared core layer. Admin UI and the author API call the same in-process app/core/ use-cases — one validation and scan path, no self-HTTP.
  • Phase C — public author API. Third parties submit modules through an authenticated REST API (collection-bound bearer tokens); the operator reviews and approves.
  • Phase C.1 — API-key admin. Collection-scoped token management in the UI: create, pause / resume, soft-revoke — all step-up-TOTP-gated.

Still cooking

  • Self-install packages — the actual open-source release: setup wizard, documented ENV defaults, sample Caddyfile, security CHANGELOG.
  • JenniHUB — a standalone self-service marketplace frontend on top of the author API (separate project): developers manage their own uploads, the operator only approves.
  • Hardware-token / HSM for the root key — deliberately out of scope for the current phase, on the menu for later.
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.