6 Commits 90e0d37438 ... d2870b6772

Autor SHA1 Mensagem Data
  Medowar d2870b6772 changed docs to english 1 mês atrás
  Medowar 92de6a4111 reworking client webdocs 1 mês atrás
  Medowar ef586c1593 adding web client docs for implementation 1 mês atrás
  Medowar 938a0e1c29 fixing script not beeing in client package 1 mês atrás
  Medowar f62663ef10 removed references to psa project #2 1 mês atrás
  Medowar d0b75f6b2d removed references to psa project 1 mês atrás
61 arquivos alterados com 4026 adições e 1607 exclusões
  1. 120 79
      README.md
  2. 1 1
      admin/instances.php
  3. 1 1
      admin/releases.php
  4. 2 2
      api/v1/manifest.php
  5. 2 3
      assets/css/style.css
  6. 15 0
      client-docs/.htaccess
  7. 85 0
      client-docs/api.php
  8. 326 0
      client-docs/assets/docs.css
  9. 11 0
      client-docs/assets/marked.min.js
  10. 1 0
      client-docs/assets/swagger-ui-bundle.js
  11. 306 0
      client-docs/assets/swagger-ui.LICENSE.txt
  12. 0 0
      client-docs/assets/swagger-ui.css
  13. 101 0
      client-docs/content/00_OVERVIEW.md
  14. 39 0
      client-docs/content/50_API.md
  15. 27 0
      client-docs/content/llms-intro.md
  16. 668 0
      client-docs/inc/handbook.php
  17. 213 0
      client-docs/index.php
  18. 152 0
      client-docs/llms.php
  19. 427 0
      client-docs/openapi.json
  20. 23 0
      client-docs/openapi.php
  21. 96 81
      client-package/README.md
  22. 46 41
      client-package/docs/01_QUICKSTART.md
  23. 73 70
      client-package/docs/02_INTEGRATION.md
  24. 77 75
      client-package/docs/03_CONFIG_REFERENCE.md
  25. 79 75
      client-package/docs/04_FUNCTION_API.md
  26. 103 99
      client-package/docs/05_BACKUP_SOURCES.md
  27. 85 79
      client-package/docs/06_UPDATE_PACKAGING.md
  28. 82 80
      client-package/docs/07_POST_UPDATE_HOOKS.md
  29. 64 60
      client-package/docs/08_PROTOCOL.md
  30. 202 137
      client-package/docs/09_TROUBLESHOOTING.md
  31. 99 94
      client-package/docs/10_SECURITY.md
  32. 12 12
      client-package/docs/index.php
  33. 15 16
      client-package/examples/after-update.php
  34. 19 19
      client-package/examples/cron/manage-client.cron
  35. 8 8
      client-package/examples/flat-file-project/migrations/2026-08-20-01-add-category-id.php
  36. 13 13
      client-package/examples/integration-snippet.php
  37. 5 5
      client-package/examples/mysql-project/migrations/2026-08-20-01-add-orders-index.php
  38. 1 1
      client-package/manage-client/config.sample.php
  39. 2 4
      client-package/manage-client/lib/backup.php
  40. 1 1
      client-package/manage-client/lib/mysql.php
  41. 3 4
      client-package/manage-client/lib/remote.php
  42. 4 5
      client-package/manage-client/lib/updater.php
  43. 4 6
      client-package/manage-client/lib/zip.php
  44. 2 2
      client-package/manage-client/ui/panel.php
  45. 5 6
      client-package/scripts/create-release-zip.sh
  46. 2 2
      config.sample.php
  47. 81 97
      docs/ARCHITECTURE.md
  48. 78 76
      docs/CONFIG_REFERENCE.md
  49. 75 71
      docs/INSTANCE_MANAGEMENT.md
  50. 0 108
      docs/MIGRATION_PSA.md
  51. 61 59
      docs/RELEASING.md
  52. 85 83
      docs/SERVER_SETUP.md
  53. 12 12
      docs/index.php
  54. 1 1
      includes/api.php
  55. 2 3
      includes/auth.php
  56. 1 2
      includes/backups.php
  57. 1 2
      includes/log.php
  58. 1 2
      includes/ratelimit.php
  59. 2 3
      includes/releases.php
  60. 2 3
      includes/s3.php
  61. 2 4
      includes/storage.php

+ 120 - 79
README.md

@@ -1,99 +1,140 @@
 # Manage
 
-Update- und Backup-Server für PHP-Projekte, plus ein weitergebbares Client-Paket.
+Update and backup server for PHP projects, plus a redistributable client package.
 
-## Zweck
+## Purpose
 
-Mehrere eigenständige PHP-Projekte brauchen dieselben zwei Dinge: eine Möglichkeit,
-neue Versionen einzuspielen, und regelmäßige Sicherungen der Betriebsdaten. `manage`
-stellt beides zentral bereit, statt es in jedem Projekt erneut zu bauen.
+Multiple independent PHP projects need the same two things: a way to roll out
+new versions, and regular backups of operational data. `manage` provides both
+centrally instead of building them into each project again.
 
-Eine Installation betreut **ein Produkt** mit einer überschaubaren Zahl von
-Instanzen. Für ein weiteres Produkt wird `manage` erneut ausgerollt.
+One installation serves **one product** with a manageable number of
+instances. For another product, `manage` is deployed again.
 
-## Kernfunktionen
+## Core features
 
-- Release-Verwaltung: Pakete hochladen, Prüfsummen serverseitig berechnen, aktuelles
-  Release festlegen
-- Backup-Empfang je Instanz, mit zwei Aufbewahrungsstufen und optionalem S3-Archiv
-- Instanzregister mit Token-Authentifizierung, Deaktivierung und Token-Erneuerung
-- Übersicht über installierte Versionen, letzte Sicherungen und offene Migrationen
-- Client-Paket mit Kommandozeile, fertiger Adminoberfläche und Funktions-API
+- Release management: upload packages, checksums computed server-side, set
+  the current release
+- Backup reception per instance, with two retention tiers and an optional S3
+  archive
+- Instance registry with token authentication, deactivation and token
+  rotation
+- Overview of installed versions, latest backups and pending migrations
+- Client package with a command line, a ready-made admin UI and a function
+  API
 
-## Voraussetzungen
+## Requirements
 
 - PHP 8.x
-- ein Webserver (Apache mit aktiver `.htaccess` oder nginx mit gesetzten Sperren)
-- Schreibrechte auf `storage/`
+- a web server (Apache with `.htaccess` enabled, or nginx with the
+  equivalent locks set)
+- write access to `storage/`
 
-Keine Datenbank, kein Composer, kein Build-Schritt, keine externen Bibliotheken.
+No database, no Composer, no build step, no external libraries.
 
-## Wichtige Dateien
+## Key files
 
-- Konfiguration: `config.php` (aus `config.sample.php`)
-- Oberfläche: `admin/`
-- Schnittstelle für Clients: `api/v1/`
-- Gemeinsame Bibliothek: `includes/`
-- Zustand: `storage/` (nicht öffentlich)
-- Weitergebbares Client-Paket: `client-package/`
+- Configuration: `config.php` (from `config.sample.php`)
+- UI: `admin/`
+- Client interface: `api/v1/`
+- Shared library: `includes/`
+- State: `storage/` (not public)
+- Redistributable client package: `client-package/`
 
-## Einrichtung
+## Setup
 
-1. `config.sample.php` nach `config.php` kopieren.
-2. Passwort-Hash erzeugen und als `MANAGE_ADMIN_PASSWORD_HASH` eintragen:
+1. Copy `config.sample.php` to `config.php`.
+2. Generate a password hash and enter it as `MANAGE_ADMIN_PASSWORD_HASH`:
    `php -r 'echo password_hash("…", PASSWORD_DEFAULT), PHP_EOL;'`
-3. `MANAGE_PUBLIC_URL` auf die absolute Adresse dieser Installation setzen.
-4. `MANAGE_PRODUCT_NAME` und `MANAGE_PACKAGE_PREFIX` auf das betreute Produkt setzen.
-5. Schreibrechte auf `storage/` sicherstellen.
-6. `admin/login.php` öffnen und unter **Einstellungen → Diagnose** prüfen, dass alles
-   Wesentliche stimmt.
+3. Set `MANAGE_PUBLIC_URL` to the absolute address of this installation.
+4. Set `MANAGE_PRODUCT_NAME` and `MANAGE_PACKAGE_PREFIX` to the product being
+   served.
+5. Make sure `storage/` is writable.
+6. Open `admin/login.php` and check under **Settings → Diagnostics** that
+   everything essential checks out.
 
-Ausführlich: [docs/SERVER_SETUP.md](docs/SERVER_SETUP.md).
+Details: [docs/SERVER_SETUP.md](docs/SERVER_SETUP.md).
 
-## Ein Projekt anbinden
+## Connecting a project
 
-1. Im Manage-Server unter **Instanzen** eine Instanz anlegen und das einmalig
-   angezeigte Token notieren.
-2. Client-Paket bauen und übergeben:
+1. In the Manage server under **Instances**, create an instance and note the
+   token shown once.
+2. Build and hand over the client package:
    `./scripts/build-client-package.sh --server-url https://manage.example.org`
-3. Im Projekt: `manage-client/` einkopieren, `config.php` anlegen,
-   `php manage-client/bin/manage-client.php status` ausführen.
-
-Die vollständige Anleitung dafür liegt **im Paket selbst**
-([client-package/README.md](client-package/README.md)), damit die empfangende Seite
-keinen Zugriff auf dieses Repository braucht.
-
-## Dokumentation
-
-Serverseitig, in `docs/`:
-
-- [ARCHITECTURE.md](docs/ARCHITECTURE.md) – Aufbau, Datenfluss, Speicherformate, Herkunft des Codes
-- [SERVER_SETUP.md](docs/SERVER_SETUP.md) – Installation, Webserver, Limits, S3
-- [INSTANCE_MANAGEMENT.md](docs/INSTANCE_MANAGEMENT.md) – Instanzen, Tokens, Übergabe des Client-Pakets
-- [RELEASING.md](docs/RELEASING.md) – Pakete bauen und veröffentlichen
-- [CONFIG_REFERENCE.md](docs/CONFIG_REFERENCE.md) – alle Serverkonstanten
-- [MIGRATION_PSA.md](docs/MIGRATION_PSA.md) – was eine spätere Ablösung im PSA-Bestellsystem bedeuten würde
-
-Für Projekte, in `client-package/docs/`: Quickstart, Integration, Konfiguration,
-Funktions-API, Backup-Quellen, Paketbau, Post-Update-Hooks, Protokoll, Fehlersuche,
-Sicherheit.
-
-Im Browser lesbar über `docs/index.php` beziehungsweise `client-package/docs/index.php`
-(Markdown wird mit dem mitgelieferten [marked](https://marked.js.org/) gerendert).
-
-## Was bewusst fehlt
-
-- **Keine Wiederherstellung.** Backups werden erstellt, übertragen und zum Download
-  bereitgestellt, aber nie automatisch zurückgespielt. Ein Update sichert die
-  überschriebenen Dateien, kann sie aber nicht zurückholen.
-- **Keine automatischen Updates.** Der Server bietet an, die Instanz entscheidet.
-- **Keine Signatur der Pakete.** Prüfsumme und Paket kommen vom selben Server; die
-  Absicherung ist TLS plus Token. Der Manage-Server muss entsprechend geschützt sein.
-- **Keine Kanäle pro Instanz.** Es gibt ein aktuelles Release für alle Instanzen
-  eines Servers.
-
-## Hinweise
-
-- Das PSA-Bestellsystem wurde beim Herauslösen nicht verändert und läuft unverändert
-  gegen seine bisherigen Server weiter.
-- Kein automatisiertes Test-/CI-Setup vorgesehen.
+3. In the project: copy in `manage-client/`, create `config.php`, run
+   `php manage-client/bin/manage-client.php status`.
+
+The complete guide for this lives **inside the package itself**
+([client-package/README.md](client-package/README.md)), so the receiving side
+needs no access to this repository.
+
+## Documentation
+
+Server-side, in `docs/`:
+
+- [ARCHITECTURE.md](docs/ARCHITECTURE.md) – structure, data flow, storage formats
+- [SERVER_SETUP.md](docs/SERVER_SETUP.md) – installation, web server, limits, S3
+- [INSTANCE_MANAGEMENT.md](docs/INSTANCE_MANAGEMENT.md) – instances, tokens, handing over the client package
+- [RELEASING.md](docs/RELEASING.md) – building and publishing packages
+- [CONFIG_REFERENCE.md](docs/CONFIG_REFERENCE.md) – every server constant
+
+For projects, in `client-package/docs/`: quickstart, integration,
+configuration, function API, backup sources, packaging, post-update hooks,
+protocol, troubleshooting, security.
+
+Readable in the browser via `docs/index.php` and
+`client-package/docs/index.php` respectively (Markdown rendered with the
+bundled [marked](https://marked.js.org/)).
+
+### Public client handbook
+
+`client-docs/` publishes the content of `client-package/` over HTTP: every
+chapter and every source file as its own page, plus the interface as
+OpenAPI. Unlike `admin/` and `api/v1/`, this folder is reachable without
+logging in — it exists to be handed out, so a project can be integrated
+without shipping a ZIP first.
+
+| Address | Content |
+|---|---|
+| `client-docs/` | overview, from there a chapter or a source file |
+| `client-docs/index.php?doc=01_QUICKSTART` | one chapter |
+| `client-docs/index.php?code=manage-client/lib/updater.php` | one source file |
+| `client-docs/api.php` | protocol v1 in Swagger UI |
+| `client-docs/openapi.php` | the OpenAPI document alone, for code generators |
+
+For programs, the same pages exist as plain Markdown under `llms.php`, with
+`llms.php?doc=…` and `llms.php?code=…` alongside. `client-docs/llms.php` is
+the index: it names every page with a one-sentence description and its size,
+and for the usual tasks — integrate the client, backups only, updater only,
+write your own client, troubleshooting — lists the short set of pages that
+suffices. That is the point of the split: load only what is needed. This is
+the address to hand to an LLM. Apache additionally answers `llms.txt`;
+`llms.php` is canonical, because that works without `mod_rewrite`.
+
+The pages for humans render their Markdown in the browser and carry a
+comment and an invisible element in the source that point to the Markdown
+version of the same page — a program that lands there by accident finds its
+way.
+
+The content is read from `client-package/` on every request; there is
+nothing to build and nothing to regenerate. `manage-client/config.php` is
+excluded from publication, so a locally created token never becomes public.
+
+marked and Swagger UI live under `client-docs/assets/` in the repository. No
+external server is contacted, not even for fonts or icons.
+
+## What is deliberately missing
+
+- **No restore.** Backups are created, transferred and made available for
+  download, but never played back automatically. An update backs up the
+  files it overwrites, but cannot bring them back.
+- **No automatic updates.** The server offers; the instance decides.
+- **No package signing.** The checksum and the package come from the same
+  server; the safeguard is TLS plus the token. The Manage server must be
+  secured accordingly.
+- **No per-instance channels.** There is one current release for all
+  instances of a server.
+
+## Notes
+
+- No automated test/CI setup is provided.

+ 1 - 1
admin/instances.php

@@ -88,7 +88,7 @@ define("MANAGE_TOKEN",      "<?php echo manageEscape($newToken); ?>");</pre>
         <div class="form-group">
             <label for="new_instance">Kennung</label>
             <input type="text" id="new_instance" name="new_instance" required
-                   pattern="[A-Za-z0-9][A-Za-z0-9._\-]*" maxlength="120" placeholder="psa-prod">
+                   pattern="[A-Za-z0-9][A-Za-z0-9._\-]*" maxlength="120" placeholder="example-prod">
             <p class="hint">Buchstaben, Zahlen, Punkt, Unterstrich, Bindestrich.</p>
         </div>
         <div class="form-group">

+ 1 - 1
admin/releases.php

@@ -115,7 +115,7 @@ manageRenderHeader("Releases", $messages, $errors);
 <h2>Veröffentlichte Releases</h2>
 
 <?php if ($releases === []): ?>
-    <p class="empty">Noch keine Releases vorhanden. Paket mit <code>scripts/create-release-zip.sh</code> bauen und hier hochladen.</p>
+    <p class="empty">Noch keine Releases vorhanden. Paket mit dem Build-Skript aus dem Client-Paket (<code>scripts/create-release-zip.sh</code>) bauen und hier hochladen.</p>
 <?php else: ?>
     <div class="table-scroll">
         <table class="data-table">

+ 2 - 2
api/v1/manifest.php

@@ -5,8 +5,8 @@ declare(strict_types=1);
 // GET api/v1/manifest.php
 // Returns the release the calling instance should install.
 //
-// Unlike the PSA update server this endpoint requires a valid instance token;
-// release metadata is no longer public.
+// This endpoint requires a valid instance token; release metadata is not
+// public.
 
 require_once __DIR__ . "/../../includes/api.php";
 require_once __DIR__ . "/../../includes/releases.php";

+ 2 - 3
assets/css/style.css

@@ -1,8 +1,7 @@
 /*
  * Manage admin UI. Single hand-written stylesheet, no framework and no build
- * step, following the same custom-property approach as the PSA order system
- * (docs/STYLE_SYSTEM.md there). Neutral palette, because this is an operations
- * tool rather than a branded surface.
+ * step, built on custom properties. Neutral palette, because this is an
+ * operations tool rather than a branded surface.
  */
 
 :root {

+ 15 - 0
client-docs/.htaccess

@@ -0,0 +1,15 @@
+# Public documentation: readable without a login, unlike admin/ and the API.
+Options -Indexes
+
+<IfModule mod_rewrite.c>
+    RewriteEngine On
+
+    # Convenience alias for the index that agents read. llms.php is the
+    # canonical address and everything links to that, so this rule is optional:
+    # a server without mod_rewrite loses nothing but the shorter name.
+    RewriteRule ^llms\.txt$ llms.php [L]
+</IfModule>
+
+# The parent .htaccess denies .md and .json outright. Both are served through
+# PHP here (index.php, llms.php, openapi.php), so the denial stays in place and
+# content/ plus openapi.json remain unreachable directly.

+ 85 - 0
client-docs/api.php

@@ -0,0 +1,85 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * API reference: Swagger UI over the OpenAPI document.
+ *
+ * swagger-ui.css and swagger-ui-bundle.js are vendored in assets/, so this page
+ * makes no external requests and stays within the repository's content security
+ * policy.
+ */
+
+require_once __DIR__ . "/inc/handbook.php";
+
+$base = handbookBaseUrl();
+$self = handbookSelfUrl();
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>API Reference – Manage Client</title>
+    <link rel="stylesheet" href="assets/docs.css">
+    <link rel="stylesheet" href="assets/swagger-ui.css">
+    <link rel="alternate" type="application/json" href="openapi.php"
+          title="The same description as an OpenAPI document">
+</head>
+<body>
+<!--
+    Note for LLMs and other programs:
+
+    This page is Swagger UI and only builds itself in the browser; there is
+    nothing usable in the HTML. The same description as OpenAPI 3.1:
+
+        <?php echo $self . "/openapi.php\n"; ?>
+
+    Index of every documentation page, with a short description and size:
+
+        <?php echo $self . "/llms.php\n"; ?>
+-->
+<p class="llm-only">
+    For LLMs: the interface as an OpenAPI document at <?php echo handbookEscape($self); ?>/openapi.php –
+    index of every page at <?php echo handbookEscape($self); ?>/llms.php
+</p>
+<header class="docs-header">
+    <div class="docs-header-inner">
+        <a class="docs-brand" href="index.php">Manage Client</a>
+        <nav class="docs-header-links">
+            <a href="index.php">Documentation</a>
+            <a href="openapi.php">OpenAPI</a>
+            <a href="llms.php">For LLMs</a>
+        </nav>
+    </div>
+</header>
+<div class="swagger-frame">
+    <div class="docs-note">
+        <p><strong>Protocol v1</strong> – the interface between a project instance and
+            <code><?php echo handbookEscape($base); ?></code>.</p>
+        <p>"Try it out" talks to this installation and needs a valid instance plus
+            token; without both, every endpoint responds with <code>401</code>. The id is
+            set via <em>Authorize</em>.</p>
+    </div>
+    <div id="swagger-ui"></div>
+</div>
+<script src="assets/swagger-ui-bundle.js"></script>
+<script>
+    window.addEventListener('load', function () {
+        SwaggerUIBundle({
+            url: 'openapi.php',
+            dom_id: '#swagger-ui',
+            deepLinking: true,
+            docExpansion: 'list',
+            defaultModelsExpandDepth: 1,
+            defaultModelRendering: 'model',
+            tryItOutEnabled: true,
+            persistAuthorization: false,
+            supportedSubmitMethods: ['get', 'post'],
+            presets: [SwaggerUIBundle.presets.apis],
+            layout: 'BaseLayout'
+        });
+    });
+</script>
+</body>
+</html>

+ 326 - 0
client-docs/assets/docs.css

@@ -0,0 +1,326 @@
+/*
+ * Public client handbook. Deliberately close to client-package/docs/assets/docs.css
+ * so all three viewers look like the same product; the additions here are the
+ * index lists, the source note and the hint that only machines read.
+ */
+
+:root {
+    color-scheme: light;
+    --docs-bg: #f5f6f8;
+    --docs-surface: #fff;
+    --docs-text: #1a1a1a;
+    --docs-muted: #5c6370;
+    --docs-accent: #003366;
+    --docs-border: #d8dde6;
+    --docs-code-bg: #eef1f5;
+    font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+    line-height: 1.6;
+}
+
+*,
+*::before,
+*::after {
+    box-sizing: border-box;
+}
+
+body {
+    margin: 0;
+    background: var(--docs-bg);
+    color: var(--docs-text);
+}
+
+/*
+ * The pointer to the Markdown rendering. It is in the markup, not in a comment
+ * alone, so that a program extracting text from this page finds it; a reader
+ * never sees it.
+ */
+.llm-only {
+    display: none;
+}
+
+.docs-header {
+    background: var(--docs-accent);
+    color: #fff;
+}
+
+.docs-header-inner {
+    max-width: 72rem;
+    margin: 0 auto;
+    padding: 0.75rem 1.25rem;
+    display: flex;
+    align-items: baseline;
+    justify-content: space-between;
+    gap: 1rem;
+    flex-wrap: wrap;
+}
+
+.docs-brand {
+    color: inherit;
+    text-decoration: none;
+    font-weight: 600;
+}
+
+.docs-brand:hover {
+    text-decoration: underline;
+}
+
+.docs-header-links {
+    display: flex;
+    gap: 1rem;
+    font-size: 0.9rem;
+}
+
+.docs-header-links a {
+    color: inherit;
+    opacity: 0.9;
+}
+
+.docs-layout {
+    max-width: 72rem;
+    margin: 0 auto;
+    padding: 1.25rem;
+    display: grid;
+    grid-template-columns: minmax(13rem, 17rem) 1fr;
+    gap: 1.5rem;
+    align-items: start;
+}
+
+@media (max-width: 900px) {
+    .docs-layout {
+        grid-template-columns: 1fr;
+    }
+
+    .docs-nav {
+        position: static;
+        max-height: none;
+    }
+}
+
+.docs-nav {
+    background: var(--docs-surface);
+    border: 1px solid var(--docs-border);
+    border-radius: 0.5rem;
+    padding: 1rem;
+    position: sticky;
+    top: 1rem;
+    max-height: calc(100vh - 2rem);
+    overflow-y: auto;
+}
+
+.docs-nav-title {
+    margin: 1.1rem 0 0.4rem;
+    font-size: 0.7rem;
+    text-transform: uppercase;
+    letter-spacing: 0.06em;
+    color: var(--docs-muted);
+}
+
+.docs-nav-title:first-child {
+    margin-top: 0;
+}
+
+.docs-nav ul {
+    margin: 0;
+    padding: 0;
+    list-style: none;
+}
+
+.docs-nav li + li {
+    margin-top: 0.15rem;
+}
+
+.docs-nav a {
+    display: block;
+    padding: 0.3rem 0.45rem;
+    border-radius: 0.25rem;
+    color: var(--docs-accent);
+    text-decoration: none;
+    font-size: 0.88rem;
+    overflow-wrap: anywhere;
+}
+
+.docs-nav a:hover {
+    background: var(--docs-code-bg);
+}
+
+.docs-nav a[aria-current="page"] {
+    background: var(--docs-accent);
+    color: #fff;
+}
+
+.docs-nav-code a {
+    font-family: ui-monospace, "Cascadia Code", "Source Code Pro", monospace;
+    font-size: 0.78rem;
+}
+
+.docs-main {
+    background: var(--docs-surface);
+    border: 1px solid var(--docs-border);
+    border-radius: 0.5rem;
+    padding: 1.5rem 2rem;
+    min-width: 0;
+}
+
+.docs-main > h1:first-child {
+    margin-top: 0;
+}
+
+.docs-main h2 {
+    margin-top: 2rem;
+    padding-bottom: 0.2em;
+    border-bottom: 1px solid var(--docs-border);
+}
+
+/* Index: page name plus the one line that says what is on it. */
+.docs-index-list {
+    margin: 0.75rem 0 0;
+}
+
+.docs-index-list dt {
+    margin-top: 0.9rem;
+    display: flex;
+    align-items: baseline;
+    gap: 0.6rem;
+}
+
+.docs-index-list dt a {
+    color: var(--docs-accent);
+    font-weight: 600;
+}
+
+.docs-index-list dd {
+    margin: 0.1rem 0 0;
+    color: var(--docs-muted);
+    font-size: 0.92rem;
+}
+
+.docs-index-code dt a {
+    font-family: ui-monospace, "Cascadia Code", "Source Code Pro", monospace;
+    font-size: 0.88rem;
+    font-weight: 500;
+}
+
+.docs-size {
+    color: var(--docs-muted);
+    font-size: 0.78rem;
+    white-space: nowrap;
+}
+
+.docs-source-note {
+    margin: 0 0 1.5rem;
+    padding-bottom: 0.75rem;
+    border-bottom: 1px solid var(--docs-border);
+    color: var(--docs-muted);
+    font-size: 0.85rem;
+}
+
+.docs-source-note a {
+    color: var(--docs-accent);
+}
+
+.markdown-body h1,
+.markdown-body h2,
+.markdown-body h3,
+.markdown-body h4 {
+    line-height: 1.25;
+    margin-top: 1.6em;
+    margin-bottom: 0.5em;
+}
+
+.markdown-body h1:first-child {
+    margin-top: 0;
+}
+
+.markdown-body h2 {
+    padding-bottom: 0.2em;
+    border-bottom: 1px solid var(--docs-border);
+}
+
+.markdown-body p,
+.markdown-body ul,
+.markdown-body ol,
+.markdown-body pre,
+.markdown-body table {
+    margin: 0.75em 0;
+}
+
+.markdown-body a {
+    color: var(--docs-accent);
+}
+
+.markdown-body code {
+    font-family: ui-monospace, "Cascadia Code", "Source Code Pro", monospace;
+    font-size: 0.9em;
+    background: var(--docs-code-bg);
+    padding: 0.1em 0.35em;
+    border-radius: 0.2em;
+}
+
+.markdown-body pre {
+    background: var(--docs-code-bg);
+    padding: 1rem;
+    overflow-x: auto;
+    border-radius: 0.35rem;
+    line-height: 1.45;
+}
+
+.markdown-body pre code {
+    padding: 0;
+    background: none;
+    font-size: 0.82rem;
+}
+
+.markdown-body table {
+    border-collapse: collapse;
+    width: 100%;
+    font-size: 0.95rem;
+    display: block;
+    overflow-x: auto;
+}
+
+.markdown-body th,
+.markdown-body td {
+    border: 1px solid var(--docs-border);
+    padding: 0.4rem 0.6rem;
+    text-align: left;
+}
+
+.markdown-body th {
+    background: var(--docs-code-bg);
+}
+
+.markdown-body blockquote {
+    margin: 1em 0;
+    padding-left: 1em;
+    border-left: 4px solid var(--docs-border);
+    color: var(--docs-muted);
+}
+
+.markdown-body hr {
+    border: 0;
+    border-top: 1px solid var(--docs-border);
+    margin: 2em 0;
+}
+
+.docs-note {
+    background: var(--docs-code-bg);
+    border-left: 4px solid var(--docs-accent);
+    padding: 0.75rem 1rem;
+    margin: 0 0 1.5rem;
+    font-size: 0.9rem;
+}
+
+.docs-note p {
+    margin: 0.25rem 0;
+}
+
+/* Swagger UI page: same frame, the widget brings its own styling. */
+.swagger-frame {
+    max-width: 72rem;
+    margin: 0 auto;
+    padding: 1.25rem;
+}
+
+.swagger-frame .swagger-ui .topbar {
+    display: none;
+}

Diferenças do arquivo suprimidas por serem muito extensas
+ 11 - 0
client-docs/assets/marked.min.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 1 - 0
client-docs/assets/swagger-ui-bundle.js


+ 306 - 0
client-docs/assets/swagger-ui.LICENSE.txt

@@ -0,0 +1,306 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+/*!
+	Copyright (c) 2018 Jed Watson.
+	Licensed under the MIT License (MIT), see
+	http://jedwatson.github.io/classnames
+*/
+
+/*!
+ * @description Recursive object extending
+ * @author Viacheslav Lotsmanov <lotsmanov89@gmail.com>
+ * @license MIT
+ *
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2013-2018 Viacheslav Lotsmanov
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to
+ * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+ * the Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+/*!
+ * The buffer module from node.js, for the browser.
+ *
+ * @author   Feross Aboukhadijeh <https://feross.org>
+ * @license  MIT
+ */
+
+/*!
+ * https://github.com/Starcounter-Jack/JSON-Patch
+ * (c) 2017-2021 Joachim Wester
+ * MIT license
+ */
+
+/*!
+ * https://github.com/Starcounter-Jack/JSON-Patch
+ * (c) 2017-2022 Joachim Wester
+ * MIT licensed
+ */
+
+/*!
+ * repeat-string <https://github.com/jonschlinkert/repeat-string>
+ *
+ * Copyright (c) 2014-2015, Jon Schlinkert.
+ * Licensed under the MIT License.
+ */
+
+/*! @license DOMPurify 3.4.13 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.13/LICENSE */
+
+/*! ieee754. BSD-3-Clause License. Feross Aboukhadijeh <https://feross.org/opensource> */
+
+/*! safe-buffer. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
+
+/**
+ * @license React
+ * react-dom.production.min.js
+ *
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * @license React
+ * react.production.min.js
+ *
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * @license React
+ * scheduler.production.min.js
+ *
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * @license React
+ * use-sync-external-store-with-selector.production.js
+ *
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
client-docs/assets/swagger-ui.css


+ 101 - 0
client-docs/content/00_OVERVIEW.md

@@ -0,0 +1,101 @@
+# Overview
+
+The Manage client brings two things into a standalone PHP project: a way
+to install new versions, and regular backups of operational data.
+
+This is the published version of the `client-package/` folder from the
+Manage repository: {{DOC_COUNT}} chapters and {{CODE_COUNT}} source files,
+each on its own page, plus the interface to the server as OpenAPI.
+Everything is read fresh from the repository on every request; what's here
+is the current state.
+
+## What the client does
+
+Three operations, all through the same functions, whether they're
+triggered from the command line, from the bundled UI, or directly from the
+project.
+
+**Update.** `manageUpdateCheck()` fetches the manifest from the server and
+compares versions. `manageUpdateApply()` downloads the package, checks size
+and SHA-256, extracts it into a working directory while checking every ZIP
+entry against path traversal, requires at least one sanity path in the
+archive, then copies file by file into the project and saves every
+overwritten file first into a timestamped folder. Protected paths are
+skipped. After that, the migrations from the package run, along with an
+optional callback.
+
+**Backup.** `manageBackupCreate()` collects the configured sources —
+globs, directories, individual files, each with a target prefix in the
+archive —, optionally appends a MySQL dump, writes a ZIP, applies local
+retention, and uploads the archive to the Manage server as well as to
+optional extra targets (S3, SFTP, a custom endpoint). A lock file prevents
+concurrent runs. A failed upload does not invalidate the local archive.
+
+**Heartbeat.** `manageHeartbeatSend()` reports version, PHP version, free
+disk space, pending migrations and the time of the last backup. The
+response includes the update information as a side effect.
+
+## Integrate, don't rebuild
+
+The client is cut so it can be copied as a folder into a foreign project.
+It has no dependencies, no build step, and no assumptions about the target
+project beyond PHP. Rebuilding it costs time and loses details already
+solved here — checksums, protected paths, lock files, resuming after
+partial failure.
+
+1. Take `manage-client/` from the source part into the target project,
+   paths and content unchanged.
+2. Copy `manage-client/config.sample.php` to `manage-client/config.php` and
+   set `MANAGE_SERVER_URL`, `MANAGE_INSTANCE`, `MANAGE_TOKEN` and
+   `MANAGE_BACKUP_SOURCES`. The first three values come from the Manage
+   server.
+3. Check `MANAGE_APP_ROOT`, `MANAGE_VERSION_FILE` and
+   `MANAGE_VERSION_CONSTANT` against the target project's actual layout.
+   These are the only places where the client has to know anything about
+   the project.
+4. Extend `MANAGE_UPDATE_PROTECTED_PATHS` with anything an update must
+   never overwrite, and `MANAGE_UPDATE_SANITY_PATHS` with a file present in
+   every valid release.
+5. Wire in one of the three entry points: the command line for cron,
+   `ui/panel.php` for the admin area, or the function API for custom pages.
+   See [02_INTEGRATION.md](02_INTEGRATION.md).
+6. Check with `php manage-client/bin/manage-client.php status`.
+
+Details in [01_QUICKSTART.md](01_QUICKSTART.md).
+
+## Writing your own client
+
+Anyone who has to implement this in a different language will find the
+binding description of the interface in [08_PROTOCOL.md](08_PROTOCOL.md)
+and the OpenAPI document. Three points decide whether the implementation is
+safe:
+
+- Check SHA-256 **and** size of every package against the manifest and
+  abort on mismatch, before anything is extracted.
+- When extracting, check every entry against path traversal (`..`,
+  absolute paths).
+- When deploying, skip the protected paths — otherwise the first update
+  overwrites the configuration and the operational data.
+
+## Limits
+
+These points are missing deliberately. Anyone expecting them is building
+on a false assumption.
+
+- **No restore.** Backups are created, transferred and made available for
+  download, but never played back automatically. An update backs up the
+  files it overwrites, but cannot bring them back.
+- **No automatic updates.** The server offers; the instance decides.
+- **No package signing.** The checksum and the package come from the same
+  server; the safeguard is TLS plus the token.
+- **No maintenance page.** Deployment overwrites files while the
+  application is live.
+- **No backup before the update.** That's deliberately a visible line in
+  the project rather than something built in; see
+  [04_FUNCTION_API.md](04_FUNCTION_API.md).
+
+## Requirements
+
+PHP 8.0 or newer, the `zip` extension for updates, write access to the
+project's data directory. No Composer, no build step, no external
+libraries.

+ 39 - 0
client-docs/content/50_API.md

@@ -0,0 +1,39 @@
+# HTTP API
+
+The interface between client and Manage server, protocol v1. Anyone using
+the bundled client doesn't need this chapter — it's for custom clients, for
+debugging, and for troubleshooting with `curl`.
+
+Base URL of this installation: `{{BASE_URL}}/api/v1`
+
+## The four endpoints
+
+| Method | Path | Purpose |
+|---|---|---|
+| `GET` | `/manifest.php` | which release should be installed |
+| `GET` | `/package.php?version=vX.Y.Z` | the release ZIP |
+| `POST` | `/backup.php` | upload a backup archive (`multipart/form-data`) |
+| `POST` | `/heartbeat.php` | report status, receive update information |
+
+Every request carries two headers:
+
+```http
+X-Manage-Instance: myproject-prod
+X-Manage-Token:    e4032c4dc51e9100…
+```
+
+There is no session, no cookie and no shared password. The server stores
+only the SHA-256 hash of the token and compares it in constant time.
+
+## Where to find what
+
+- **Description with example calls**, error cases, and the rules a custom
+  client must follow: [08_PROTOCOL.md](08_PROTOCOL.md).
+- **Machine-readable**, as OpenAPI 3.1: <{{SELF_URL}}/openapi.php>.
+  Contains schemas for every response, the error codes, and the patterns
+  for version, filename and checksum.
+- **To browse and try out** in the browser:
+  [Swagger UI]({{SELF_URL}}/api.php). "Try it out" talks to this
+  installation and needs a valid instance plus token.
+- **As a reference implementation**: `manage-client/lib/client.php` builds
+  the requests; `lib/updater.php` and `lib/backup.php` use them.

+ 27 - 0
client-docs/content/llms-intro.md

@@ -0,0 +1,27 @@
+# Manage Client – Index
+
+Update and backup functionality for standalone PHP projects: a PHP client
+that fetches, verifies and installs releases from a central server, creates
+and uploads backups of operational data, and reports the installation's
+status. No dependencies, no Composer, no build step.
+
+This is the index for programs. Every page is plain Markdown and
+individually retrievable. {{PAGE_COUNT}} pages in total, {{TOTAL_SIZE}}
+combined — **don't load all of it.** The *What each page is for* section
+names, for the usual tasks, the short list that suffices; the size shown
+after each page helps with estimating.
+
+Addresses:
+
+- Index (this page): {{SELF_URL}}/llms.php (also {{SELF_URL}}/llms.txt on Apache)
+- One page: {{SELF_URL}}/llms.php?doc=KEY or {{SELF_URL}}/llms.php?code=PATH
+- The same content for humans: {{SELF_URL}}/index.php
+- The Manage server this client talks to: {{BASE_URL}}
+
+The source-code pages reproduce each file's content completely and
+unchanged, in a code block under the path where the file must live in the
+project. They're meant to be adopted, not used as a template to rewrite.
+
+Not included: `manage-client/config.php`. This file carries a specific
+instance's access token and is not published. Its template is
+`manage-client/config.sample.php`.

+ 668 - 0
client-docs/inc/handbook.php

@@ -0,0 +1,668 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * Page index for the public client handbook.
+ *
+ * The handbook is one page per document and one page per source file, the same
+ * split the repository already has - not a single long page. Two renderings
+ * share this index:
+ *
+ *   index.php  HTML for people, rendered with the vendored marked.js
+ *   llms.php   the same pages as plain Markdown, plus llms.txt as their index
+ *
+ * Everything is read from disk on each request, so the published pages always
+ * match the repository; there is no build step and nothing to regenerate.
+ */
+
+const HANDBOOK_PACKAGE_DIR = __DIR__ . "/../../client-package";
+const HANDBOOK_CONTENT_DIR = __DIR__ . "/../content";
+
+/**
+ * Paths inside client-package/ that must never be published.
+ *
+ * config.php holds the instance token. It is git-ignored and stripped by
+ * build-client-package.sh, but these pages are public, so it is excluded by
+ * path as well instead of relying on it being absent.
+ */
+const HANDBOOK_EXCLUDED = [
+    "manage-client/config.php",
+];
+
+/** Vendored third-party files: named in the index, never served as a page. */
+const HANDBOOK_VENDORED = [
+    "docs/assets/marked.min.js",
+];
+
+// ---------------------------------------------------------------------------
+// Addresses
+// ---------------------------------------------------------------------------
+
+function handbookRepoRoot(): string
+{
+    return dirname(__DIR__, 2);
+}
+
+/** Name of this documentation directory, as it appears in the URL. */
+function handbookDirName(): string
+{
+    return basename(dirname(__DIR__));
+}
+
+/**
+ * Absolute base URL of this installation.
+ *
+ * Prefers the configured MANAGE_PUBLIC_URL so that copied links keep working;
+ * falls back to the current request for installations served under a different
+ * name (staging, a local `php -S`).
+ */
+function handbookBaseUrl(): string
+{
+    static $cached = null;
+    if ($cached !== null) {
+        return $cached;
+    }
+
+    $configured = "";
+    $config = handbookRepoRoot() . "/config.php";
+    if (is_file($config)) {
+        // Read as text: including the server config would pull in its side
+        // effects, and a public page needs none of them.
+        $source = (string) file_get_contents($config);
+        if (preg_match('/define\(\s*"MANAGE_PUBLIC_URL"\s*,\s*"([^"]*)"/', $source, $match) === 1) {
+            $configured = trim($match[1]);
+        }
+    }
+
+    if ($configured !== "" && preg_match('#^https?://#i', $configured) === 1) {
+        return $cached = rtrim($configured, "/");
+    }
+
+    $scheme = ($_SERVER["HTTPS"] ?? "") === "on"
+        || ($_SERVER["HTTP_X_FORWARDED_PROTO"] ?? "") === "https" ? "https" : "http";
+    $host = (string) ($_SERVER["HTTP_HOST"] ?? "localhost");
+
+    // dirname(SCRIPT_NAME) ends with this directory's name; dropping that
+    // suffix yields the mount point of the manage installation itself.
+    $dir = rtrim(str_replace("\\", "/", dirname($_SERVER["SCRIPT_NAME"] ?? "")), "/");
+    $own = "/" . handbookDirName();
+    if (str_ends_with($dir, $own)) {
+        $dir = substr($dir, 0, -strlen($own));
+    }
+
+    return $cached = $scheme . "://" . $host . $dir;
+}
+
+/** Absolute URL of this documentation directory, without a trailing slash. */
+function handbookSelfUrl(): string
+{
+    return handbookBaseUrl() . "/" . handbookDirName();
+}
+
+/** Absolute address of a page in the human rendering. */
+function handbookPageUrl(array $page): string
+{
+    return handbookSelfUrl() . "/index.php?" . handbookPageQuery($page);
+}
+
+/** Absolute address of the same page as plain Markdown. */
+function handbookRawUrl(array $page): string
+{
+    return handbookSelfUrl() . "/llms.php?" . handbookPageQuery($page);
+}
+
+function handbookPageQuery(array $page): string
+{
+    // Slashes are legal in a query string and a source path is easier to read
+    // - and to type into a terminal - when they are left alone.
+    return $page["kind"] . "=" . str_replace("%2F", "/", rawurlencode($page["key"]));
+}
+
+function handbookEscape(string $value): string
+{
+    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");
+}
+
+/**
+ * A summary for the HTML index. The summaries are lifted from Markdown and
+ * from source comments, so they may carry `code spans`; those become <code>
+ * after escaping, which has already neutralised any markup in the text.
+ */
+function handbookSummaryHtml(string $summary): string
+{
+    return preg_replace(
+        '/`([^`]+)`/',
+        '<code>$1</code>',
+        handbookEscape($summary),
+    ) ?? handbookEscape($summary);
+}
+
+// ---------------------------------------------------------------------------
+// The page index
+// ---------------------------------------------------------------------------
+
+/**
+ * Every page of the handbook, in reading order, keyed by "<kind>:<key>".
+ *
+ * A page is an array with: kind (doc|code), key, title, nav, summary, path,
+ * source (repository path, null for the two authored chapters), bytes.
+ *
+ * @return array<string, array>
+ */
+function handbookPages(): array
+{
+    static $pages = null;
+    if ($pages !== null) {
+        return $pages;
+    }
+
+    $pages = [];
+
+    // The two authored chapters frame the material that comes from the package.
+    foreach (["00_OVERVIEW", "50_API"] as $key) {
+        $path = HANDBOOK_CONTENT_DIR . "/" . $key . ".md";
+        if (!is_file($path)) {
+            continue;
+        }
+        $pages["doc:" . $key] = handbookMakeDocPage($key, $path, null);
+    }
+
+    foreach (handbookScanPackage() as $relative) {
+        if (in_array($relative, HANDBOOK_VENDORED, true)) {
+            continue;
+        }
+
+        $path = HANDBOOK_PACKAGE_DIR . "/" . $relative;
+
+        if (str_ends_with($relative, ".md")) {
+            $key = handbookDocKey($relative);
+            $pages["doc:" . $key] = handbookMakeDocPage($key, $path, $relative);
+            continue;
+        }
+
+        $pages["code:" . $relative] = [
+            "kind" => "code",
+            "key" => $relative,
+            "title" => $relative,
+            "nav" => $relative,
+            "summary" => handbookCodeSummary($path),
+            "path" => $path,
+            "source" => "client-package/" . $relative,
+            "bytes" => (int) @filesize($path),
+        ];
+    }
+
+    uasort($pages, static function (array $a, array $b): int {
+        return [handbookWeight($a), $a["key"]] <=> [handbookWeight($b), $b["key"]];
+    });
+
+    return $pages;
+}
+
+function handbookMakeDocPage(string $key, string $path, ?string $relative): array
+{
+    $title = handbookDocTitle($path, $key);
+    $number = preg_match('/^(\d+)_/', $key, $match) === 1 ? $match[1] . " · " : "";
+
+    return [
+        "kind" => "doc",
+        "key" => $key,
+        "title" => $title,
+        "nav" => $number . $title,
+        "summary" => handbookDocSummary($path),
+        "path" => $path,
+        "source" => $relative === null
+            ? handbookDirName() . "/content/" . $key . ".md"
+            : "client-package/" . $relative,
+        "bytes" => (int) @filesize($path),
+    ];
+}
+
+/** Whether two page records describe the same page. */
+function handbookIsSamePage(?array $a, array $b): bool
+{
+    return $a !== null && $a["kind"] === $b["kind"] && $a["key"] === $b["key"];
+}
+
+/** Look up one page, or null when the key is unknown. */
+function handbookPage(string $kind, string $key): ?array
+{
+    return handbookPages()[$kind . ":" . $key] ?? null;
+}
+
+/** @return array<string, array> only the pages of one kind */
+function handbookPagesOfKind(string $kind): array
+{
+    return array_filter(handbookPages(), static fn(array $p): bool => $p["kind"] === $kind);
+}
+
+/**
+ * Reading order. Chapters before source, the overview first and the API
+ * chapter after the numbered ones; within the source the files that get copied
+ * into the host project come first, entry point before the modules it pulls in,
+ * examples and tooling last. Anything unrecognised sorts to the end, so a
+ * newly added file still lands somewhere sensible.
+ */
+function handbookWeight(array $page): int
+{
+    if ($page["kind"] === "doc") {
+        return match (true) {
+            $page["key"] === "00_OVERVIEW" => 0,
+            $page["key"] === "README" => 1,
+            $page["key"] === "50_API" => 3,
+            default => 2,
+        };
+    }
+
+    $relative = $page["key"];
+
+    return match (true) {
+        $relative === "manage-client/config.sample.php" => 10,
+        $relative === "manage-client/lib/client.php" => 11,
+        $relative === "manage-client/lib/updater.php" => 12,
+        $relative === "manage-client/lib/backup.php" => 13,
+        $relative === "manage-client/lib/remote.php" => 14,
+        str_starts_with($relative, "manage-client/lib/") => 15,
+        str_starts_with($relative, "manage-client/bin/") => 16,
+        str_starts_with($relative, "manage-client/ui/") => 17,
+        str_starts_with($relative, "manage-client/") => 18,
+        str_starts_with($relative, "examples/") => 20,
+        str_starts_with($relative, "scripts/") => 21,
+        default => 30,
+    };
+}
+
+/** Third-party files that are named in the index but not served as pages. */
+function handbookVendoredFiles(): array
+{
+    $found = [];
+    foreach (handbookScanPackage() as $relative) {
+        if (in_array($relative, HANDBOOK_VENDORED, true)) {
+            $found[$relative] = (int) @filesize(HANDBOOK_PACKAGE_DIR . "/" . $relative);
+        }
+    }
+
+    return $found;
+}
+
+/**
+ * Recursively lists every file under client-package/, relative to it.
+ *
+ * @return string[] sorted relative paths
+ */
+function handbookScanPackage(): array
+{
+    static $files = null;
+    if ($files !== null) {
+        return $files;
+    }
+
+    $root = realpath(HANDBOOK_PACKAGE_DIR);
+    if ($root === false || !is_dir($root)) {
+        return $files = [];
+    }
+
+    $iterator = new RecursiveIteratorIterator(
+        new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
+        RecursiveIteratorIterator::SELF_FIRST,
+    );
+
+    $files = [];
+    foreach ($iterator as $item) {
+        if (!$item->isFile()) {
+            continue;
+        }
+
+        $relative = str_replace("\\", "/", substr($item->getPathname(), strlen($root) + 1));
+        $name = basename($relative);
+
+        // Local noise that is not part of the handed-out package.
+        if ($name === ".DS_Store" || str_ends_with($name, ".log")) {
+            continue;
+        }
+        if (in_array($relative, HANDBOOK_EXCLUDED, true)) {
+            continue;
+        }
+
+        $files[] = $relative;
+    }
+
+    sort($files, SORT_STRING);
+
+    return $files;
+}
+
+/** Page key for a Markdown file: the file name without prefix path. */
+function handbookDocKey(string $relative): string
+{
+    return basename($relative, ".md");
+}
+
+// ---------------------------------------------------------------------------
+// Titles and summaries
+// ---------------------------------------------------------------------------
+
+/**
+ * Title of a chapter: its own first-level heading, so the handbook shows what
+ * the document calls itself rather than a name derived from the file.
+ */
+function handbookDocTitle(string $path, string $key): string
+{
+    if (preg_match('/^#\s+(.+)$/m', handbookRead($path), $match) === 1) {
+        return trim($match[1]);
+    }
+
+    return ucwords(strtolower(str_replace("_", " ", preg_replace('/^\d+_/', "", $key) ?? $key)));
+}
+
+/**
+ * One line describing a chapter: its first prose paragraph, cut to the first
+ * sentence. Written by hand in every document, so nothing has to be maintained
+ * here in parallel.
+ */
+function handbookDocSummary(string $path): string
+{
+    $paragraph = "";
+    foreach (explode("\n", handbookRead($path)) as $line) {
+        $line = trim($line);
+
+        if ($paragraph === "") {
+            // Skip headings, quotes, lists, tables and fences before the prose.
+            if ($line === "" || preg_match('/^([#>|\-*+]|\d+\.|```)/', $line) === 1) {
+                continue;
+            }
+            $paragraph = $line;
+            continue;
+        }
+
+        if ($line === "") {
+            break;
+        }
+        $paragraph .= " " . $line;
+    }
+
+    return handbookFirstSentence($paragraph);
+}
+
+/**
+ * One line describing a source file: its first comment line. Every file in the
+ * package opens with one, in its own comment syntax.
+ */
+function handbookCodeSummary(string $path): string
+{
+    $handle = @fopen($path, "rb");
+    if ($handle === false) {
+        return "";
+    }
+
+    $summary = "";
+    $lines = 0;
+    while (($line = fgets($handle)) !== false && $lines < 30) {
+        $lines++;
+        $line = trim($line);
+
+        // Skip the preamble a file may carry before its first comment.
+        if ($summary === "") {
+            if ($line === "" || $line === "<?php" || str_starts_with($line, "#!")
+                || str_starts_with($line, "declare(")) {
+                continue;
+            }
+        }
+
+        if (preg_match('#^(//|\#|/\*\*?|\*)\s*(.*)$#', $line, $match) !== 1) {
+            break;
+        }
+
+        $text = trim($match[2]);
+        if ($text === "" || $text === "*/") {
+            // The blank line that ends the opening paragraph of the header.
+            if ($summary !== "") {
+                break;
+            }
+            continue;
+        }
+
+        // The header wraps over several lines; join them before cutting.
+        $summary = $summary === "" ? $text : $summary . " " . $text;
+    }
+
+    fclose($handle);
+
+    return handbookFirstSentence($summary);
+}
+
+function handbookFirstSentence(string $text): string
+{
+    $text = trim(preg_replace('/\s+/', " ", $text) ?? $text);
+    if ($text === "") {
+        return "";
+    }
+
+    // Cut after the first sentence, but not on an abbreviation or a version.
+    if (preg_match('/^(.{20,}?[.!?])(\s|$)/u', $text, $match) === 1) {
+        $text = $match[1];
+    }
+
+    if (mb_strlen($text) > 180) {
+        $text = mb_substr($text, 0, 177) . "…";
+    }
+
+    return rtrim($text, ".");
+}
+
+function handbookRead(string $path): string
+{
+    return is_file($path) ? (string) file_get_contents($path) : "";
+}
+
+function handbookFormatBytes(int $bytes): string
+{
+    if ($bytes < 1024) {
+        return $bytes . " B";
+    }
+
+    return number_format($bytes / 1024, 1, ",", ".") . " KB";
+}
+
+// ---------------------------------------------------------------------------
+// Page bodies
+// ---------------------------------------------------------------------------
+
+/** Fence language for a source file, by extension. */
+function handbookLanguage(string $relative): string
+{
+    $name = basename($relative);
+    if ($name === ".htaccess") {
+        return "apacheconf";
+    }
+    if (str_ends_with($name, ".cron")) {
+        return "text";
+    }
+
+    return match (strtolower(pathinfo($relative, PATHINFO_EXTENSION))) {
+        "php" => "php",
+        "sh", "bash" => "bash",
+        "css" => "css",
+        "js" => "javascript",
+        "json" => "json",
+        "md" => "markdown",
+        default => "text",
+    };
+}
+
+/**
+ * Picks a fence long enough to survive content that itself contains one.
+ * lib/zip.php and several chapters embed triple backticks.
+ */
+function handbookFenceFor(string $body): string
+{
+    $longest = 0;
+    if (preg_match_all('/^\s*(`{3,})/m', $body, $matches) > 0) {
+        foreach ($matches[1] as $run) {
+            $longest = max($longest, strlen($run));
+        }
+    }
+
+    return str_repeat("`", max(3, $longest + 1));
+}
+
+/**
+ * The Markdown body of a page.
+ *
+ * A chapter is served as written. A source file is wrapped in a fenced block
+ * under a heading naming its path, so both kinds of page are Markdown and can
+ * go through the same renderer.
+ */
+function handbookPageMarkdown(array $page, string $script = "index.php"): string
+{
+    $body = handbookRead($page["path"]);
+
+    if ($page["kind"] === "doc") {
+        $body = strtr($body, [
+            "{{BASE_URL}}" => handbookBaseUrl(),
+            "{{SELF_URL}}" => handbookSelfUrl(),
+            "{{SCRIPT}}" => $script,
+            "{{DOC_COUNT}}" => (string) count(handbookPagesOfKind("doc")),
+            "{{CODE_COUNT}}" => (string) count(handbookPagesOfKind("code")),
+        ]);
+
+        return handbookRewriteLinks($body, $script);
+    }
+
+    $fence = handbookFenceFor($body);
+
+    return "# " . $page["key"] . "\n\n"
+        . "> " . ($page["summary"] !== "" ? $page["summary"] . ". " : "")
+        . "Quelle: `" . $page["source"] . "`, " . handbookFormatBytes($page["bytes"]) . "\n\n"
+        . $fence . handbookLanguage($page["key"]) . "\n"
+        . rtrim($body) . "\n" . $fence . "\n";
+}
+
+/**
+ * Rewrites the links between chapters so they point at the neighbouring page
+ * of whichever rendering the reader is in.
+ *
+ * The documents link each other as `02_INTEGRATION.md`, which is right inside
+ * the package and inside the ZIP; here the same target is a query parameter.
+ */
+function handbookRewriteLinks(string $markdown, string $script = "index.php"): string
+{
+    return preg_replace_callback(
+        '/\]\(([^)\s]+\.md)(#[^)\s]*)?\)/',
+        static function (array $match) use ($script): string {
+            $key = basename($match[1], ".md");
+            if (handbookPage("doc", $key) === null) {
+                return $match[0];
+            }
+
+            return "](" . $script . "?doc=" . rawurlencode($key) . ($match[2] ?? "") . ")";
+        },
+        $markdown,
+    ) ?? $markdown;
+}
+
+// ---------------------------------------------------------------------------
+// Task recipes
+// ---------------------------------------------------------------------------
+
+/**
+ * Named jobs with the pages they need, for the index that agents read.
+ *
+ * The point of splitting the handbook is that an agent fetches a handful of
+ * pages instead of everything; without a shortlist per job it would have to
+ * fetch everything to find out which pages matter. Keys that no longer exist
+ * are dropped when the list is rendered, so a renamed document degrades to a
+ * shorter recipe rather than to a dead link.
+ */
+function handbookRecipes(): array
+{
+    return [
+        [
+            "title" => "Adding update and backup functionality to a project",
+            "note" => "The usual case. The package gets adopted, not rebuilt.",
+            "pages" => [
+                ["doc", "00_OVERVIEW"],
+                ["doc", "01_QUICKSTART"],
+                ["doc", "02_INTEGRATION"],
+                ["doc", "03_CONFIG_REFERENCE"],
+                ["code", "manage-client/config.sample.php"],
+                ["code", "manage-client/lib/client.php"],
+            ],
+        ],
+        [
+            "title" => "Setting up backups only",
+            "note" => "Without the updater. Define sources, choose targets, add cron.",
+            "pages" => [
+                ["doc", "05_BACKUP_SOURCES"],
+                ["doc", "03_CONFIG_REFERENCE"],
+                ["code", "manage-client/lib/backup.php"],
+                ["code", "manage-client/lib/remote.php"],
+                ["code", "examples/cron/manage-client.cron"],
+            ],
+        ],
+        [
+            "title" => "Setting up the updater only",
+            "note" => "Protected paths, packaging, migrations and the post-update hook.",
+            "pages" => [
+                ["doc", "06_UPDATE_PACKAGING"],
+                ["doc", "07_POST_UPDATE_HOOKS"],
+                ["code", "manage-client/lib/updater.php"],
+                ["code", "manage-client/lib/hooks.php"],
+                ["code", "scripts/create-release-zip.sh"],
+            ],
+        ],
+        [
+            "title" => "Writing your own client",
+            "note" => "A different language or framework. The protocol is binding, "
+                . "the PHP version is the reference.",
+            "pages" => [
+                ["doc", "08_PROTOCOL"],
+                ["doc", "50_API"],
+                ["doc", "10_SECURITY"],
+                ["code", "manage-client/lib/client.php"],
+                ["code", "manage-client/lib/updater.php"],
+                ["code", "manage-client/lib/backup.php"],
+            ],
+        ],
+        [
+            "title" => "Debugging a running installation",
+            "note" => "Messages, exit codes and their causes.",
+            "pages" => [
+                ["doc", "09_TROUBLESHOOTING"],
+                ["doc", "03_CONFIG_REFERENCE"],
+                ["code", "manage-client/bin/manage-client.php"],
+            ],
+        ],
+    ];
+}
+
+// ---------------------------------------------------------------------------
+// The OpenAPI document
+// ---------------------------------------------------------------------------
+
+/**
+ * The OpenAPI document for api/v1, with the live server URL substituted.
+ *
+ * openapi.json next to this directory is the source of truth; only the server
+ * entry is filled in, so the file stays valid on its own.
+ */
+function handbookOpenApiJson(): string
+{
+    $raw = handbookRead(__DIR__ . "/../openapi.json");
+
+    $spec = json_decode($raw, true);
+    if (!is_array($spec)) {
+        return $raw;
+    }
+
+    $spec["servers"] = [[
+        "url" => handbookBaseUrl() . "/api/v1",
+        "description" => "Diese Manage-Installation",
+    ]];
+
+    return (string) json_encode(
+        $spec,
+        JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
+    );
+}

+ 213 - 0
client-docs/index.php

@@ -0,0 +1,213 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * Public client handbook (Markdown -> HTML via the vendored marked.js).
+ *
+ * Same shape as the two viewers inside the repository: an index plus one page
+ * per document, reached as index.php?doc=KEY. In addition every source file of
+ * the client package has its own page, index.php?code=PATH.
+ *
+ * Self-contained: no build step and no external requests. The pages are read
+ * from client-package/ on each request, so they always match the repository.
+ *
+ * Agents do not read this rendering - the head of every page points at the
+ * Markdown twin under llms.php; see the hint below.
+ */
+
+require_once __DIR__ . "/inc/handbook.php";
+
+$pages = handbookPages();
+$docs = handbookPagesOfKind("doc");
+$code = handbookPagesOfKind("code");
+
+$requestedDoc = isset($_GET["doc"]) ? (string) $_GET["doc"] : "";
+$requestedCode = isset($_GET["code"]) ? (string) $_GET["code"] : "";
+
+$page = null;
+$markdown = null;
+$pageTitle = "Overview";
+$notFound = false;
+
+if ($requestedDoc !== "" || $requestedCode !== "") {
+    $page = $requestedDoc !== ""
+        ? handbookPage("doc", $requestedDoc)
+        : handbookPage("code", $requestedCode);
+
+    if ($page === null) {
+        http_response_code(404);
+        $pageTitle = "Not Found";
+        $notFound = true;
+    } else {
+        $markdown = handbookPageMarkdown($page);
+        $pageTitle = $page["title"];
+    }
+}
+
+// Works both when client-docs/ is a subdirectory and when it is served as the
+// web root (php -S localhost:8080 -t client-docs), where dirname() yields "/".
+$baseHref = rtrim(str_replace("\\", "/", dirname($_SERVER["SCRIPT_NAME"] ?? "")), "/") . "/";
+if ($baseHref === "") {
+    $baseHref = "/";
+}
+
+$indexUrl = handbookSelfUrl() . "/llms.php";
+$rawUrl = $page !== null ? handbookRawUrl($page) : $indexUrl;
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title><?php echo handbookEscape($pageTitle); ?> – Manage Client</title>
+    <link rel="stylesheet" href="<?php echo handbookEscape($baseHref); ?>assets/docs.css">
+    <link rel="alternate" type="text/markdown" href="<?php echo handbookEscape($rawUrl); ?>"
+          title="This page as Markdown">
+</head>
+<body>
+<!--
+    Note for LLMs and other programs:
+
+    This page renders its content only in the browser. The text is therefore
+    not in this HTML. The same page as plain Markdown, unstyled:
+
+        <?php echo $rawUrl . "\n"; ?>
+
+    Index of every page, with a short description and size, so you can load
+    only what you need:
+
+        <?php echo $indexUrl . "\n"; ?>
+-->
+<p class="llm-only">
+    For LLMs: this page as Markdown at <?php echo handbookEscape($rawUrl); ?> –
+    index of every page at <?php echo handbookEscape($indexUrl); ?>
+</p>
+<header class="docs-header">
+    <div class="docs-header-inner">
+        <a class="docs-brand" href="<?php echo handbookEscape($baseHref); ?>index.php">Manage Client</a>
+        <nav class="docs-header-links">
+            <a href="<?php echo handbookEscape($baseHref); ?>api.php">API Reference</a>
+            <a href="<?php echo handbookEscape($baseHref); ?>llms.php">For LLMs</a>
+        </nav>
+    </div>
+</header>
+<div class="docs-layout">
+    <nav class="docs-nav" aria-label="Documentation">
+        <p class="docs-nav-title">Documentation</p>
+        <ul>
+            <?php foreach ($docs as $entry): ?>
+                <li>
+                    <a href="<?php echo handbookEscape($baseHref); ?>index.php?<?php echo handbookEscape(handbookPageQuery($entry)); ?>"
+                       <?php echo handbookIsSamePage($page, $entry) ? 'aria-current="page"' : ""; ?>>
+                        <?php echo handbookEscape($entry["nav"]); ?>
+                    </a>
+                </li>
+            <?php endforeach; ?>
+        </ul>
+
+        <p class="docs-nav-title">Interface</p>
+        <ul>
+            <li><a href="<?php echo handbookEscape($baseHref); ?>api.php">Swagger UI</a></li>
+            <li><a href="<?php echo handbookEscape($baseHref); ?>openapi.php">OpenAPI document</a></li>
+        </ul>
+
+        <p class="docs-nav-title">Source Code</p>
+        <ul class="docs-nav-code">
+            <?php foreach ($code as $entry): ?>
+                <li>
+                    <a href="<?php echo handbookEscape($baseHref); ?>index.php?<?php echo handbookEscape(handbookPageQuery($entry)); ?>"
+                       <?php echo handbookIsSamePage($page, $entry) ? 'aria-current="page"' : ""; ?>>
+                        <?php echo handbookEscape($entry["nav"]); ?>
+                    </a>
+                </li>
+            <?php endforeach; ?>
+        </ul>
+    </nav>
+    <main class="docs-main">
+        <?php if ($page === null && !$notFound): ?>
+            <h1>Manage Client</h1>
+            <p>Update and backup functionality for standalone PHP projects: the
+                complete documentation of the client package, its entire source code,
+                and the interface to the Manage server.</p>
+            <p>Every chapter and every source file has its own page; the list is on
+                the left and again below, each with one sentence about what's on it.</p>
+            <p>For programs, the same pages exist as plain Markdown:
+                <a href="<?php echo handbookEscape($baseHref); ?>llms.php">llms.php</a> is the
+                index for that, and additionally names which pages are needed for which
+                task.</p>
+
+            <h2>Documentation</h2>
+            <p>In the recommended reading order.</p>
+            <dl class="docs-index-list">
+                <?php foreach ($docs as $entry): ?>
+                    <dt>
+                        <a href="<?php echo handbookEscape($baseHref); ?>index.php?<?php echo handbookEscape(handbookPageQuery($entry)); ?>">
+                            <?php echo handbookEscape($entry["nav"]); ?>
+                        </a>
+                    </dt>
+                    <dd><?php echo handbookSummaryHtml($entry["summary"]); ?></dd>
+                <?php endforeach; ?>
+            </dl>
+
+            <h2>Interface</h2>
+            <dl class="docs-index-list">
+                <dt><a href="<?php echo handbookEscape($baseHref); ?>api.php">API reference</a></dt>
+                <dd>Protocol v1 in Swagger UI, to look up and try out</dd>
+                <dt><a href="<?php echo handbookEscape($baseHref); ?>openapi.php">OpenAPI document</a></dt>
+                <dd>The same description as OpenAPI 3.1, for code generators</dd>
+            </dl>
+
+            <h2>Source Code</h2>
+            <p>The complete client package. Integrating it needs the folder
+                <code>manage-client/</code>; <code>examples/</code>,
+                <code>docs/</code> and <code>scripts/</code> are not part of that.</p>
+            <dl class="docs-index-list docs-index-code">
+                <?php foreach ($code as $entry): ?>
+                    <dt>
+                        <a href="<?php echo handbookEscape($baseHref); ?>index.php?<?php echo handbookEscape(handbookPageQuery($entry)); ?>">
+                            <?php echo handbookEscape($entry["nav"]); ?>
+                        </a>
+                        <span class="docs-size"><?php echo handbookEscape(handbookFormatBytes($entry["bytes"])); ?></span>
+                    </dt>
+                    <dd><?php echo handbookSummaryHtml($entry["summary"]); ?></dd>
+                <?php endforeach; ?>
+            </dl>
+        <?php elseif ($notFound): ?>
+            <h1>Not Found</h1>
+            <p>The requested page doesn't exist.</p>
+            <p><a href="<?php echo handbookEscape($baseHref); ?>index.php">Back to the overview</a></p>
+        <?php else: ?>
+            <p class="docs-source-note">
+                Source: <code><?php echo handbookEscape($page["source"]); ?></code>
+                · <?php echo handbookEscape(handbookFormatBytes($page["bytes"])); ?>
+                · <a href="<?php echo handbookEscape($rawUrl); ?>">as Markdown</a>
+            </p>
+            <article id="doc-content" class="markdown-body"></article>
+            <script type="application/json" id="doc-source"><?php
+                echo json_encode(
+                    $markdown,
+                    JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE,
+                );
+            ?></script>
+        <?php endif; ?>
+    </main>
+</div>
+<?php if ($markdown !== null): ?>
+    <script src="<?php echo handbookEscape($baseHref); ?>assets/marked.min.js"></script>
+    <script>
+        (function () {
+            var source = document.getElementById('doc-source');
+            var target = document.getElementById('doc-content');
+            if (!source || !target || typeof marked === 'undefined') {
+                return;
+            }
+            target.innerHTML = marked.parse(JSON.parse(source.textContent || '""'), {
+                gfm: true,
+                breaks: false
+            });
+        })();
+    </script>
+<?php endif; ?>
+</body>
+</html>

+ 152 - 0
client-docs/llms.php

@@ -0,0 +1,152 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * The handbook as plain Markdown, one page per request.
+ *
+ *   llms.php                  index: every page with a summary and its size
+ *   llms.php?doc=KEY          one chapter, as written
+ *   llms.php?code=PATH        one source file
+ *
+ * Apache also answers llms.txt for the index, see .htaccess. That alias is a
+ * convenience, not the canonical address: it needs mod_rewrite, and this file
+ * has to work without it.
+ *
+ * This is the rendering meant for agents. It exists separately from index.php
+ * so that a fetch costs only the page that was asked for: the index says what
+ * each page contains and how large it is, and names the pages a given job
+ * needs, so nothing has to be pulled in on the chance it might be relevant.
+ */
+
+require_once __DIR__ . "/inc/handbook.php";
+
+$requestedDoc = isset($_GET["doc"]) ? (string) $_GET["doc"] : "";
+$requestedCode = isset($_GET["code"]) ? (string) $_GET["code"] : "";
+
+if ($requestedDoc === "" && $requestedCode === "") {
+    handbookSendText(handbookRenderIndex());
+}
+
+$page = $requestedDoc !== ""
+    ? handbookPage("doc", $requestedDoc)
+    : handbookPage("code", $requestedCode);
+
+if ($page === null) {
+    http_response_code(404);
+    handbookSendText(
+        "# Not Found\n\n"
+        . "This page doesn't exist. The index of every page is at\n"
+        . handbookSelfUrl() . "/llms.php\n",
+    );
+}
+
+// Chapters link each other by file name; inside this rendering the neighbour
+// is another llms.php page, so a following request stays in plain Markdown.
+handbookSendText(
+    rtrim(handbookPageMarkdown($page, "llms.php")) . "\n\n"
+    . "---\n\n"
+    . "Index of every page: " . handbookSelfUrl() . "/llms.php\n"
+    . "This page for humans: " . handbookPageUrl($page) . "\n",
+);
+
+/** Emits a Markdown document and ends the request. */
+function handbookSendText(string $text): void
+{
+    header("Content-Type: text/plain; charset=utf-8");
+    header("Content-Length: " . (string) strlen($text));
+    header("X-Content-Type-Options: nosniff");
+    // Cheap to rebuild, but an agent walking several pages should not pay for
+    // a revalidation on each one.
+    header("Cache-Control: public, max-age=300");
+
+    echo $text;
+    exit;
+}
+
+/**
+ * The index: what exists, how big it is, and which pages a given job needs.
+ */
+function handbookRenderIndex(): string
+{
+    $self = handbookSelfUrl();
+    $docs = handbookPagesOfKind("doc");
+    $code = handbookPagesOfKind("code");
+
+    $total = 0;
+    foreach (handbookPages() as $page) {
+        $total += $page["bytes"];
+    }
+
+    $out = [];
+    $out[] = strtr(handbookRead(HANDBOOK_CONTENT_DIR . "/llms-intro.md"), [
+        "{{SELF_URL}}" => $self,
+        "{{BASE_URL}}" => handbookBaseUrl(),
+        "{{PAGE_COUNT}}" => (string) count(handbookPages()),
+        "{{TOTAL_SIZE}}" => handbookFormatBytes($total),
+    ]);
+
+    // ---- Recipes: the shortlist per job ------------------------------------
+    $out[] = "## What Each Page Is For\n";
+    foreach (handbookRecipes() as $recipe) {
+        $lines = ["### " . $recipe["title"], "", $recipe["note"], ""];
+
+        foreach ($recipe["pages"] as [$kind, $key]) {
+            $page = handbookPage($kind, $key);
+            if ($page === null) {
+                continue;
+            }
+            $lines[] = "- " . handbookRawUrl($page) . " – " . $page["title"];
+        }
+
+        $out[] = implode("\n", $lines) . "\n";
+    }
+
+    // ---- Every page --------------------------------------------------------
+    $out[] = handbookRenderList(
+        "## Documentation",
+        "Chapters in the recommended reading order.",
+        $docs,
+    );
+
+    $out[] = "## Interface\n\n"
+        . "- " . $self . "/openapi.php – OpenAPI 3.1 of the Manage server's four endpoints, "
+        . "as JSON. Read together with the *Protocol v1* chapter for a custom client.\n"
+        . "- " . $self . "/api.php – the same description in Swagger UI. A page for "
+        . "humans, no use to a program.\n";
+
+    $out[] = handbookRenderList(
+        "## Source Code",
+        "The complete client package, every file individually retrievable. Integrating it "
+        . "needs the folder `manage-client/`; `examples/`, `docs/` and `scripts/` "
+        . "are not part of that.",
+        $code,
+    );
+
+    $vendored = handbookVendoredFiles();
+    if ($vendored !== []) {
+        $lines = ["## Not Reproduced Here", "", "Third-party libraries that are part of the "
+            . "package but don't have a page here:", ""];
+        foreach ($vendored as $relative => $bytes) {
+            $lines[] = "- `client-package/" . $relative . "` (" . handbookFormatBytes($bytes) . ")";
+        }
+        $out[] = implode("\n", $lines) . "\n";
+    }
+
+    return implode("\n", $out);
+}
+
+/** @param array<string, array> $pages */
+function handbookRenderList(string $heading, string $intro, array $pages): string
+{
+    $lines = [$heading, "", $intro, ""];
+
+    foreach ($pages as $page) {
+        $lines[] = "- " . handbookRawUrl($page)
+            . " (" . handbookFormatBytes($page["bytes"]) . ")"
+            . " – **" . $page["title"] . "**"
+            . ($page["summary"] !== "" ? ". " . $page["summary"] : "");
+    }
+
+    return implode("\n", $lines) . "\n";
+}

+ 427 - 0
client-docs/openapi.json

@@ -0,0 +1,427 @@
+{
+    "openapi": "3.1.0",
+    "info": {
+        "title": "Manage – Protocol v1",
+        "version": "1.0.0",
+        "summary": "Update and backup interface between a project instance and the Manage server.",
+        "description": "The four endpoints a client needs: fetch the release manifest, download the release package, upload a backup, report status.\n\nEvery request authenticates with two headers (`X-Manage-Instance`, `X-Manage-Token`). There is no session, no cookie and no shared password. The server stores only the SHA-256 hash of the token.\n\nEvery successfully authenticated request additionally updates `last_seen_at` and the instance's latest IP.\n\nThe reference implementation of the client is fully documented in the handbook; see `lib/remote.php`, `lib/updater.php` and `lib/backup.php`.\n\nNote: the server's actual `error` strings in the response examples below are still German text — this document's descriptions are in English, the API's literal responses are not.",
+        "license": {
+            "name": "See the handbook"
+        }
+    },
+    "servers": [
+        {
+            "url": "/api/v1",
+            "description": "This Manage installation"
+        }
+    ],
+    "tags": [
+        {
+            "name": "Update",
+            "description": "Determine the release and fetch the package."
+        },
+        {
+            "name": "Backup",
+            "description": "Transfer backup archives to the server."
+        },
+        {
+            "name": "Status",
+            "description": "Report the instance's state."
+        }
+    ],
+    "security": [
+        {
+            "instanceId": [],
+            "instanceToken": []
+        }
+    ],
+    "paths": {
+        "/manifest.php": {
+            "get": {
+                "tags": ["Update"],
+                "operationId": "getManifest",
+                "summary": "Fetch the release the instance should install",
+                "description": "Returns version, download address, size and SHA-256 of the current release.\n\n`package_url` is built from the server configuration (`MANAGE_PUBLIC_URL`), not from the request's `Host` header. A spoofed header therefore can't redirect a client to a foreign server.\n\nRelease metadata is not public: the endpoint requires a valid token.",
+                "responses": {
+                    "200": {
+                        "description": "Current release",
+                        "content": {
+                            "application/json": {
+                                "schema": { "$ref": "#/components/schemas/Manifest" },
+                                "example": {
+                                    "success": true,
+                                    "latest": "v1.3.0",
+                                    "version": "v1.3.0",
+                                    "package_url": "https://manage.example.org/api/v1/package.php?version=v1.3.0",
+                                    "sha256": "70f17aae44a9afdd948de1767daa61f936bbecb52096753757791e230a22f024",
+                                    "size": 2199,
+                                    "published_at": "2026-08-20T09:20:43+00:00"
+                                }
+                            }
+                        }
+                    },
+                    "401": { "$ref": "#/components/responses/Unauthorized" },
+                    "403": { "$ref": "#/components/responses/Disabled" },
+                    "404": {
+                        "description": "No valid release is published.",
+                        "content": {
+                            "application/json": {
+                                "schema": { "$ref": "#/components/schemas/Error" },
+                                "example": { "success": false, "error": "Es ist kein gültiges Release veröffentlicht." }
+                            }
+                        }
+                    },
+                    "405": { "$ref": "#/components/responses/MethodNotAllowed" },
+                    "429": { "$ref": "#/components/responses/RateLimited" },
+                    "500": { "$ref": "#/components/responses/ServerError" }
+                }
+            }
+        },
+        "/package.php": {
+            "get": {
+                "tags": ["Update"],
+                "operationId": "getPackage",
+                "summary": "Download the release ZIP",
+                "description": "Streams the release archive. The response is `application/zip` with `Content-Length` and `Cache-Control: private, no-store`; no JSON on success.\n\nThe client **must** check size and SHA-256 against the manifest and delete the file on mismatch. Without this check, arbitrary code gets deployed.",
+                "parameters": [
+                    {
+                        "name": "version",
+                        "in": "query",
+                        "required": true,
+                        "description": "Version in `vMAJOR.MINOR.PATCH` format.",
+                        "schema": { "$ref": "#/components/schemas/Version" },
+                        "example": "v1.3.0"
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "The release archive",
+                        "headers": {
+                            "Content-Disposition": {
+                                "description": "attachment; filename=\"…zip\"",
+                                "schema": { "type": "string" }
+                            },
+                            "Content-Length": {
+                                "description": "Size in bytes, identical to `size` from the manifest.",
+                                "schema": { "type": "integer" }
+                            },
+                            "Cache-Control": {
+                                "description": "Always `private, no-store`.",
+                                "schema": { "type": "string" }
+                            }
+                        },
+                        "content": {
+                            "application/zip": {
+                                "schema": { "type": "string", "format": "binary" }
+                            }
+                        }
+                    },
+                    "400": {
+                        "description": "Invalid version format",
+                        "content": {
+                            "application/json": {
+                                "schema": { "$ref": "#/components/schemas/Error" },
+                                "example": { "success": false, "error": "Ungültige Version." }
+                            }
+                        }
+                    },
+                    "401": { "$ref": "#/components/responses/Unauthorized" },
+                    "403": { "$ref": "#/components/responses/Disabled" },
+                    "404": {
+                        "description": "Release does not exist",
+                        "content": {
+                            "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
+                        }
+                    },
+                    "405": { "$ref": "#/components/responses/MethodNotAllowed" },
+                    "429": { "$ref": "#/components/responses/RateLimited" },
+                    "500": { "$ref": "#/components/responses/ServerError" }
+                }
+            }
+        },
+        "/backup.php": {
+            "post": {
+                "tags": ["Backup"],
+                "operationId": "uploadBackup",
+                "summary": "Upload a backup archive",
+                "description": "Accepts a ZIP and stores it under the instance.\n\nChecked in this order: upload error code, `is_uploaded_file`, size limit (`MANAGE_BACKUP_MAX_UPLOAD_BYTES`), ZIP signature, filename pattern, checksum after storing. If the checksum doesn't match, the file is deleted again and `400` is reported.\n\nAn existing filename is never overwritten: the server appends `-2`, `-3` and reports the name actually used.\n\nErrors during S3 archiving do **not** fail the upload – the local copy is stored and gets caught up later.",
+                "requestBody": {
+                    "required": true,
+                    "content": {
+                        "multipart/form-data": {
+                            "schema": {
+                                "type": "object",
+                                "required": ["backup"],
+                                "properties": {
+                                    "backup": {
+                                        "type": "string",
+                                        "format": "binary",
+                                        "description": "The ZIP archive."
+                                    },
+                                    "filename": {
+                                        "type": "string",
+                                        "pattern": "^backup-\\d{8}-\\d{6}(?:-\\d+)?\\.zip$",
+                                        "description": "Desired name. The server assigns `backup-YYYYmmdd-HHMMSS.zip` if omitted.",
+                                        "example": "backup-20260820-092104.zip"
+                                    },
+                                    "sha256": {
+                                        "type": "string",
+                                        "pattern": "^[a-f0-9]{64}$",
+                                        "description": "Checksum of the archive. Recomputed and compared server-side."
+                                    },
+                                    "meta": {
+                                        "type": "string",
+                                        "description": "JSON object with `trigger`, `file_count`, `source_bytes`, `app_version`.",
+                                        "example": "{\"trigger\":\"cron\",\"file_count\":3,\"source_bytes\":63,\"app_version\":\"v1.3.0\"}"
+                                    }
+                                }
+                            },
+                            "encoding": {
+                                "backup": { "contentType": "application/zip" }
+                            }
+                        }
+                    }
+                },
+                "responses": {
+                    "200": {
+                        "description": "Backup stored",
+                        "content": {
+                            "application/json": {
+                                "schema": { "$ref": "#/components/schemas/BackupResult" },
+                                "example": {
+                                    "success": true,
+                                    "instance": "myproject-prod",
+                                    "filename": "backup-20260820-092104.zip",
+                                    "size": 427,
+                                    "sha256": "824f3f8000000000000000000000000000000000000000000000000000000000",
+                                    "retention": 30,
+                                    "s3": { "enabled": false, "uploaded": false, "pending": 0 }
+                                }
+                            }
+                        }
+                    },
+                    "400": {
+                        "description": "Upload rejected: file missing, limit exceeded, not a ZIP, wrong name or checksum.",
+                        "content": {
+                            "application/json": {
+                                "schema": { "$ref": "#/components/schemas/Error" },
+                                "example": { "success": false, "error": "Die hochgeladene Datei muss ein ZIP-Archiv sein." }
+                            }
+                        }
+                    },
+                    "401": { "$ref": "#/components/responses/Unauthorized" },
+                    "403": { "$ref": "#/components/responses/Disabled" },
+                    "405": { "$ref": "#/components/responses/MethodNotAllowed" },
+                    "429": { "$ref": "#/components/responses/RateLimited" }
+                }
+            }
+        },
+        "/heartbeat.php": {
+            "post": {
+                "tags": ["Status"],
+                "operationId": "sendHeartbeat",
+                "summary": "Report status and receive update information",
+                "description": "Reports the instance's state. All fields are optional; missing fields leave the server's current value unchanged.\n\nThe response includes the update information, so it replaces a separate call to `manifest.php` for simple monitoring.",
+                "requestBody": {
+                    "required": false,
+                    "content": {
+                        "application/json": {
+                            "schema": { "$ref": "#/components/schemas/HeartbeatRequest" },
+                            "example": {
+                                "version": "v1.3.0",
+                                "php_version": "8.3.6",
+                                "disk_free": 12884901888,
+                                "pending_migrations": 0,
+                                "last_backup_at": "2026-08-20T09:21:04+00:00"
+                            }
+                        }
+                    }
+                },
+                "responses": {
+                    "200": {
+                        "description": "Status accepted",
+                        "content": {
+                            "application/json": {
+                                "schema": { "$ref": "#/components/schemas/HeartbeatResponse" },
+                                "example": {
+                                    "success": true,
+                                    "instance": "myproject-prod",
+                                    "latest": "v1.3.0",
+                                    "update_available": false,
+                                    "server_time": "2026-08-20T09:23:11+00:00"
+                                }
+                            }
+                        }
+                    },
+                    "400": {
+                        "description": "Request body is not valid JSON.",
+                        "content": {
+                            "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
+                        }
+                    },
+                    "401": { "$ref": "#/components/responses/Unauthorized" },
+                    "403": { "$ref": "#/components/responses/Disabled" },
+                    "405": { "$ref": "#/components/responses/MethodNotAllowed" },
+                    "429": { "$ref": "#/components/responses/RateLimited" }
+                }
+            }
+        }
+    },
+    "components": {
+        "securitySchemes": {
+            "instanceId": {
+                "type": "apiKey",
+                "in": "header",
+                "name": "X-Manage-Instance",
+                "description": "The instance id, for example `myproject-prod`."
+            },
+            "instanceToken": {
+                "type": "apiKey",
+                "in": "header",
+                "name": "X-Manage-Token",
+                "description": "The token shown once when the instance was created."
+            }
+        },
+        "responses": {
+            "Unauthorized": {
+                "description": "Headers missing, instance unknown, or token wrong – deliberately indistinguishable, so instance ids can't be brute-forced.",
+                "content": {
+                    "application/json": {
+                        "schema": { "$ref": "#/components/schemas/Error" },
+                        "example": { "success": false, "error": "Authentifizierung fehlgeschlagen." }
+                    }
+                }
+            },
+            "Disabled": {
+                "description": "Instance exists but is deactivated.",
+                "content": {
+                    "application/json": {
+                        "schema": { "$ref": "#/components/schemas/Error" },
+                        "example": { "success": false, "error": "Diese Instanz ist deaktiviert." }
+                    }
+                }
+            },
+            "MethodNotAllowed": {
+                "description": "Wrong HTTP method. The response carries an `Allow` header.",
+                "headers": {
+                    "Allow": { "schema": { "type": "string" } }
+                },
+                "content": {
+                    "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
+                }
+            },
+            "RateLimited": {
+                "description": "Too many failed authentications from this IP (`MANAGE_API_RATE_LIMIT_MAX` per `MANAGE_API_RATE_LIMIT_WINDOW` seconds, 240 per 300s out of the box). A successful login resets the counter.",
+                "content": {
+                    "application/json": {
+                        "schema": { "$ref": "#/components/schemas/Error" },
+                        "example": { "success": false, "error": "Zu viele Anfragen. Bitte später erneut versuchen." }
+                    }
+                }
+            },
+            "ServerError": {
+                "description": "Unexpected server error; details are in the server log.",
+                "content": {
+                    "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
+                }
+            }
+        },
+        "schemas": {
+            "Version": {
+                "type": "string",
+                "pattern": "^v\\d+\\.\\d+\\.\\d+$",
+                "examples": ["v1.3.0"]
+            },
+            "Error": {
+                "type": "object",
+                "description": "Shape of **every** error response.",
+                "required": ["success", "error"],
+                "properties": {
+                    "success": { "type": "boolean", "const": false },
+                    "error": { "type": "string", "description": "Plain-text message, for logging and display." }
+                }
+            },
+            "Manifest": {
+                "type": "object",
+                "required": ["success", "latest", "version", "package_url", "sha256", "size", "published_at"],
+                "properties": {
+                    "success": { "type": "boolean", "const": true },
+                    "latest": { "$ref": "#/components/schemas/Version" },
+                    "version": {
+                        "allOf": [{ "$ref": "#/components/schemas/Version" }],
+                        "description": "Equivalent to `latest`; both fields exist for compatibility reasons."
+                    },
+                    "package_url": {
+                        "type": "string",
+                        "format": "uri",
+                        "description": "Absolute download address, built from `MANAGE_PUBLIC_URL`."
+                    },
+                    "sha256": {
+                        "type": "string",
+                        "pattern": "^[a-f0-9]{64}$",
+                        "description": "Checksum of the package. Must be verified by the client."
+                    },
+                    "size": { "type": "integer", "description": "Package size in bytes." },
+                    "published_at": { "type": "string", "format": "date-time" }
+                }
+            },
+            "BackupResult": {
+                "type": "object",
+                "required": ["success", "instance", "filename", "size", "sha256", "retention", "s3"],
+                "properties": {
+                    "success": { "type": "boolean", "const": true },
+                    "instance": { "type": "string" },
+                    "filename": {
+                        "type": "string",
+                        "description": "The name actually assigned – may differ from the one requested if it was already taken."
+                    },
+                    "size": { "type": "integer" },
+                    "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
+                    "retention": {
+                        "type": "integer",
+                        "description": "How many days the server keeps this archive."
+                    },
+                    "s3": {
+                        "type": "object",
+                        "description": "State of the optional S3 archive. Never a reason for failure.",
+                        "properties": {
+                            "enabled": { "type": "boolean" },
+                            "uploaded": { "type": "boolean" },
+                            "pending": { "type": "integer" }
+                        }
+                    }
+                }
+            },
+            "HeartbeatRequest": {
+                "type": "object",
+                "properties": {
+                    "version": {
+                        "type": "string",
+                        "description": "Installed version of the application. Empty if it can't be determined."
+                    },
+                    "php_version": { "type": "string", "examples": ["8.3.6"] },
+                    "disk_free": { "type": "integer", "description": "Free disk space in bytes." },
+                    "pending_migrations": { "type": "integer", "minimum": 0 },
+                    "last_backup_at": { "type": "string", "format": "date-time" }
+                }
+            },
+            "HeartbeatResponse": {
+                "type": "object",
+                "required": ["success", "instance", "latest", "update_available", "server_time"],
+                "properties": {
+                    "success": { "type": "boolean", "const": true },
+                    "instance": { "type": "string" },
+                    "latest": {
+                        "type": "string",
+                        "description": "Current release on the server, or empty if none is published."
+                    },
+                    "update_available": {
+                        "type": "boolean",
+                        "description": "Result of a `version_compare` between `latest` and the reported version. `false` as long as the instance reports no version."
+                    },
+                    "server_time": { "type": "string", "format": "date-time" }
+                }
+            }
+        }
+    }
+}

+ 23 - 0
client-docs/openapi.php

@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * The OpenAPI document for api/v1, with the server URL of this installation
+ * filled in.
+ *
+ * Served through PHP because the repository denies direct access to .json
+ * files; openapi.json next to this file is the source of truth.
+ */
+
+require_once __DIR__ . "/inc/handbook.php";
+
+$json = handbookOpenApiJson();
+
+header("Content-Type: application/json; charset=utf-8");
+header("Content-Disposition: inline; filename=\"manage-openapi.json\"");
+header("Content-Length: " . (string) strlen($json));
+header("X-Content-Type-Options: nosniff");
+header("Cache-Control: public, max-age=300");
+
+echo $json;

+ 96 - 81
client-package/README.md

@@ -1,122 +1,137 @@
-# Manage Client-Paket
+# Manage Client Package
 
-Update- und Backup-Client für PHP-Projekte, zusammen mit der vollständigen
-Dokumentation zur Einbindung. Dieser Ordner ist so gebaut, dass er als einzelnes
-ZIP weitergegeben werden kann: Wer ihn erhält, braucht weder Zugriff auf das
-Repository des Manage-Servers noch weitere Erklärungen.
+Update and backup client for PHP projects, together with the complete
+documentation for integrating it. This folder is built so it can be handed
+over as a single ZIP: whoever receives it needs neither access to the
+Manage server's repository nor further explanation.
 
-## Was das ist
+## What this is
 
-Ein PHP-Client, der
+A PHP client that
 
-- Releases von einem zentralen Manage-Server holt, prüft und einspielt,
-- Backups der Betriebsdaten erstellt und dorthin hochlädt,
-- den Status der Installation an den Server meldet.
+- fetches, verifies and installs releases from a central Manage server,
+- creates backups of operational data and uploads them there,
+- reports the installation's status to the server.
 
-Der Client läuft eigenständig über die Kommandozeile, bringt eine fertige
-Oberfläche für den Adminbereich mit und stellt alle Funktionen zum direkten
-Aufruf aus dem Projekt bereit. Alle drei Wege rufen dieselben Funktionen auf, ein
-Vorgang verhält sich also überall gleich.
+The client runs standalone from the command line, brings a ready-made UI
+for the admin area, and exposes every function for direct calls from the
+project. All three paths call the same functions, so a given operation
+behaves identically no matter how it's triggered.
 
-Keine Abhängigkeiten: kein Composer, kein Build-Schritt, keine externen
-Bibliotheken. PHP 8.0 oder neuer, `ext-zip` für Updates, Schreibrechte auf dem
-Datenverzeichnis.
+No dependencies: no Composer, no build step, no external libraries. PHP 8.0
+or newer, `ext-zip` for updates, write access to the data directory.
 
-## In fünf Minuten
+## In five minutes
 
 ```bash
-# 1. Ordner ins Projekt kopieren
-cp -r manage-client /pfad/zum/projekt/manage-client
+# 1. Copy the folder into the project
+cp -r manage-client /path/to/project/manage-client
 
-# 2. Instanz im Manage-Server anlegen und das einmalig angezeigte Token kopieren
+# 2. Create an instance on the Manage server and copy the token shown once
 
-# 3. Konfiguration anlegen
-cd /pfad/zum/projekt/manage-client
+# 3. Create the configuration
+cd /path/to/project/manage-client
 cp config.sample.php config.php
-# MANAGE_SERVER_URL, MANAGE_INSTANCE, MANAGE_TOKEN und MANAGE_BACKUP_SOURCES setzen
+# Set MANAGE_SERVER_URL, MANAGE_INSTANCE, MANAGE_TOKEN and MANAGE_BACKUP_SOURCES
 
-# 4. Verbindung prüfen
+# 4. Check the connection
 php bin/manage-client.php status
 
-# 5. Erstes Backup
+# 5. First backup
 php bin/manage-client.php backup
 ```
 
-Ausführlich: [docs/01_QUICKSTART.md](docs/01_QUICKSTART.md).
+Details: [docs/01_QUICKSTART.md](docs/01_QUICKSTART.md).
 
-## Inhalt
+## Contents
 
 ```text
-manage-client/            <- dieser Ordner wird ins Projekt kopiert
-  config.sample.php       Vorlage, wird zu config.php
-  lib/                    die Bibliothek; client.php ist der einzige Einstiegspunkt
-  bin/manage-client.php   Kommandozeile für Cron und Shell
-  ui/panel.php            fertige Seite für den Adminbereich
-  ui/status-partial.php   kleiner Statusblock für eine bestehende Seite
-
-docs/                     die Dokumentation, siehe unten
-examples/                 lauffähige Beispiele zum Abschreiben
+manage-client/            <- this folder gets copied into the project
+  config.sample.php       template, becomes config.php
+  lib/                    the library; client.php is the only entry point
+  bin/manage-client.php   command line for cron and shell
+  ui/panel.php            ready-made page for the admin area
+  ui/status-partial.php   small status block for an existing page
+
+docs/                     the documentation, see below
+examples/                 working examples to copy from
+scripts/                  build script for release packages, see below
 ```
 
-Im Browser lesbar: `docs/index.php` über einen beliebigen PHP-Server öffnen, zum
-Beispiel `php -S localhost:8080 -t docs`. Die Dateien sind auch als reines
-Markdown lesbar.
+Readable in the browser: open `docs/index.php` through any PHP server, for
+example `php -S localhost:8080 -t docs`. The files are also readable as
+plain Markdown.
 
-## Dokumentation
+## Documentation
 
-| Dokument | Inhalt |
+| Document | Content |
 |---|---|
-| [01_QUICKSTART](docs/01_QUICKSTART.md) | Von diesem Ordner zum ersten Backup |
-| [02_INTEGRATION](docs/02_INTEGRATION.md) | Einbindung in ein bestehendes Projekt, Cron, Rechte |
-| [03_CONFIG_REFERENCE](docs/03_CONFIG_REFERENCE.md) | Jede Konstante mit Standardwert und Bedeutung |
-| [04_FUNCTION_API](docs/04_FUNCTION_API.md) | Die aufrufbaren Funktionen mit Rückgabewerten |
-| [05_BACKUP_SOURCES](docs/05_BACKUP_SOURCES.md) | Was gesichert wird, Datenbank-Dump, zusätzliche Ziele |
-| [06_UPDATE_PACKAGING](docs/06_UPDATE_PACKAGING.md) | Wie ein Release-Paket gebaut werden muss |
-| [07_POST_UPDATE_HOOKS](docs/07_POST_UPDATE_HOOKS.md) | Migrationen und Projekt-Callback nach dem Update |
-| [08_PROTOCOL](docs/08_PROTOCOL.md) | Die HTTP-Schnittstelle, für eigene Clients und Debugging |
-| [09_TROUBLESHOOTING](docs/09_TROUBLESHOOTING.md) | Jede Fehlermeldung mit Ursache und Behebung |
-| [10_SECURITY](docs/10_SECURITY.md) | Token, Berechtigungen, Checkliste vor dem Produktivgang |
-
-Empfohlene Reihenfolge beim ersten Mal: 01, 02, 05, 06. Der Rest ist Nachschlagewerk.
-
-## Beispiele
-
-| Datei | Inhalt |
+| [01_QUICKSTART](docs/01_QUICKSTART.md) | from this folder to the first backup |
+| [02_INTEGRATION](docs/02_INTEGRATION.md) | integration into an existing project, cron, permissions |
+| [03_CONFIG_REFERENCE](docs/03_CONFIG_REFERENCE.md) | every constant with its default value and meaning |
+| [04_FUNCTION_API](docs/04_FUNCTION_API.md) | the callable functions with return values |
+| [05_BACKUP_SOURCES](docs/05_BACKUP_SOURCES.md) | what gets backed up, database dumps, extra targets |
+| [06_UPDATE_PACKAGING](docs/06_UPDATE_PACKAGING.md) | how a release package must be built |
+| [07_POST_UPDATE_HOOKS](docs/07_POST_UPDATE_HOOKS.md) | migrations and the project callback after an update |
+| [08_PROTOCOL](docs/08_PROTOCOL.md) | the HTTP interface, for custom clients and debugging |
+| [09_TROUBLESHOOTING](docs/09_TROUBLESHOOTING.md) | every error message with cause and fix |
+| [10_SECURITY](docs/10_SECURITY.md) | token, permissions, checklist before going live |
+
+Recommended order for the first read: 01, 02, 05, 06. The rest is reference
+material.
+
+## Examples
+
+| File | Content |
 |---|---|
-| `examples/flat-file-project/` | Projekt mit JSON-Dateien, inklusive Beispielmigration |
-| `examples/mysql-project/` | Projekt mit MySQL, Datenbank-Dump und `ALTER TABLE`-Migration |
-| `examples/after-update.php` | Vorlage für den Post-Update-Callback |
-| `examples/integration-snippet.php` | Die Zeilen, die ein bestehendes Projekt braucht |
-| `examples/cron/manage-client.cron` | Fertige Crontab-Zeilen |
+| `examples/flat-file-project/` | project with JSON files, including an example migration |
+| `examples/mysql-project/` | project with MySQL, database dump and an `ALTER TABLE` migration |
+| `examples/after-update.php` | template for the post-update callback |
+| `examples/integration-snippet.php` | the lines an existing project needs |
+| `examples/cron/manage-client.cron` | ready-made crontab lines |
 
-## Befehle
+## Building release packages
+
+`scripts/create-release-zip.sh` builds the ZIP from a project that gets
+uploaded to the Manage server as a release. It gets copied to `scripts/` of
+the project, adjusted once at the top (product name, version file,
+exclusions), and then run from the project directory:
+
+```bash
+./scripts/create-release-zip.sh v1.3.0
+```
+
+Details: [docs/06_UPDATE_PACKAGING.md](docs/06_UPDATE_PACKAGING.md).
+
+## Commands
 
 ```text
 php manage-client/bin/manage-client.php status
-                                        check                      Exit 2 = Update verfügbar
+                                        check                      exit 2 = update available
                                         update [--force] [--yes] [--skip-hook]
                                         migrate [--dry-run]
                                         backup [--trigger=cron]
                                         heartbeat
 ```
 
-Exit-Codes: `0` Erfolg, `1` Fehler, `2` Update verfügbar (nur bei `check`).
-`--quiet` unterdrückt die normale Ausgabe, Fehler gehen weiterhin auf STDERR.
+Exit codes: `0` success, `1` error, `2` update available (`check` only).
+`--quiet` suppresses normal output; errors still go to STDERR.
 
-## Was der Client bewusst nicht tut
+## What the client deliberately does not do
 
-- **Keine Wiederherstellung.** Backups werden erstellt und übertragen, aber nie
-  zurückgespielt. Ein Update sichert die überschriebenen Dateien, kann sie aber
-  nicht zurückholen. Beides ist bewusst Handarbeit.
-- **Keine automatischen Updates.** `update` ist immer eine bewusste Entscheidung.
-- **Kein Entfernen gelöschter Dateien.** Ein Update überlagert den Bestand; was im
-  neuen Release fehlt, bleibt liegen. Soll es verschwinden, gehört das in eine
-  Migration.
-- **Kein Wartungsmodus.** Die Anwendung bleibt während des Ausrollens erreichbar.
+- **No restore.** Backups are created and transferred, but never played
+  back. An update backs up the files it overwrites, but cannot bring them
+  back. Both are deliberately manual work.
+- **No automatic updates.** `update` is always a deliberate decision.
+- **No removal of deleted files.** An update overlays what's there; a file
+  missing from the new release stays behind. To make it disappear, that
+  belongs in a migration.
+- **No maintenance mode.** The application stays reachable while rolling
+  out.
 
-## Anpassen
+## Customizing
 
-Der Ordner wird pro Projekt gepflegt. Anpassungen an `ui/panel.php` – etwa an die
-Anmeldung oder das Aussehen des Projekts – sind vorgesehen und ausdrücklich
-erwünscht. Änderungen in `lib/` sollten sparsam bleiben, damit eine neuere Fassung
-des Client-Pakets noch übernommen werden kann.
+This folder is maintained per project. Adjustments to `ui/panel.php` — for
+example to the login or the project's look — are expected and explicitly
+welcome. Changes inside `lib/` should stay sparse, so a newer version of
+the client package can still be adopted.

+ 46 - 41
client-package/docs/01_QUICKSTART.md

@@ -1,51 +1,54 @@
 # Quickstart
 
-## Überblick
+## Overview
 
-Fünf Schritte von diesem Ordner bis zum ersten Backup. Ausführliche Erklärungen
-stehen in [02_INTEGRATION](02_INTEGRATION.md) und [03_CONFIG_REFERENCE](03_CONFIG_REFERENCE.md).
+Five steps from this folder to the first backup. Detailed explanations are
+in [02_INTEGRATION](02_INTEGRATION.md) and
+[03_CONFIG_REFERENCE](03_CONFIG_REFERENCE.md).
 
-Relevante Dateien:
+Relevant files:
 
-- `manage-client/` – der Ordner, der in das Projekt kopiert wird
-- `manage-client/config.sample.php` – Vorlage für die Konfiguration
-- `manage-client/bin/manage-client.php` – Kommandozeile
-- `manage-client/ui/panel.php` – fertige Oberfläche für den Adminbereich
+- `manage-client/` – the folder that gets copied into the project
+- `manage-client/config.sample.php` – template for the configuration
+- `manage-client/bin/manage-client.php` – command line
+- `manage-client/ui/panel.php` – ready-made UI for the admin area
 
-Voraussetzungen: PHP 8.0 oder neuer, die Erweiterung `zip` (für Updates), Schreibrechte
-auf dem Datenverzeichnis des Projekts. Weder Composer noch ein Build-Schritt werden benötigt.
+Requirements: PHP 8.0 or newer, the `zip` extension (for updates), write
+access to the project's data directory. Neither Composer nor a build step
+is needed.
 
-## 1. Ordner kopieren
+## 1. Copy the folder
 
 ```bash
-cp -r manage-client /pfad/zum/projekt/manage-client
+cp -r manage-client /path/to/project/manage-client
 ```
 
-Der Ordner liegt üblicherweise direkt im Projektstamm, also neben `index.php`.
-Liegt er woanders, muss `MANAGE_APP_ROOT` angepasst werden.
+The folder usually sits directly in the project root, next to `index.php`.
+If it lives elsewhere, `MANAGE_APP_ROOT` needs to be adjusted.
 
-## 2. Instanz auf dem Server anlegen
+## 2. Create an instance on the server
 
-Im Manage-Server unter **Instanzen** eine neue Instanz anlegen. Direkt danach wird
-das Token **einmalig** angezeigt – zusammen mit einem fertigen Konfigurationsblock
-zum Kopieren. Danach ist das Token nicht mehr abrufbar; es kann nur neu erzeugt werden.
+In the Manage server, under **Instances**, create a new instance. Right
+after that, the token is shown **once** — together with a ready-made
+configuration block to copy. After that the token can no longer be
+retrieved; it can only be regenerated.
 
-## 3. Konfiguration anlegen
+## 3. Create the configuration
 
 ```bash
-cd /pfad/zum/projekt/manage-client
+cd /path/to/project/manage-client
 cp config.sample.php config.php
 ```
 
-Mindestens diese Werte eintragen:
+Enter at least these values:
 
 ```php
 define("MANAGE_SERVER_URL", "https://manage.example.org");
-define("MANAGE_INSTANCE",   "mein-projekt-prod");
+define("MANAGE_INSTANCE",   "my-project-prod");
 define("MANAGE_TOKEN",      "…");
 ```
 
-Danach festlegen, was gesichert werden soll:
+Then decide what should be backed up:
 
 ```php
 define("MANAGE_BACKUP_SOURCES", [
@@ -54,40 +57,42 @@ define("MANAGE_BACKUP_SOURCES", [
 ]);
 ```
 
-`config.php` gehört **nicht** ins Repository des Projekts. Siehe [10_SECURITY](10_SECURITY.md).
+`config.php` does **not** belong in the project's repository. See
+[10_SECURITY](10_SECURITY.md).
 
-## 4. Verbindung prüfen
+## 4. Check the connection
 
 ```bash
 php manage-client/bin/manage-client.php status
 ```
 
-Erwartete Ausgabe:
+Expected output (the command's actual output stays in German; shown here
+with an English gloss on the right):
 
 ```text
-Instanz:           mein-projekt-prod
+Instanz:           my-project-prod      # Instance:
 Server:            https://manage.example.org
-Konfiguriert:      ja
-Installierte Ver.: v1.0.0
-Aktuelles Release: v1.0.0
-Update verfügbar:  nein
-Lokale Backups:    0
+Konfiguriert:      ja                   # Configured: yes
+Installierte Ver.: v1.0.0               # Installed version:
+Aktuelles Release: v1.0.0               # Current release:
+Update verfügbar:  nein                 # Update available: no
+Lokale Backups:    0                    # Local backups:
 ```
 
-Steht dort `Konfiguriert: NEIN` oder erscheint ein Fehler, hilft
-[09_TROUBLESHOOTING](09_TROUBLESHOOTING.md).
+If it shows `Konfiguriert: NEIN` ("Configured: NO") or an error appears,
+[09_TROUBLESHOOTING](09_TROUBLESHOOTING.md) helps.
 
-## 5. Erstes Backup
+## 5. First backup
 
 ```bash
 php manage-client/bin/manage-client.php backup
 ```
 
-Das Archiv liegt danach lokal unter `data/manage/backups/` und ist im Manage-Server
-unter **Backups** sichtbar.
+The archive then sits locally under `data/manage/backups/` and is visible
+in the Manage server under **Backups**.
 
-## Weiter
+## Next
 
-- Oberfläche in den Adminbereich einbinden: [02_INTEGRATION](02_INTEGRATION.md)
-- Regelmäßige Backups per Cron: [02_INTEGRATION](02_INTEGRATION.md)
-- Release-Pakete richtig bauen: [06_UPDATE_PACKAGING](06_UPDATE_PACKAGING.md)
+- Add the UI to the admin area: [02_INTEGRATION](02_INTEGRATION.md)
+- Regular backups via cron: [02_INTEGRATION](02_INTEGRATION.md)
+- Building release packages correctly: [06_UPDATE_PACKAGING](06_UPDATE_PACKAGING.md)

+ 73 - 70
client-package/docs/02_INTEGRATION.md

@@ -1,31 +1,32 @@
-# Integration in ein bestehendes Projekt
+# Integration Into an Existing Project
 
-## Überblick
+## Overview
 
-Der Client funktioniert eigenständig über die Kommandozeile. Zusätzlich lässt er
-sich in drei Stufen in das Projekt einbinden – von "gar nicht" bis "vollständig".
+The client works standalone from the command line. It can additionally be
+wired into the project in three stages — from "not at all" to "fully".
 
-Relevante Dateien:
+Relevant files:
 
-- `manage-client/lib/client.php` – einziger Einstiegspunkt, lädt alles Weitere
-- `manage-client/ui/panel.php` – vollständige Oberfläche
-- `manage-client/ui/status-partial.php` – kleiner Statusblock
-- `manage-client/bin/manage-client.php` – Kommandozeile für Cron
+- `manage-client/lib/client.php` – the only entry point, loads everything else
+- `manage-client/ui/panel.php` – the complete UI
+- `manage-client/ui/status-partial.php` – a small status block
+- `manage-client/bin/manage-client.php` – command line for cron
 
-## Stufe 1: Standalone
+## Stage 1: standalone
 
-Nichts zu tun. Der Client wird ausschließlich über die Kommandozeile bedient und
-das Projekt weiß nichts von ihm. Nur der Ordner muss vorhanden und konfiguriert sein.
+Nothing to do. The client is operated exclusively from the command line and
+the project knows nothing about it. Only the folder needs to exist and be
+configured.
 
-Diese Stufe genügt, wenn Updates und Backups von einer Person mit Shell-Zugang
-gepflegt werden.
+This stage is enough when updates and backups are maintained by one person
+with shell access.
 
-## Stufe 2: Oberfläche im Adminbereich
+## Stage 2: UI in the admin area
 
-`ui/panel.php` ist eine fertige Seite. Sie erwartet, dass das Projekt seine
-**eigene Anmeldung bereits geprüft hat**, bevor die Datei eingebunden wird.
+`ui/panel.php` is a ready-made page. It expects the project to have
+**already checked its own login** before the file is included.
 
-Neue Datei `admin/manage.php` im Projekt:
+New file `admin/manage.php` in the project:
 
 ```php
 <?php
@@ -33,7 +34,7 @@ Neue Datei `admin/manage.php` im Projekt:
 require_once __DIR__ . "/../config.php";
 require_once __DIR__ . "/../includes/functions.php";
 
-// Anmeldung des Projekts – hier steht, was das Projekt ohnehin verwendet.
+// The project's own login check - whatever the project already uses.
 if (empty($_SESSION["admin_logged_in"])) {
     header("Location: login.php");
     exit;
@@ -42,21 +43,22 @@ if (empty($_SESSION["admin_logged_in"])) {
 require __DIR__ . "/../manage-client/ui/panel.php";
 ```
 
-Das Panel bringt eine eigene Prüfung auf `$_SESSION["admin_logged_in"]` mit, damit
-ein direkter Aufruf nicht ungeschützt ist. Verwendet das Projekt ein anderes
-Session-Flag, gibt es zwei Möglichkeiten:
+The panel brings its own check on `$_SESSION["admin_logged_in"]`, so a
+direct call isn't left unprotected. If the project uses a different session
+flag, there are two options:
 
-1. den Guard in `ui/panel.php` anpassen (der Ordner wird ohnehin pro Projekt gepflegt), oder
-2. vor dem Einbinden `define("MANAGE_PANEL_SKIP_AUTH_GUARD", true);` setzen, wenn die
-   Anmeldung im aufrufenden Skript bereits sichergestellt ist.
+1. adjust the guard in `ui/panel.php` (this folder is maintained per project
+   anyway), or
+2. set `define("MANAGE_PANEL_SKIP_AUTH_GUARD", true);` before including it,
+   if the login is already guaranteed in the calling script.
 
-Die zweite Variante deaktiviert **nur** die zusätzliche Prüfung des Panels. Wer sie
-setzt, ohne vorher selbst zu prüfen, veröffentlicht Update- und Backup-Funktionen
-im Internet.
+The second option **only** disables the panel's extra check. Setting it
+without checking the login yourself publishes update and backup functions
+to the internet.
 
-### Statusblock auf einer bestehenden Seite
+### Status block on an existing page
 
-Für eine Einstellungsseite genügt oft der kleine Block:
+For a settings page, the small block is often enough:
 
 ```php
 <?php
@@ -65,77 +67,78 @@ include __DIR__ . "/../manage-client/ui/status-partial.php";
 ?>
 ```
 
-Er rendert nur ein Fragment, wirft nie eine Exception und zeigt Version,
-Update-Verfügbarkeit, letztes Backup und offene Migrationen.
+It renders only a fragment, never throws an exception, and shows version,
+update availability, last backup and pending migrations.
 
-## Stufe 3: Funktionsaufrufe im Projekt
+## Stage 3: function calls in the project
 
-Alle Funktionen aus [04_FUNCTION_API](04_FUNCTION_API.md) können direkt aufgerufen
-werden:
+All functions from [04_FUNCTION_API](04_FUNCTION_API.md) can be called
+directly:
 
 ```php
 require_once __DIR__ . "/manage-client/lib/client.php";
 
-// z. B. auf dem Admin-Dashboard: automatisches Backup, wenn fällig
+// e.g. on the admin dashboard: automatic backup when due
 manageBackupCreateAutomaticIfDue();
 ```
 
-Dieser Aufruf ist die Variante für Hosting ohne Cron: Er erstellt ein Backup, wenn
-seit dem letzten automatischen Backup `MANAGE_BACKUP_AUTO_INTERVAL_SECONDS` vergangen
-sind, und gibt sonst sofort `null` zurück. Ein Backup dauert je nach Datenmenge
-mehrere Sekunden – deshalb gehört der Aufruf auf eine selten geladene Adminseite,
-nicht auf jede Seite des Projekts.
+This call is the option for hosting without cron: it creates a backup once
+`MANAGE_BACKUP_AUTO_INTERVAL_SECONDS` has passed since the last automatic
+backup, and otherwise returns `null` immediately. A backup takes several
+seconds depending on the amount of data — so the call belongs on a rarely
+loaded admin page, not on every page of the project.
 
 ## Cron
 
-Cron ist der empfohlene Weg. Beispielzeilen liegen in
+Cron is the recommended path. Example lines live in
 `examples/cron/manage-client.cron`:
 
 ```cron
-# Backup, jede Nacht um 03:20 Uhr
-20 3 * * * /usr/bin/php /pfad/zum/projekt/manage-client/bin/manage-client.php backup --trigger=cron --quiet
+# Backup, every night at 03:20.
+20 3 * * * /usr/bin/php /path/to/project/manage-client/bin/manage-client.php backup --trigger=cron --quiet
 
-# Statusmeldung an den Manage-Server, stündlich
-7 * * * * /usr/bin/php /pfad/zum/projekt/manage-client/bin/manage-client.php heartbeat --quiet
+# Status report to the Manage server, hourly.
+7 * * * * /usr/bin/php /path/to/project/manage-client/bin/manage-client.php heartbeat --quiet
 
-# Update-Prüfung, werktags um 08:00 Uhr (Exit 2 = Update verfügbar)
-0 8 * * 1-5 /usr/bin/php /pfad/zum/projekt/manage-client/bin/manage-client.php check --quiet
+# Update check, weekdays at 08:00 (exit 2 = update available).
+0 8 * * 1-5 /usr/bin/php /path/to/project/manage-client/bin/manage-client.php check --quiet
 ```
 
-`--quiet` unterdrückt die normale Ausgabe; Fehler gehen weiterhin auf STDERR und
-werden von Cron per Mail zugestellt. Updates werden bewusst **nicht** automatisch
-eingespielt: `update` bleibt eine bewusste Entscheidung.
+`--quiet` suppresses normal output; errors still go to STDERR and are
+delivered by cron via mail. Updates are deliberately **not** installed
+automatically: `update` remains a deliberate decision.
 
-## .gitignore des Projekts
+## The project's .gitignore
 
-Ins `.gitignore` des Projekts gehören:
+The project's `.gitignore` should contain:
 
 ```gitignore
 manage-client/config.php
 data/manage/
 ```
 
-Der übrige Inhalt von `manage-client/` **soll** eingecheckt werden, damit er Teil
-des Release-Pakets ist und mit ausgerollt wird.
+The rest of `manage-client/` **should** be checked in, so it's part of the
+release package and gets deployed along with it.
 
-## Verzeichnisse und Rechte
+## Directories and permissions
 
-PHP braucht Schreibrechte auf:
+PHP needs write access to:
 
-- `data/manage/backups/` – lokale Archive
-- `data/manage/work/` – Arbeitsverzeichnis für Updates (wird nach jedem Lauf geleert)
-- `data/manage/updates/` – Sicherungskopien der überschriebenen Dateien
-- den **gesamten Anwendungsstamm**, sofern Updates eingespielt werden sollen
+- `data/manage/backups/` – local archives
+- `data/manage/work/` – working directory for updates (cleared after every run)
+- `data/manage/updates/` – copies of the files an update overwrote
+- the **entire application root**, if updates are to be deployed
 
-Fehlen Schreibrechte im Anwendungsstamm, schlägt ein Update mittendrin fehl. Siehe
-[09_TROUBLESHOOTING](09_TROUBLESHOOTING.md).
+Without write access in the application root, an update fails partway
+through. See [09_TROUBLESHOOTING](09_TROUBLESHOOTING.md).
 
-Diese Verzeichnisse dürfen nicht über das Web erreichbar sein. Bei Apache erledigt
-das üblicherweise die `.htaccess` des Projekts; das mitgelieferte
-`manage-client/.htaccess` schützt zusätzlich `config.php`, `lib/` und `bin/`.
+These directories must not be reachable over the web. On Apache, the
+project's own `.htaccess` usually handles that; the bundled
+`manage-client/.htaccess` additionally protects `config.php`, `lib/` and
+`bin/`.
 
-## Weiter
+## Next
 
-- [03_CONFIG_REFERENCE](03_CONFIG_REFERENCE.md) – alle Konstanten
-- [05_BACKUP_SOURCES](05_BACKUP_SOURCES.md) – was gesichert wird
-- [06_UPDATE_PACKAGING](06_UPDATE_PACKAGING.md) – wie Release-Pakete gebaut werden
+- [03_CONFIG_REFERENCE](03_CONFIG_REFERENCE.md) – every constant
+- [05_BACKUP_SOURCES](05_BACKUP_SOURCES.md) – what gets backed up
+- [06_UPDATE_PACKAGING](06_UPDATE_PACKAGING.md) – how release packages are built

+ 77 - 75
client-package/docs/03_CONFIG_REFERENCE.md

@@ -1,119 +1,121 @@
-# Konfigurationsreferenz
+# Configuration Reference
 
-## Überblick
+## Overview
 
-Alle Konstanten stehen in `manage-client/config.php`, kopiert aus
-`manage-client/config.sample.php`. Jede Konstante hat einen Standardwert in
-`manage-client/lib/client.php`; eine minimale `config.php` braucht nur die drei
-Verbindungswerte und `MANAGE_BACKUP_SOURCES`.
+All constants live in `manage-client/config.php`, copied from
+`manage-client/config.sample.php`. Every constant has a default in
+`manage-client/lib/client.php`; a minimal `config.php` only needs the three
+connection values plus `MANAGE_BACKUP_SOURCES`.
 
-Konstanten werden mit `define()` gesetzt, nicht als Array. Wird eine Konstante
-bereits vom Projekt definiert, gewinnt der Wert des Projekts.
+Constants are set with `define()`, not as an array. If a constant is
+already defined by the project, the project's value wins.
 
-## Verbindung
+## Connection
 
-| Konstante | Standard | Bedeutung |
+| Constant | Default | Meaning |
 |---|---|---|
-| `MANAGE_SERVER_URL` | `""` | Basis-URL des Manage-Servers, ohne `/api` und ohne Schrägstrich am Ende |
-| `MANAGE_INSTANCE` | `""` | Kennung der Instanz, wie auf dem Server angelegt |
-| `MANAGE_TOKEN` | `""` | Geheimes Token der Instanz, einmalig beim Anlegen angezeigt |
-| `MANAGE_HTTP_TIMEOUT` | `15` | Sekunden für Manifest und Heartbeat |
-| `MANAGE_HTTP_TIMEOUT_LONG` | `300` | Sekunden für Paket-Download und Backup-Upload |
+| `MANAGE_SERVER_URL` | `""` | base URL of the Manage server, without `/api` and without a trailing slash |
+| `MANAGE_INSTANCE` | `""` | instance id, as created on the server |
+| `MANAGE_TOKEN` | `""` | the instance's secret token, shown once when created |
+| `MANAGE_HTTP_TIMEOUT` | `15` | seconds for manifest and heartbeat |
+| `MANAGE_HTTP_TIMEOUT_LONG` | `300` | seconds for package download and backup upload |
 
-Fehlt einer der ersten drei Werte, melden alle Funktionen mit Serverkontakt einen
-Konfigurationsfehler. Lokale Backups funktionieren trotzdem, sofern
-`MANAGE_BACKUP_UPLOAD` auf `false` steht.
+If any of the first three values is missing, every function that contacts
+the server reports a configuration error. Local backups still work as long
+as `MANAGE_BACKUP_UPLOAD` is `false`.
 
-## Anwendungslayout
+## Application layout
 
-| Konstante | Standard | Bedeutung |
+| Constant | Default | Meaning |
 |---|---|---|
-| `MANAGE_APP_ROOT` | Elternverzeichnis von `manage-client/` | Wurzel der Anwendung. Ziel des Updates, Basis aller relativen Backup-Pfade |
-| `MANAGE_VERSION_FILE` | `MANAGE_APP_ROOT . "/VERSION"` | Datei mit der installierten Version |
-| `MANAGE_VERSION_CONSTANT` | `null` | Name der Konstante in dieser Datei, oder `null` für eine reine Textdatei |
-| `MANAGE_WORK_DIR` | `data/manage/work/` | Arbeitsverzeichnis für Updates, wird nach jedem Lauf geleert |
-| `MANAGE_UPDATE_BACKUP_DIR` | `data/manage/updates/` | Sicherungskopien der überschriebenen Dateien |
-| `MANAGE_BACKUP_DIR` | `data/manage/backups/` | Lokale Backup-Archive |
-| `MANAGE_LOG_FILE` | `data/manage/manage-client.log` | JSONL-Protokoll des Clients |
+| `MANAGE_APP_ROOT` | parent directory of `manage-client/` | root of the application. Update target, base of every relative backup path |
+| `MANAGE_VERSION_FILE` | `MANAGE_APP_ROOT . "/VERSION"` | file holding the installed version |
+| `MANAGE_VERSION_CONSTANT` | `null` | name of the constant in that file, or `null` for a plain text file |
+| `MANAGE_WORK_DIR` | `data/manage/work/` | working directory for updates, cleared after every run |
+| `MANAGE_UPDATE_BACKUP_DIR` | `data/manage/updates/` | copies of the files an update overwrote |
+| `MANAGE_BACKUP_DIR` | `data/manage/backups/` | local backup archives |
+| `MANAGE_LOG_FILE` | `data/manage/manage-client.log` | the client's JSONL log |
 
-### Versionsdatei
+### Version file
 
-Zwei Varianten werden unterstützt.
+Two variants are supported.
 
-PHP-Datei mit Konstante (wie im PSA-Bestellsystem):
+PHP file with a constant:
 
 ```php
 define("MANAGE_VERSION_FILE", MANAGE_APP_ROOT . "/includes/version.php");
 define("MANAGE_VERSION_CONSTANT", "APP_VERSION");
 ```
 
-Reine Textdatei, die nur `v1.2.3` enthält:
+Plain text file that only contains `v1.2.3`:
 
 ```php
 define("MANAGE_VERSION_FILE", MANAGE_APP_ROOT . "/VERSION");
 define("MANAGE_VERSION_CONSTANT", null);
 ```
 
-Die Version wird bei der Variante mit Konstante per regulärem Ausdruck aus der Datei
-gelesen und die Datei dabei **nicht** ausgeführt. Damit funktioniert das Auslesen
-auch dann, wenn die Konstante im laufenden Prozess bereits mit dem alten Wert
-definiert ist – zum Beispiel unmittelbar nach einem Update.
+With the constant variant, the version is read from the file with a regular
+expression, and the file is **not** executed. That way reading it still
+works even when the constant is already defined in the running process with
+the old value — for example right after an update.
 
-Die Version selbst wird nie vom Client geschrieben: Sie ist Teil des Release-Pakets
-und ändert sich als Nebeneffekt des Dateikopierens.
+The version itself is never written by the client: it's part of the release
+package and changes as a side effect of copying files.
 
 ## Update
 
-| Konstante | Standard | Bedeutung |
+| Constant | Default | Meaning |
 |---|---|---|
-| `MANAGE_UPDATE_PROTECTED_PATHS` | `["config.php", "data/", ".git/", "manage-client/config.php"]` | Pfade, die nie überschrieben werden |
-| `MANAGE_UPDATE_SANITY_PATHS` | `["index.php"]` | Das Paket muss mindestens einen dieser Pfade enthalten |
-| `MANAGE_UPDATE_POST_HOOK` | `null` | Callback nach erfolgreichem Update |
-| `MANAGE_MIGRATIONS_DIR` | `MANAGE_APP_ROOT . "/migrations"` | Verzeichnis mit Migrationsskripten, `null` deaktiviert sie |
-| `MANAGE_MIGRATIONS_STATE` | `data/manage/migrations.json` | Welche Migrationen bereits gelaufen sind |
+| `MANAGE_UPDATE_PROTECTED_PATHS` | `["config.php", "data/", ".git/", "manage-client/config.php"]` | paths that are never overwritten |
+| `MANAGE_UPDATE_SANITY_PATHS` | `["index.php"]` | the package must contain at least one of these paths |
+| `MANAGE_UPDATE_POST_HOOK` | `null` | callback after a successful update |
+| `MANAGE_MIGRATIONS_DIR` | `MANAGE_APP_ROOT . "/migrations"` | directory with migration scripts, `null` disables them |
+| `MANAGE_MIGRATIONS_STATE` | `data/manage/migrations.json` | which migrations have already run |
 
-Pfade in `MANAGE_UPDATE_PROTECTED_PATHS` sind relativ zu `MANAGE_APP_ROOT`. Ein
-Eintrag mit Schrägstrich am Ende schützt das Verzeichnis samt Inhalt; ohne
-Schrägstrich wird der genaue Pfad geschützt, ein Verzeichnis aber ebenfalls
-mitsamt Inhalt. `config.php` und das Datenverzeichnis gehören immer dazu.
+Paths in `MANAGE_UPDATE_PROTECTED_PATHS` are relative to `MANAGE_APP_ROOT`.
+An entry with a trailing slash protects the directory and its contents;
+without a slash, the exact path is protected, but a directory is likewise
+protected with its contents. `config.php` and the data directory always
+belong here.
 
-`MANAGE_UPDATE_SANITY_PATHS` verhindert, dass ein völlig fremdes ZIP über die
-Anwendung kopiert wird. Der Wert sollte eine Datei oder ein Verzeichnis benennen,
-das in jedem Release enthalten ist.
+`MANAGE_UPDATE_SANITY_PATHS` prevents a completely unrelated ZIP from being
+copied over the application. The value should name a file or directory that
+every release contains.
 
-Hook und Migrationen sind in [07_POST_UPDATE_HOOKS](07_POST_UPDATE_HOOKS.md)
-beschrieben.
+The hook and migrations are described in
+[07_POST_UPDATE_HOOKS](07_POST_UPDATE_HOOKS.md).
 
 ## Backup
 
-| Konstante | Standard | Bedeutung |
+| Constant | Default | Meaning |
 |---|---|---|
-| `MANAGE_BACKUP_SOURCES` | `[["as" => "data", "glob" => "data/*.json"]]` | Was ins Archiv kommt |
-| `MANAGE_BACKUP_DATABASE` | `null` | Optionaler MySQL-Dump |
-| `MANAGE_BACKUP_LOCAL_RETENTION` | `4` | Lokale Archive, die behalten werden. Minimum 1 |
-| `MANAGE_BACKUP_AUTO_INTERVAL_SECONDS` | `604800` | Intervall für `manageBackupCreateAutomaticIfDue()`, `0` deaktiviert |
-| `MANAGE_BACKUP_COMPRESS` | `true` | Einträge im ZIP komprimieren (Deflate) |
-| `MANAGE_BACKUP_UPLOAD` | `true` | Jedes neue Backup an den Manage-Server senden |
-| `MANAGE_BACKUP_REMOTE_TARGETS` | `[]` | Zusätzliche Ziele: `s3`, `sftp`, `custom` |
+| `MANAGE_BACKUP_SOURCES` | `[["as" => "data", "glob" => "data/*.json"]]` | what goes into the archive |
+| `MANAGE_BACKUP_DATABASE` | `null` | optional MySQL dump |
+| `MANAGE_BACKUP_LOCAL_RETENTION` | `4` | local archives kept. Minimum 1 |
+| `MANAGE_BACKUP_AUTO_INTERVAL_SECONDS` | `604800` | interval for `manageBackupCreateAutomaticIfDue()`, `0` disables it |
+| `MANAGE_BACKUP_COMPRESS` | `true` | compress entries in the ZIP (deflate) |
+| `MANAGE_BACKUP_UPLOAD` | `true` | send every new backup to the Manage server |
+| `MANAGE_BACKUP_REMOTE_TARGETS` | `[]` | extra targets: `s3`, `sftp`, `custom` |
 
-Aufbau von Quellen und Zielen: [05_BACKUP_SOURCES](05_BACKUP_SOURCES.md).
+Layout of sources and targets: [05_BACKUP_SOURCES](05_BACKUP_SOURCES.md).
 
-`MANAGE_BACKUP_COMPRESS` benötigt zlib, das in PHP standardmäßig vorhanden ist.
-Fehlt es, wird ohne Kompression geschrieben statt abzubrechen. Für Archive, die
-überwiegend aus JPEGs bestehen, bringt Kompression fast nichts; für SQL-Dumps sehr viel.
+`MANAGE_BACKUP_COMPRESS` needs zlib, which is present in PHP by default. If
+it's missing, the client writes without compression instead of aborting.
+For archives that are mostly JPEGs, compression gains almost nothing; for
+SQL dumps it gains a lot.
 
-Die lokale Aufbewahrung ist unabhängig von der Aufbewahrung auf dem Manage-Server.
-Auf dem Server wird sie zentral in den Servereinstellungen gepflegt, üblicherweise
-deutlich höher als lokal.
+Local retention is independent of retention on the Manage server. On the
+server it's maintained centrally in the server settings, usually
+considerably higher than locally.
 
-## Zusammenspiel mit dem Projekt
+## Interplay with the project
 
-Definiert das Projekt eine Konstante bereits selbst, gewinnt sie, weil `config.php`
-des Clients vor den Standardwerten geladen wird und alle Standardwerte mit
-`if (!defined(...))` gesetzt sind. So kann ein Projekt seine Pfade zentral
-konfigurieren und der Client sie übernehmen.
+If the project already defines a constant itself, that value wins, because
+the client's `config.php` loads before the defaults, and every default is
+set with `if (!defined(...))`. This lets a project configure its paths
+centrally and have the client pick them up.
 
-## Weiter
+## Next
 
-- [04_FUNCTION_API](04_FUNCTION_API.md) – die aufrufbaren Funktionen
-- [09_TROUBLESHOOTING](09_TROUBLESHOOTING.md) – Fehlermeldungen und Ursachen
+- [04_FUNCTION_API](04_FUNCTION_API.md) – the callable functions
+- [09_TROUBLESHOOTING](09_TROUBLESHOOTING.md) – error messages and their causes

+ 79 - 75
client-package/docs/04_FUNCTION_API.md

@@ -1,57 +1,58 @@
-# Funktions-API
+# Function API
 
-## Überblick
+## Overview
 
-Alle Funktionen stehen nach einem einzigen `require` zur Verfügung:
+All functions become available after a single `require`:
 
 ```php
 require_once __DIR__ . "/manage-client/lib/client.php";
 ```
 
-Die Kommandozeile (`bin/manage-client.php`) und die Oberfläche (`ui/panel.php`)
-enthalten **keine eigene Logik**, sondern rufen genau diese Funktionen auf. Eine
-Funktion verhält sich deshalb identisch, egal wie sie ausgelöst wird.
+The command line (`bin/manage-client.php`) and the UI (`ui/panel.php`)
+contain **no logic of their own** — they call exactly these functions. A
+function therefore behaves identically no matter how it's triggered.
 
-Fehler werden als `RuntimeException` geworfen. Ausnahmen von dieser Regel sind
-ausdrücklich vermerkt.
+Errors are thrown as `RuntimeException`. Exceptions to this rule are noted
+explicitly.
 
 ## Status
 
 ### `manageClientStatus(): array`
 
-Sammelaufruf für Oberflächen. **Wirft nie**: Jeder Fehler landet im Rückgabewert,
-damit eine Einstellungsseite auch bei nicht erreichbarem Server rendert.
+Collective call for UIs. **Never throws**: every error ends up in the
+return value, so a settings page still renders even when the server is
+unreachable.
 
 ```php
 [
-    "instance"           => "mein-projekt-prod",
+    "instance"           => "my-project-prod",
     "server_url"         => "https://manage.example.org",
     "configured"         => true,
-    "version"            => "v1.2.3",   // "" wenn nicht ermittelbar
+    "version"            => "v1.2.3",   // "" if it can't be determined
     "php_version"        => "8.3.6",
-    "update"             => [...],      // Rückgabe von manageUpdateCheck(), oder null
-    "update_error"       => null,       // Fehlermeldung, wenn die Prüfung scheiterte
-    "backups"            => [...],      // Rückgabe von manageBackupList()
+    "update"             => [...],      // return value of manageUpdateCheck(), or null
+    "update_error"       => null,       // error message if the check failed
+    "backups"            => [...],      // return value of manageBackupList()
     "last_backup_at"     => "2026-08-20T09:21:04+00:00",
     "pending_migrations" => [...],
-    "errors"             => [],         // nicht-fatale Warnungen
+    "errors"             => [],         // non-fatal warnings
 ]
 ```
 
 ### `manageClientVersion(): string`
 
-Installierte Version, zum Beispiel `"v1.2.3"`. Gibt `""` zurück, wenn sie nicht
-ermittelbar ist; das ist kein Fehler, sondern "unbekannt".
+Installed version, for example `"v1.2.3"`. Returns `""` if it can't be
+determined; that's not an error, just "unknown".
 
 ### `manageClientConfigured(): bool`
 
-Ob Server-URL, Instanz und Token gesetzt sind.
+Whether server URL, instance and token are set.
 
 ## Update
 
 ### `manageUpdateCheck(): array`
 
-Holt das Manifest und vergleicht die Versionen.
+Fetches the manifest and compares versions.
 
 ```php
 [
@@ -68,22 +69,22 @@ Holt das Manifest und vergleicht die Versionen.
 ]
 ```
 
-Ist die installierte Version unbekannt, gilt `available => true`, damit eine
-Installation ohne lesbare Versionsdatei nicht dauerhaft blockiert.
+If the installed version is unknown, `available => true` holds, so an
+installation without a readable version file isn't permanently blocked.
 
 ### `manageUpdateApply(array $options = []): array`
 
-Lädt das Paket, prüft Größe und SHA-256, entpackt es, rollt es aus und führt
-anschließend den Post-Update-Schritt aus.
+Downloads the package, checks size and SHA-256, extracts it, deploys it,
+and then runs the post-update step.
 
-Optionen:
+Options:
 
-| Option | Bedeutung |
+| Option | Meaning |
 |---|---|
-| `force` | Auch ausrollen, wenn keine neuere Version vorliegt |
-| `skip_hook` | Nur Dateien ausrollen, weder Migrationen noch Callback ausführen |
+| `force` | deploy even when no newer version is available |
+| `skip_hook` | only deploy files, run neither migrations nor the callback |
 
-Rückgabe:
+Return value:
 
 ```php
 [
@@ -92,25 +93,25 @@ Rückgabe:
     "to_version"      => "v1.3.0",
     "copied"          => 128,
     "backed_up"       => 126,
-    "skipped"         => 2,      // geschützte Pfade
+    "skipped"         => 2,      // protected paths
     "removed_backups" => 1,
     "backup_dir"      => "/…/data/manage/updates/20260820-092114-v1.3.0",
-    "hook"            => [...],  // siehe unten
+    "hook"            => [...],  // see below
 ]
 ```
 
-Wirft, wenn das **Ausrollen** scheitert. Scheitert nur der Post-Update-Schritt,
-kehrt die Funktion normal zurück und `hook["success"]` ist `false` – die Dateien
-sind dann bereits ausgerollt. Aufrufer müssen beides unterscheiden; siehe
+Throws if **deployment** fails. If only the post-update step fails, the
+function returns normally and `hook["success"]` is `false` — the files are
+then already deployed. Callers must distinguish between the two; see
 [07_POST_UPDATE_HOOKS](07_POST_UPDATE_HOOKS.md).
 
-Wichtig: Das Ausrollen überschreibt Dateien im laufenden Betrieb. Es gibt keine
-Wartungsseite und keine Rücknahme. Die überschriebenen Dateien liegen als Kopie in
-`backup_dir`, aber ausschließlich für die manuelle Wiederherstellung.
+Important: deployment overwrites files while the application is live. There
+is no maintenance page and no rollback. The overwritten files sit as copies
+in `backup_dir`, but exclusively for manual restoration.
 
 ### `manageUpdatePendingMigrations(): array`
 
-Noch nicht ausgeführte Migrationen in Ausführungsreihenfolge:
+Migrations not yet run, in execution order:
 
 ```php
 [["id" => "2026-08-20-01-add-index", "path" => "/…/migrations/2026-08-20-01-add-index.php"]]
@@ -118,7 +119,7 @@ Noch nicht ausgeführte Migrationen in Ausführungsreihenfolge:
 
 ### `manageUpdateRunMigrations(array $context = []): array`
 
-Führt die offenen Migrationen aus. **Wirft nicht**, sondern meldet:
+Runs the pending migrations. **Never throws**, reports instead:
 
 ```php
 [
@@ -126,7 +127,7 @@ Führt die offenen Migrationen aus. **Wirft nicht**, sondern meldet:
     "applied" => ["2026-08-20-01-add-index"],
     "failed"  => "2026-08-20-02-backfill",
     "error"   => "SQLSTATE[42S22]: …",
-    "pending" => 2,   // inklusive der fehlgeschlagenen
+    "pending" => 2,   // including the failed one
 ]
 ```
 
@@ -134,10 +135,11 @@ Führt die offenen Migrationen aus. **Wirft nicht**, sondern meldet:
 
 ### `manageBackupCreate(string $trigger = "manual"): array`
 
-Erstellt ein lokales Archiv und lädt es hoch, sofern `MANAGE_BACKUP_UPLOAD` aktiv ist.
+Creates a local archive and uploads it, provided `MANAGE_BACKUP_UPLOAD` is
+active.
 
-`$trigger` ist frei wählbar; üblich sind `manual`, `automatic`, `cron`, `update`.
-Nur `automatic` und `cron` zählen für die Intervallprüfung.
+`$trigger` is free-form; common values are `manual`, `automatic`, `cron`,
+`update`. Only `automatic` and `cron` count toward the interval check.
 
 ```php
 [
@@ -149,48 +151,50 @@ Nur `automatic` und `cron` zählen für die Intervallprüfung.
     "source_bytes"   => 63,
     "sha256"         => "…",
     "app_version"    => "v1.2.3",
-    "database"       => null,   // oder ["tables" => 12, "rows" => 4711, …]
+    "database"       => null,   // or ["tables" => 12, "rows" => 4711, …]
     "remote_uploads" => [
-        ["target" => "Manage-Server", "type" => "manage", "success" => true, …],
+        ["target" => "Manage Server", "type" => "manage", "success" => true, …],
     ],
 ]
 ```
 
-Ein fehlgeschlagener Upload macht das lokale Archiv **nicht** ungültig: Der Fehler
-steht in `remote_uploads[].error` und im Protokoll, die Funktion wirft nicht. Wirft
-sie doch, ist das Archiv selbst nicht zustande gekommen.
+A failed upload does **not** invalidate the local archive: the error sits
+in `remote_uploads[].error` and in the log; the function doesn't throw. If
+it does throw, the archive itself never came into being.
 
-Gleichzeitige Läufe werden über eine Sperrdatei verhindert; der zweite Lauf wirft
-sofort `Es läuft bereits ein Backup.`
+Concurrent runs are prevented by a lock file; a second run throws
+immediately with `Es läuft bereits ein Backup.` ("a backup is already
+running" — the literal, still German, message text).
 
 ### `manageBackupCreateAutomaticIfDue(): ?array`
 
-Erstellt ein Backup, wenn seit dem letzten automatischen Backup
-`MANAGE_BACKUP_AUTO_INTERVAL_SECONDS` vergangen sind, sonst `null`. Für Hosting ohne
-Cron gedacht; gehört auf eine selten geladene Adminseite.
+Creates a backup once `MANAGE_BACKUP_AUTO_INTERVAL_SECONDS` has passed since
+the last automatic backup, otherwise `null`. Meant for hosting without
+cron; belongs on a rarely loaded admin page.
 
 ### `manageBackupList(): array`
 
-Lokale Archive, neuestes zuerst. Selbstheilend: Einträge ohne Datei werden entfernt,
-Größen werden von der Festplatte aktualisiert.
+Local archives, newest first. Self-healing: entries without a file are
+removed, sizes are refreshed from disk.
 
 ### `manageBackupUpload(string $archivePath, array $meta = []): array`
 
-Lädt ein vorhandenes Archiv zum Manage-Server. Wird von `manageBackupCreate()`
-automatisch aufgerufen und ist nur für Sonderfälle einzeln nötig – etwa um ein
-Archiv nach einem Serverausfall nachzureichen.
+Uploads an existing archive to the Manage server. Called automatically by
+`manageBackupCreate()`; only needed on its own for special cases — for
+example, resending an archive after a server outage.
 
 ### `manageBackupPath(string $filename): string`
 
-Absoluter Pfad eines lokalen Archivs. Validiert den Dateinamen streng, damit ein
-Download-Formular keinen beliebigen Pfad ausliefern kann.
+Absolute path of a local archive. Validates the filename strictly, so a
+download form can't be made to serve an arbitrary path.
 
 ## Heartbeat
 
 ### `manageHeartbeatSend(): array`
 
-Meldet Version, PHP-Version, freien Speicher, offene Migrationen und den Zeitpunkt
-des letzten Backups. Die Antwort enthält nebenbei die Update-Information:
+Reports version, PHP version, free disk space, pending migrations and the
+time of the last backup. The response includes the update information as a
+side effect:
 
 ```php
 ["success" => true, "latest" => "v1.3.0", "update_available" => true, "server_time" => "…"]
@@ -198,33 +202,33 @@ des letzten Backups. Die Antwort enthält nebenbei die Update-Information:
 
 ### `manageHeartbeatSendQuietly(): ?array`
 
-Wie oben, wirft aber nie und gibt bei Fehlern `null` zurück. Für Aufrufe innerhalb
-des Projekts, in denen ein nicht erreichbarer Server folgenlos bleiben soll.
+Same as above, but never throws and returns `null` on error. For calls
+inside the project where an unreachable server should have no consequence.
 
-## Hilfsfunktionen
+## Helper functions
 
-| Funktion | Zweck |
+| Function | Purpose |
 |---|---|
-| `manageFormatBytes(int $bytes): string` | `1.234.567` → `1,18 MB` |
-| `manageClientLog(string $level, string $message, array $context = []): void` | Eine Zeile ins Client-Protokoll. Wirft nie |
-| `manageRemoteCapabilities(): array` | Welche konfigurierten Zieltypen dieses System unterstützt |
+| `manageFormatBytes(int $bytes): string` | `1234567` → `1.18 MB` |
+| `manageClientLog(string $level, string $message, array $context = []): void` | one line into the client log. Never throws |
+| `manageRemoteCapabilities(): array` | which configured target types this system supports |
 
-## Beispiel
+## Example
 
 ```php
 require_once __DIR__ . "/manage-client/lib/client.php";
 
 $check = manageUpdateCheck();
 if ($check["available"]) {
-    manageBackupCreate("update");          // vor dem Update sichern
+    manageBackupCreate("update");          // back up before the update
     $result = manageUpdateApply();
 
     if (!$result["hook"]["success"]) {
-        // Dateien sind ausgerollt, der Post-Update-Schritt nicht.
-        error_log("Migration fehlgeschlagen: " . $result["hook"]["error"]);
+        // Files are deployed, the post-update step is not.
+        error_log("Migration failed: " . $result["hook"]["error"]);
     }
 }
 ```
 
-Ein Backup vor dem Update ist bewusst **nicht** eingebaut, sondern eine Zeile im
-Projekt – so bleibt sichtbar, dass es passiert.
+A backup before the update is deliberately **not** built in, but a line in
+the project — so it stays visible that it happens.

+ 103 - 99
client-package/docs/05_BACKUP_SOURCES.md

@@ -1,159 +1,163 @@
-# Backup-Quellen und Ziele
+# Backup Sources and Targets
 
-## Überblick
+## Overview
 
-Was gesichert wird, steht in `MANAGE_BACKUP_SOURCES`. Optional kommt ein
-MySQL-Dump dazu. Wohin gesichert wird, steuern `MANAGE_BACKUP_UPLOAD` und
-`MANAGE_BACKUP_REMOTE_TARGETS`.
+What gets backed up lives in `MANAGE_BACKUP_SOURCES`. A MySQL dump can be
+added optionally. Where it gets backed up to is controlled by
+`MANAGE_BACKUP_UPLOAD` and `MANAGE_BACKUP_REMOTE_TARGETS`.
 
-Relevante Dateien:
+Relevant files:
 
-- `manage-client/lib/backup.php` – Sammeln, Archivieren, Aufbewahrung, Upload
-- `manage-client/lib/zip.php` – ZIP-Erzeugung ohne externe Bibliothek
-- `manage-client/lib/mysql.php` – optionaler Datenbank-Dump
-- `manage-client/lib/remote.php` – zusätzliche Ziele
+- `manage-client/lib/backup.php` – collecting, archiving, retention, upload
+- `manage-client/lib/zip.php` – ZIP creation without an external library
+- `manage-client/lib/mysql.php` – optional database dump
+- `manage-client/lib/remote.php` – extra targets
 
-## Quellen deklarieren
+## Declaring sources
 
-Alle Pfade sind relativ zu `MANAGE_APP_ROOT`. Jeder Eintrag verwendet genau eine
-der drei Formen:
+All paths are relative to `MANAGE_APP_ROOT`. Every entry uses exactly one
+of three forms:
 
 ```php
 define("MANAGE_BACKUP_SOURCES", [
-    // Nicht-rekursives Glob-Muster
+    // Non-recursive glob pattern
     ["as" => "data", "glob" => "data/*.json"],
 
-    // Verzeichnis, rekursiv
+    // Directory, recursive
     ["as" => "data/uploads", "dir" => "data/uploads"],
 
-    // Einzelne Datei
+    // Single file
     ["as" => "config", "file" => "config.dist.php"],
 ]);
 ```
 
-`as` ist das Präfix im Archiv. Ohne `as` landet der Eintrag auf oberster Ebene –
-bei `dir` wird der Verzeichnisname als Präfix verwendet.
+`as` is the prefix inside the archive. Without `as`, the entry lands at the
+top level — for `dir`, the directory name is used as the prefix.
 
-Das obige Beispiel erzeugt:
+The example above produces:
 
 ```text
 data/orders.json
 data/settings.json
-data/uploads/2026/bild.jpg
+data/uploads/2026/image.jpg
 config/config.dist.php
 ```
 
-### Was automatisch übersprungen wird
+### What gets skipped automatically
 
-- Dateien, deren Name mit einem Punkt beginnt
-- Dateien mit den Endungen `.tmp` und `.part`
-- nicht lesbare Dateien
+- files whose name starts with a dot
+- files with the extensions `.tmp` and `.part`
+- unreadable files
 
-Ergeben zwei Quellen denselben Archivpfad, gewinnt die erste. Ein Archiv enthält
-nie zwei Einträge mit gleichem Namen.
+If two sources produce the same archive path, the first one wins. An
+archive never contains two entries with the same name.
 
-### Was **nicht** hineingehört
+### What does **not** belong in it
 
-- `config.php` mit echten Zugangsdaten – ein Backup wird an den Server übertragen
-  und dort heruntergeladen; siehe [10_SECURITY](10_SECURITY.md)
-- das Backup-Verzeichnis selbst
-- Protokolle und Cache-Verzeichnisse
-- die Anwendungsdateien: Die kommen aus dem Release-Paket, nicht aus dem Backup
+- `config.php` with real credentials — a backup gets transferred to the
+  server and downloaded there; see [10_SECURITY](10_SECURITY.md)
+- the backup directory itself
+- logs and cache directories
+- the application files: those come from the release package, not from the
+  backup
 
-Ein Backup sichert **Betriebsdaten**, kein vollständiges Systemabbild.
+A backup secures **operational data**, not a complete system image.
 
-## Datenbank-Dump
+## Database dump
 
-Nur aktiv, wenn `MANAGE_BACKUP_DATABASE` gesetzt ist:
+Only active when `MANAGE_BACKUP_DATABASE` is set:
 
 ```php
 define("MANAGE_BACKUP_DATABASE", [
-    "dsn"      => "mysql:host=localhost;dbname=meinprojekt;charset=utf8mb4",
-    "user"     => "meinprojekt",
+    "dsn"      => "mysql:host=localhost;dbname=myproject;charset=utf8mb4",
+    "user"     => "myproject",
     "password" => "…",
 
-    // Optional: von diesen Tabellen nur die Struktur sichern, nicht die Zeilen.
+    // Optional: back up only the structure of these tables, not their rows.
     "skip_data_tables" => ["sessions", "cache"],
 
-    // Optional: Name im Archiv, falls SELECT DATABASE() nichts liefert.
-    "name" => "meinprojekt",
+    // Optional: name inside the archive, if SELECT DATABASE() returns nothing.
+    "name" => "myproject",
 ]);
 ```
 
-Der Dump landet im Archiv als `database/<name>.sql` und enthält für jede Tabelle
-`DROP TABLE IF EXISTS`, das `CREATE TABLE` aus `SHOW CREATE TABLE` und die Zeilen
-als einzelne `INSERT`-Anweisungen. `SET FOREIGN_KEY_CHECKS=0` steht am Anfang, am
-Ende wird wieder auf `1` gesetzt.
+The dump lands in the archive as `database/<name>.sql` and contains, for
+every table, `DROP TABLE IF EXISTS`, the `CREATE TABLE` from `SHOW CREATE
+TABLE`, and the rows as individual `INSERT` statements.
+`SET FOREIGN_KEY_CHECKS=0` sits at the top, set back to `1` at the end.
 
-Eigenschaften und Grenzen:
+Properties and limits:
 
-- Nur PDO, kein `mysqldump`-Aufruf – das ist auf vielen Shared-Hostern die einzige
-  Möglichkeit, funktioniert aber nur für MySQL und MariaDB.
-- Die Zeilen werden in Blöcken von 500 gelesen und sofort geschrieben, sodass der
-  Speicherbedarf nicht mit der Tabellengröße wächst.
-- Binärwerte werden als `0x…`-Literal geschrieben, damit der Dump gültiges ASCII bleibt.
-- Views werden als Struktur gesichert, aber nicht mit Daten.
-- Der Dump ist **nicht** transaktional konsistent. Für eine Anwendung mit
-  gleichzeitigem Schreibverkehr sollte das Backup in einer ruhigen Zeit laufen.
+- PDO only, no `mysqldump` call — on many shared hosts that's the only
+  option available, but it only works for MySQL and MariaDB.
+- Rows are read in blocks of 500 and written immediately, so memory use
+  doesn't grow with table size.
+- Binary values are written as `0x…` literals, so the dump stays valid
+  ASCII.
+- Views are backed up as structure, but without data.
+- The dump is **not** transactionally consistent. For an application with
+  concurrent writes, the backup should run during a quiet period.
 
-Wiederherstellung von Hand:
+Manual restore:
 
 ```bash
-unzip -p backup-20260820-092104.zip database/meinprojekt.sql | mysql meinprojekt
+unzip -p backup-20260820-092104.zip database/myproject.sql | mysql myproject
 ```
 
-## Kompression
+## Compression
 
-`MANAGE_BACKUP_COMPRESS` (Standard `true`) schaltet Deflate ein. Der Unterschied
-ist bei SQL-Dumps und JSON groß, bei bereits komprimierten Bildern praktisch null.
-Fehlt zlib, schreibt der Client unkomprimiert weiter statt abzubrechen.
+`MANAGE_BACKUP_COMPRESS` (default `true`) turns on deflate. The difference
+is large for SQL dumps and JSON, practically zero for already-compressed
+images. If zlib is missing, the client keeps writing uncompressed instead
+of aborting.
 
-Grenzen des ZIP-Formats ohne Zip64: 4 GB pro Datei, 4 GB pro Archiv, 65535 Einträge.
-Wird eine Grenze überschritten, bricht das Backup mit einer klaren Meldung ab.
+Limits of the ZIP format without Zip64: 4 GB per file, 4 GB per archive,
+65535 entries. If a limit is exceeded, the backup aborts with a clear
+message.
 
-## Ziele
+## Targets
 
-### Manage-Server
+### Manage server
 
-Standardziel, aktiv über `MANAGE_BACKUP_UPLOAD` (Standard `true`). Konfiguriert
-wird nichts weiter: Es gelten `MANAGE_SERVER_URL`, `MANAGE_INSTANCE` und
-`MANAGE_TOKEN`. Der Server prüft die mitgesendete Prüfsumme erneut und lehnt
-abweichende Uploads ab.
+Default target, active via `MANAGE_BACKUP_UPLOAD` (default `true`). Nothing
+else needs configuring: `MANAGE_SERVER_URL`, `MANAGE_INSTANCE` and
+`MANAGE_TOKEN` apply. The server re-checks the submitted checksum and
+rejects a mismatching upload.
 
-Für rein lokale Backups:
+For purely local backups:
 
 ```php
 define("MANAGE_BACKUP_UPLOAD", false);
 ```
 
-### Zusätzliche Ziele
+### Extra targets
 
-Jedes Ziel wird unabhängig versucht. Ein Fehlschlag macht weder das lokale Archiv
-noch die anderen Ziele ungültig.
+Every target is attempted independently. A failure invalidates neither the
+local archive nor the other targets.
 
-S3-kompatibler Speicher:
+S3-compatible storage:
 
 ```php
 define("MANAGE_BACKUP_REMOTE_TARGETS", [
     [
-        "name"       => "S3 Archiv",
+        "name"       => "S3 Archive",
         "type"       => "s3",
         "bucket"     => "example-bucket",
         "region"     => "eu-central-1",
-        "prefix"     => "meinprojekt",
+        "prefix"     => "myproject",
         "access_key" => "AKIA…",
         "secret_key" => "…",
-        // Für S3-kompatible Anbieter:
+        // For S3-compatible providers:
         // "endpoint" => "https://fsn1.your-objectstorage.com",
     ],
 ]);
 ```
 
-Signiert wird mit AWS Signature V4 über normale PHP-HTTPS-Streams; eine Bibliothek
-wird nicht benötigt. Das Archiv wird für die Signatur vollständig in den Speicher
-geladen – ein Backup größer als `memory_limit` kann dieses Ziel nicht nutzen.
+Signing uses AWS Signature V4 over plain PHP HTTPS streams; no library is
+required. The archive is loaded fully into memory for signing — a backup
+larger than `memory_limit` cannot use this target.
 
-SFTP (benötigt die PHP-Erweiterung `ssh2`):
+SFTP (needs the PHP `ssh2` extension):
 
 ```php
 [
@@ -163,31 +167,31 @@ SFTP (benötigt die PHP-Erweiterung `ssh2`):
     "port"     => 22,
     "username" => "backup-user",
     "password" => "…",
-    "path"     => "/backups/meinprojekt",
+    "path"     => "/backups/myproject",
 ]
 ```
 
-Mit Schlüssel statt Passwort:
+With a key instead of a password:
 
 ```php
 [
     "type"        => "sftp",
     "host"        => "backup.example.org",
     "username"    => "backup-user",
-    "public_key"  => "/pfad/zu/backup.pub",
-    "private_key" => "/pfad/zu/backup",
-    "password"    => "",        // Passphrase des Schlüssels, sonst leer
-    "path"        => "/backups/meinprojekt",
+    "public_key"  => "/path/to/backup.pub",
+    "private_key" => "/path/to/backup",
+    "password"    => "",        // key passphrase, empty otherwise
+    "path"        => "/backups/myproject",
 ]
 ```
 
-Das Zielverzeichnis muss existieren und beschreibbar sein; es wird nicht angelegt.
+The target directory must exist and be writable; it is not created.
 
-Eigener Uploader:
+Custom uploader:
 
 ```php
 [
-    "name"     => "Eigenes Ziel",
+    "name"     => "Custom Target",
     "type"     => "custom",
     "file"     => MANAGE_APP_ROOT . "/includes/backup-uploader.php",
     "callback" => "myProjectUploadBackup",
@@ -198,22 +202,22 @@ Eigener Uploader:
 function myProjectUploadBackup(string $archivePath, array $metadata, array $target)
 {
     // $metadata: filename, created_at, trigger, sha256, file_count, source_bytes
-    // Erfolg: true oder ein Array zurückgeben.
-    // Fehler: Exception werfen oder ["success" => false, "error" => "…"].
+    // Success: return true or an array.
+    // Failure: throw an exception, or return ["success" => false, "error" => "…"].
     return ["remote_path" => "…"];
 }
 ```
 
-## Protokollierung und Geheimnisse
+## Logging and secrets
 
-Fehlgeschlagene Uploads werden mit HTTP-Status und Antwortauszug protokolliert.
-Zugangsdaten sind davon ausgenommen: Aus der Zielkonfiguration wird nur eine
-Positivliste unkritischer Schlüssel übernommen (`name`, `type`, `url`, `bucket`,
+Failed uploads are logged with HTTP status and a response excerpt.
+Credentials are excluded from that: only an allowlist of non-sensitive keys
+is taken from the target configuration (`name`, `type`, `url`, `bucket`,
 `region`, `prefix`, `endpoint`, `host`, `port`, `username`, `path`, `file`,
-`callback`, `timeout`). `access_key`, `secret_key` und `password` erscheinen nie
-im Protokoll.
+`callback`, `timeout`). `access_key`, `secret_key` and `password` never
+appear in the log.
 
-## Weiter
+## Next
 
-- [04_FUNCTION_API](04_FUNCTION_API.md) – `manageBackupCreate()` und Rückgabewerte
-- [09_TROUBLESHOOTING](09_TROUBLESHOOTING.md) – Fehlermeldungen beim Backup
+- [04_FUNCTION_API](04_FUNCTION_API.md) – `manageBackupCreate()` and its return values
+- [09_TROUBLESHOOTING](09_TROUBLESHOOTING.md) – error messages during backup

+ 85 - 79
client-package/docs/06_UPDATE_PACKAGING.md

@@ -1,90 +1,95 @@
-# Release-Pakete bauen
+# Building Release Packages
 
-## Überblick
+## Overview
 
-Der Updater rollt ein ZIP über den Anwendungsstamm aus: Jede Datei im Paket wird an
-dieselbe relative Position im Projekt kopiert. Damit das funktioniert, muss das
-Paket richtig geschnitten sein.
+The updater rolls a ZIP out over the application root: every file in the
+package is copied to the same relative position in the project. For that
+to work, the package has to be cut correctly.
 
-Relevante Dateien:
+Relevant files:
 
-- `manage-client/lib/updater.php` – Prüfung, Entpacken, Ausrollen
-- auf dem Server: `scripts/create-release-zip.sh` – fertiges Build-Skript
+- `manage-client/lib/updater.php` – checking, extracting, deploying
+- in this package: `scripts/create-release-zip.sh` – ready-made build script
 
-## Aufbau des Pakets
+## Package layout
 
-Die Wurzel des ZIP **ist** der Anwendungsstamm. Kein zusätzliches Oberverzeichnis:
+The root of the ZIP **is** the application root. No extra top-level
+directory:
 
 ```text
-richtig                        falsch
-------                         ------
-index.php                      meinprojekt-v1.3.0/index.php
-admin/orders.php               meinprojekt-v1.3.0/admin/orders.php
-includes/version.php           meinprojekt-v1.3.0/includes/version.php
+correct                        wrong
+-------                        -----
+index.php                      myproject-v1.3.0/index.php
+admin/orders.php               myproject-v1.3.0/admin/orders.php
+includes/version.php           myproject-v1.3.0/includes/version.php
 manage-client/lib/client.php
 migrations/2026-08-20-01-x.php
 ```
 
-Ein Paket mit Oberverzeichnis würde das Projekt nicht aktualisieren, sondern einen
-neuen Unterordner anlegen.
+A package with a top-level directory wouldn't update the project — it would
+create a new subfolder.
 
-## Was hineingehört
+## What belongs in it
 
-- alle Anwendungsdateien
-- `includes/version.php` (oder die konfigurierte Versionsdatei) mit der **neuen** Version
-- `manage-client/` **ohne** `config.php` – so wird der Client mit aktualisiert
-- `migrations/`, sofern das Release Migrationen mitbringt
+- all application files
+- `includes/version.php` (or the configured version file) with the **new**
+  version
+- `manage-client/` **without** `config.php` — this is how the client itself
+  gets updated too
+- `migrations/`, if the release brings migrations along
 
-## Was draußen bleiben muss
+## What must stay out
 
-| Ausschluss | Grund |
+| Exclusion | Reason |
 |---|---|
-| `config.php` | enthält Zugangsdaten der Zielinstallation |
-| `manage-client/config.php` | enthält Instanz-Token der Zielinstallation |
-| `data/` | Betriebsdaten der Zielinstallation |
-| `.git/` | gehört nicht auf einen Produktivserver |
-| `build/`, `storage/` | Artefakte |
+| `config.php` | holds the target installation's credentials |
+| `manage-client/config.php` | holds the target installation's instance token |
+| `data/` | the target installation's operational data |
+| `.git/` | doesn't belong on a production server |
+| `build/`, `storage/` | build artifacts |
 
-`config.php`, `data/` und `.git/` sind zusätzlich über
-`MANAGE_UPDATE_PROTECTED_PATHS` geschützt: Selbst wenn sie versehentlich im Paket
-landen, werden sie beim Ausrollen übersprungen. Der Ausschluss beim Bauen ist
-trotzdem nötig, weil das Paket sonst fremde Zugangsdaten enthält und auf dem
-Manage-Server heruntergeladen werden kann.
+`config.php`, `data/` and `.git/` are additionally protected via
+`MANAGE_UPDATE_PROTECTED_PATHS`: even if they end up in the package by
+accident, they're skipped during deployment. Excluding them at build time
+is still necessary, because otherwise the package contains someone else's
+credentials and can be downloaded from the Manage server.
 
-## Versionsnummer
+## Version number
 
-Das Format ist `vX.Y.Z` – ohne Suffix, ohne Präfix. Sowohl der Client als auch der
-Server lehnen alles andere ab.
+The format is `vX.Y.Z` — no suffix, no prefix. Both client and server
+reject anything else.
 
-Die Version steht an genau einer Stelle: in der Versionsdatei innerhalb des Pakets.
-Der Client schreibt sie nie selbst; sie ändert sich als Nebeneffekt des
-Dateikopierens. Wird sie beim Bauen vergessen, meldet die Instanz nach dem Update
-weiterhin die alte Version und bietet dasselbe Update erneut an.
+The version lives in exactly one place: the version file inside the
+package. The client never writes it itself; it changes as a side effect of
+copying files. If it's forgotten at build time, the instance keeps
+reporting the old version after the update and keeps offering the same
+update again.
 
-## Build mit dem mitgelieferten Skript
+## Building with the bundled script
 
-Auf dem Manage-Server liegt `scripts/create-release-zip.sh`. Es wird einmal pro
-Projekt am Kopf angepasst (Produktname, Versionsdatei, Ausschlüsse) und dann im
-Projektverzeichnis aufgerufen:
+This package contains `scripts/create-release-zip.sh`. The script gets
+copied to `scripts/` of the project, adjusted once per project at the top
+(product name, version file, exclusions), and then run from the project
+directory:
 
 ```bash
 ./scripts/create-release-zip.sh v1.3.0
 ```
 
-Das Skript
+The script
 
-1. schreibt die Version in die Versionsdatei,
-2. packt alle von Git verfolgten Dateien abzüglich der Ausschlussliste,
-3. gibt SHA-256 und Größe aus.
+1. writes the version into the version file,
+2. packs every file tracked by Git, minus the exclusion list,
+3. prints SHA-256 and size.
 
-Es verwendet `git ls-files`, damit nur eingecheckte Dateien im Paket landen –
-lokale Experimente und ignorierte Dateien bleiben automatisch draußen.
+It uses `git ls-files`, so only checked-in files end up in the package —
+local experiments and ignored files stay out automatically.
 
-## Build von Hand
+## Building by hand
 
 ```bash
-cd /pfad/zum/projekt
-zip -r ../meinprojekt-v1.3.0.zip . \
+cd /path/to/project
+zip -r ../myproject-v1.3.0.zip . \
     -x 'config.php' \
        'manage-client/config.php' \
        'data/*' \
@@ -92,40 +97,41 @@ zip -r ../meinprojekt-v1.3.0.zip . \
        'build/*'
 ```
 
-Prüfen, was tatsächlich drin ist – dieser Schritt lohnt sich immer:
+Check what's actually in it — this step always pays off:
 
 ```bash
-unzip -l ../meinprojekt-v1.3.0.zip | head -30
+unzip -l ../myproject-v1.3.0.zip | head -30
 ```
 
-## Veröffentlichen
+## Publishing
 
-Im Manage-Server unter **Releases**: Version eintragen, ZIP hochladen. Prüfsumme
-und Größe berechnet der Server selbst; sie werden nie vom Hochladenden übernommen.
-Ein Upload setzt das Release automatisch als aktuell.
+In the Manage server under **Releases**: enter the version, upload the
+ZIP. Checksum and size are computed by the server itself; they are never
+taken from the uploader. An upload automatically sets the release as
+current.
 
-## Was der Client beim Ausrollen prüft
+## What the client checks when deploying
 
-1. Größe und SHA-256 müssen dem Manifest entsprechen, sonst wird die Datei gelöscht.
-2. Jeder Eintrag im ZIP wird gegen Pfad-Ausbruch geprüft (`..`, absolute Pfade,
-   Laufwerksbuchstaben, Nullbytes).
-3. Das Paket muss mindestens einen der Pfade aus `MANAGE_UPDATE_SANITY_PATHS`
-   enthalten.
-4. Beim Kopieren wird jede vorhandene Zieldatei zuerst nach
-   `MANAGE_UPDATE_BACKUP_DIR` gesichert.
+1. Size and SHA-256 must match the manifest, otherwise the file is deleted.
+2. Every entry in the ZIP is checked against path traversal (`..`, absolute
+   paths, drive letters, null bytes).
+3. The package must contain at least one of the paths from
+   `MANAGE_UPDATE_SANITY_PATHS`.
+4. While copying, every existing target file is first backed up to
+   `MANAGE_UPDATE_BACKUP_DIR`.
 
-## Grenzen des Verfahrens
+## Limits of this approach
 
-- **Gelöschte Dateien werden nicht entfernt.** Das Ausrollen ist ein Überlagern.
-  Eine Datei, die es im neuen Release nicht mehr gibt, bleibt in der Installation
-  liegen. Soll sie wirklich verschwinden, gehört das in eine Migration.
-- **Kein Wartungsmodus.** Die Anwendung bleibt während des Kopierens erreichbar.
-  Bei größeren Umbauten sollte in einer Randzeit aktualisiert werden.
-- **Keine Rücknahme.** Die Sicherungskopien in `MANAGE_UPDATE_BACKUP_DIR` sind für
-  die manuelle Wiederherstellung gedacht; es gibt keinen Befehl dafür. Aufbewahrt
-  wird nur der letzte Lauf.
+- **Deleted files are not removed.** Deployment is an overlay. A file no
+  longer present in the new release stays behind in the installation. To
+  make it truly disappear, that belongs in a migration.
+- **No maintenance mode.** The application stays reachable while files are
+  being copied. For larger changes, update during a quiet period.
+- **No rollback.** The backup copies in `MANAGE_UPDATE_BACKUP_DIR` are meant
+  for manual restoration; there's no command for it. Only the most recent
+  run is kept.
 
-## Weiter
+## Next
 
-- [07_POST_UPDATE_HOOKS](07_POST_UPDATE_HOOKS.md) – Migrationen im Paket
+- [07_POST_UPDATE_HOOKS](07_POST_UPDATE_HOOKS.md) – migrations in the package
 - [04_FUNCTION_API](04_FUNCTION_API.md) – `manageUpdateApply()`

+ 82 - 80
client-package/docs/07_POST_UPDATE_HOOKS.md

@@ -1,31 +1,30 @@
-# Post-Update-Hook und Migrationen
+# Post-Update Hook and Migrations
 
-## Überblick
+## Overview
 
-Nach einem erfolgreichen Ausrollen führt der Client einen Post-Update-Schritt aus.
-Er besteht aus zwei unabhängigen Mechanismen, die einzeln oder gemeinsam genutzt
-werden:
+After a successful deployment, the client runs a post-update step. It
+consists of two independent mechanisms, used individually or together:
 
-1. **Migrationen** – geordnete, einmalig laufende Skripte, die mit dem Release
-   ausgeliefert werden. Der übliche Ort für Datenbankänderungen.
-2. **Projekt-Callback** – eine Funktion des Projekts, die nach jedem Update läuft.
-   Für Cache leeren, abgeleitete Dateien neu bauen, Rechte setzen.
+1. **Migrations** – ordered, one-time scripts shipped with the release. The
+   usual place for database changes.
+2. **Project callback** – a project function that runs after every update.
+   For clearing caches, rebuilding derived files, setting permissions.
 
-Relevante Dateien:
+Relevant files:
 
-- `manage-client/lib/hooks.php` – beide Mechanismen
+- `manage-client/lib/hooks.php` – both mechanisms
 - `MANAGE_MIGRATIONS_DIR`, `MANAGE_MIGRATIONS_STATE`, `MANAGE_UPDATE_POST_HOOK`
 
-Reihenfolge: erst die Migrationen, dann der Callback – damit der Callback sich auf
-das neue Schema verlassen kann. Scheitert eine Migration, wird der Callback
-**nicht** ausgeführt.
+Order: migrations first, then the callback — so the callback can rely on
+the new schema. If a migration fails, the callback is **not** run.
 
-## Migrationen
+## Migrations
 
-### Ablage
+### Location
 
-Migrationen liegen im Verzeichnis aus `MANAGE_MIGRATIONS_DIR` (Standard
-`migrations/` im Anwendungsstamm) und werden **mit dem Release-Paket ausgeliefert**.
+Migrations live in the directory from `MANAGE_MIGRATIONS_DIR` (default
+`migrations/` in the application root) and are **shipped with the release
+package**.
 
 ```text
 migrations/
@@ -33,13 +32,13 @@ migrations/
   2026-08-21-01-backfill-categories.php
 ```
 
-Ausgeführt wird in **Dateinamen-Reihenfolge**. Ein Datum als Präfix mit laufender
-Nummer sortiert zuverlässig. Der Dateiname ohne `.php` ist die Kennung der
-Migration; wird eine bereits ausgeführte Datei umbenannt, läuft sie erneut.
+Run in **filename order**. A date prefix with a running number sorts
+reliably. The filename without `.php` is the migration's id; renaming an
+already-run file makes it run again.
 
-### Aufbau
+### Structure
 
-Empfohlene Form – die Datei gibt eine Funktion zurück:
+Recommended form — the file returns a function:
 
 ```php
 <?php
@@ -49,7 +48,7 @@ return function (array $context): void {
 };
 ```
 
-Alternativ definiert die Datei eine Funktion `up()`:
+Alternatively, the file defines a function `up()`:
 
 ```php
 <?php
@@ -60,29 +59,29 @@ function up(array $context): void
 }
 ```
 
-Die zurückgegebene Funktion ist die bessere Wahl: Zwei Migrationen, die beide `up()`
-definieren, würden sich im selben Prozess in die Quere kommen.
+The returned-function form is the better choice: two migrations that both
+define `up()` would collide within the same process.
 
-### Der Kontext
+### The context
 
 ```php
 [
-    "app_root"     => "/var/www/meinprojekt",
-    "instance"     => "meinprojekt-prod",
+    "app_root"     => "/var/www/myproject",
+    "instance"     => "myproject-prod",
     "from_version" => "v1.2.3",
     "to_version"   => "v1.3.0",
     "backup_dir"   => "/…/data/manage/updates/20260820-092114-v1.3.0",
     "run_id"       => "20260820-092114",
     "migration_id" => "2026-08-20-01-add-orders-index",
-    "pdo"          => PDO,   // nur wenn MANAGE_BACKUP_DATABASE konfiguriert ist
+    "pdo"          => PDO,   // only when MANAGE_BACKUP_DATABASE is configured
 ]
 ```
 
-`pdo` verwendet die Zugangsdaten, die ohnehin für den Datenbank-Dump konfiguriert
-sind. Eine zweite Konfiguration ist nicht nötig. Projekte ohne Datenbank arbeiten
-mit `app_root`.
+`pdo` uses the credentials already configured for the database dump. A
+second configuration isn't needed. Projects without a database work with
+`app_root`.
 
-### Beispiel: Datenbank
+### Example: database
 
 ```php
 <?php
@@ -90,7 +89,7 @@ mit `app_root`.
 return function (array $context): void {
     $pdo = $context["pdo"];
 
-    // Idempotent halten: die Migration kann nach einem Teilfehler erneut laufen.
+    // Keep it idempotent: the migration can run again after a partial failure.
     $exists = $pdo->query(
         "SELECT COUNT(*) FROM information_schema.statistics
          WHERE table_schema = DATABASE()
@@ -104,7 +103,7 @@ return function (array $context): void {
 };
 ```
 
-### Beispiel: JSON-Daten
+### Example: JSON data
 
 ```php
 <?php
@@ -126,9 +125,9 @@ return function (array $context): void {
 };
 ```
 
-### Zustand
+### State
 
-Ausgeführte Migrationen werden in `MANAGE_MIGRATIONS_STATE` festgehalten:
+Executed migrations are recorded in `MANAGE_MIGRATIONS_STATE`:
 
 ```json
 {
@@ -143,16 +142,17 @@ Ausgeführte Migrationen werden in `MANAGE_MIGRATIONS_STATE` festgehalten:
 }
 ```
 
-Diese Datei liegt im Datenverzeichnis und ist damit von Updates ausgenommen. Sie
-sollte im Backup enthalten sein, wenn `data/manage/` in den Quellen steht – ist es
-standardmäßig nicht, weil das Backup-Verzeichnis sich sonst selbst sichern würde.
-Wer den Migrationszustand mitsichern will, nimmt ihn einzeln auf:
+This file lives in the data directory and is therefore exempt from updates.
+It should be included in the backup if `data/manage/` is among the
+sources — by default it isn't, because the backup directory would
+otherwise back up itself. To include the migration state, add it
+individually:
 
 ```php
 ["as" => "manage", "file" => "data/manage/migrations.json"],
 ```
 
-## Projekt-Callback
+## Project callback
 
 ```php
 define("MANAGE_UPDATE_POST_HOOK", [
@@ -166,69 +166,71 @@ define("MANAGE_UPDATE_POST_HOOK", [
 
 function myProjectAfterUpdate(array $context): void
 {
-    // Cache leeren, abgeleitete Dateien neu bauen, Verzeichnis anlegen …
+    // Clear caches, rebuild derived files, create a directory …
     array_map("unlink", glob($context["app_root"] . "/data/cache/*.php") ?: []);
 }
 ```
 
-Als Fehlschlag gilt: eine geworfene Exception, `return false` oder
-`return ["success" => false, "error" => "…"]`. Alles andere gilt als Erfolg.
+Counted as failure: a thrown exception, `return false`, or
+`return ["success" => false, "error" => "…"]`. Everything else counts as
+success.
 
-Der Callback erhält denselben Kontext wie eine Migration, zusätzlich `migrations`
-mit der Liste der in diesem Lauf ausgeführten Kennungen.
+The callback receives the same context as a migration, plus `migrations`
+with the list of ids executed in this run.
 
-## Fehlerverhalten
+## Failure behavior
 
-Der wichtigste Punkt: **Zu diesem Zeitpunkt sind die Dateien bereits ausgerollt,
-und es gibt keine Rücknahme.** Ein Fehler wird deshalb laut gemeldet statt still
-verschluckt.
+The most important point: **at this point the files are already deployed,
+and there is no rollback.** A failure is therefore reported loudly instead
+of swallowed silently.
 
-Konkret:
+Specifically:
 
-- Der Lauf stoppt bei der ersten fehlgeschlagenen Migration. Die folgenden bleiben
-  offen und werden nicht versucht.
-- Der Callback wird bei einer fehlgeschlagenen Migration übersprungen.
-- `manageUpdateApply()` kehrt **normal zurück**, mit `deployed => true` und
+- The run stops at the first failed migration. The following ones stay
+  pending and are not attempted.
+- The callback is skipped if a migration failed.
+- `manageUpdateApply()` returns **normally**, with `deployed => true` and
   `hook["success"] => false`.
-- Die Kommandozeile beendet sich mit Exit-Code `1` und nennt die betroffene Datei –
-  meldet aber ausdrücklich, dass das Ausrollen erfolgreich war.
-- Die Oberfläche zeigt einen roten Hinweis mit dem Namen der Migration.
-- Beides steht im Client-Protokoll.
+- The command line exits with code `1` and names the affected file — but
+  explicitly reports that deployment itself succeeded.
+- The UI shows a red notice with the migration's name.
+- Both are recorded in the client log.
 
-Wiederherstellung nach einem Fehler:
+Recovery after a failure:
 
 ```bash
-# 1. Ursache beheben (Migration korrigieren, Rechte setzen, Datenbank prüfen)
-# 2. Offene Migrationen ansehen
+# 1. Fix the cause (correct the migration, set permissions, check the database)
+# 2. Look at pending migrations
 php manage-client/bin/manage-client.php migrate --dry-run
-# 3. Nachziehen
+# 3. Catch up
 php manage-client/bin/manage-client.php migrate
 ```
 
-Ein erneutes `update --force` rollt die Dateien nochmals aus, führt aber **keine
-bereits ausgeführten Migrationen erneut aus**.
+Running `update --force` again redeploys the files, but **does not re-run
+migrations that already ran**.
 
-## Idempotenz
+## Idempotency
 
-Migrationen sollen mehrfach ausführbar sein. Grund: Wenn eine Migration mittendrin
-scheitert – etwa nach der Hälfte einer Datenumstellung – wird sie nicht als
-ausgeführt vermerkt und läuft beim nächsten `migrate` erneut von vorn. Nur eine
-idempotente Migration übersteht das unbeschadet.
+Migrations should be safe to run more than once. Reason: if a migration
+fails partway through — say, after half of a data conversion — it is not
+recorded as applied and runs again from the start on the next `migrate`.
+Only an idempotent migration survives that unscathed.
 
-Praktisch heißt das: vor dem Ändern prüfen, ob die Änderung schon da ist, und
-Datenumstellungen so schreiben, dass bereits umgestellte Sätze übersprungen werden.
+In practice that means: check whether the change is already there before
+making it, and write data conversions so that already-converted rows are
+skipped.
 
-## Ohne Post-Update-Schritt ausrollen
+## Deploying without the post-update step
 
 ```bash
 php manage-client/bin/manage-client.php update --skip-hook
 ```
 
-Rollt nur die Dateien aus. Die Migrationen bleiben offen und können später mit
-`migrate` nachgezogen werden. Nützlich, wenn die Dateien dringend gebraucht werden,
-die Datenbankänderung aber in ein Wartungsfenster gehört.
+Deploys only the files. Migrations stay pending and can be caught up later
+with `migrate`. Useful when the files are urgently needed but the database
+change belongs in a maintenance window.
 
-## Weiter
+## Next
 
-- [06_UPDATE_PACKAGING](06_UPDATE_PACKAGING.md) – Migrationen ins Paket bekommen
+- [06_UPDATE_PACKAGING](06_UPDATE_PACKAGING.md) – getting migrations into the package
 - [04_FUNCTION_API](04_FUNCTION_API.md) – `manageUpdateRunMigrations()`

+ 64 - 60
client-package/docs/08_PROTOCOL.md

@@ -1,38 +1,42 @@
-# Protokoll v1
+# Protocol v1
 
-## Überblick
+## Overview
 
-Die Schnittstelle zwischen Client und Manage-Server. Wer den mitgelieferten Client
-verwendet, braucht dieses Dokument nicht – es ist für eigene Clients, für Debugging
-und für die Fehlersuche mit `curl` gedacht.
+The interface between client and Manage server. Anyone using the bundled
+client doesn't need this document — it's for custom clients, for debugging,
+and for troubleshooting with `curl`.
 
-Basis-URL: `<MANAGE_SERVER_URL>/api/v1/`
+Base URL: `<MANAGE_SERVER_URL>/api/v1/`
 
-## Authentifizierung
+Note: the server's actual `error` strings are still German text, shown
+verbatim in the examples below — this document's prose is in English, the
+API contract is not.
 
-Jede Anfrage trägt zwei Header:
+## Authentication
+
+Every request carries two headers:
 
 ```http
-X-Manage-Instance: meinprojekt-prod
+X-Manage-Instance: myproject-prod
 X-Manage-Token:    e4032c4dc51e9100…
 ```
 
-Der Server speichert nur den SHA-256-Hash des Tokens und vergleicht in konstanter
-Zeit. Es gibt keine Sitzung, kein Cookie und kein gemeinsames Passwort.
+The server stores only the SHA-256 hash of the token and compares it in
+constant time. There is no session, no cookie and no shared password.
 
-Fehlerantworten:
+Error responses:
 
-| Status | Bedeutung |
+| Status | Meaning |
 |---|---|
-| `401` | Header fehlen, Instanz unbekannt oder Token falsch – bewusst nicht unterscheidbar |
-| `403` | Instanz existiert, ist aber deaktiviert |
-| `405` | Falsche HTTP-Methode |
-| `429` | Zu viele fehlgeschlagene Authentifizierungen von dieser IP |
+| `401` | headers missing, instance unknown, or token wrong — deliberately indistinguishable |
+| `403` | instance exists but is deactivated |
+| `405` | wrong HTTP method |
+| `429` | too many failed authentications from this IP |
 
-Fehlgeschlagene Anmeldungen sind pro IP begrenzt, damit Instanz-Kennungen nicht
-durchprobiert werden können. Eine erfolgreiche Anmeldung setzt den Zähler zurück.
+Failed logins are rate-limited per IP, so instance ids can't be brute-forced.
+A successful login resets the counter.
 
-Alle Fehlerantworten haben denselben Aufbau:
+Every error response has the same shape:
 
 ```json
 {
@@ -43,11 +47,11 @@ Alle Fehlerantworten haben denselben Aufbau:
 
 ## GET manifest.php
 
-Liefert das Release, das die Instanz installieren soll.
+Returns the release the instance should install.
 
 ```bash
 curl -s https://manage.example.org/api/v1/manifest.php \
-  -H "X-Manage-Instance: meinprojekt-prod" \
+  -H "X-Manage-Instance: myproject-prod" \
   -H "X-Manage-Token: $TOKEN"
 ```
 
@@ -63,46 +67,46 @@ curl -s https://manage.example.org/api/v1/manifest.php \
 }
 ```
 
-`404`, wenn kein gültiges Release veröffentlicht ist.
+`404` if no valid release is published.
 
-`package_url` wird aus der Serverkonfiguration (`MANAGE_PUBLIC_URL`) gebildet, nicht
-aus dem `Host`-Header der Anfrage. Ein gefälschter Header kann einen Client daher
-nicht auf einen fremden Server umlenken.
+`package_url` is built from the server configuration (`MANAGE_PUBLIC_URL`),
+not from the request's `Host` header. A spoofed header therefore can't
+redirect a client to a foreign server.
 
 ## GET package.php
 
-Liefert das Release-ZIP.
+Returns the release ZIP.
 
 ```bash
 curl -s -o release.zip \
   "https://manage.example.org/api/v1/package.php?version=v1.3.0" \
-  -H "X-Manage-Instance: meinprojekt-prod" \
+  -H "X-Manage-Instance: myproject-prod" \
   -H "X-Manage-Token: $TOKEN"
 ```
 
-Antwort: `application/zip` mit `Content-Length` und
-`Cache-Control: private, no-store`. Bei Erfolg kein JSON.
+Response: `application/zip` with `Content-Length` and
+`Cache-Control: private, no-store`. No JSON on success.
 
-`400` bei ungültigem Versionsformat, `404`, wenn das Release nicht existiert.
+`400` on an invalid version format, `404` if the release doesn't exist.
 
-Der Client vergleicht Größe und SHA-256 mit dem Manifest und löscht die Datei bei
-Abweichung. Ein eigener Client muss das ebenso tun – ohne diese Prüfung wird
-beliebiger Code ausgerollt.
+The client compares size and SHA-256 against the manifest and deletes the
+file on mismatch. A custom client must do the same — without this check,
+arbitrary code gets deployed.
 
 ## POST backup.php
 
-Nimmt ein Backup-Archiv entgegen. `multipart/form-data`:
+Accepts a backup archive. `multipart/form-data`:
 
-| Feld | Pflicht | Bedeutung |
+| Field | Required | Meaning |
 |---|---|---|
-| `backup` | ja | die ZIP-Datei |
-| `filename` | nein | `backup-YYYYmmdd-HHMMSS[-N].zip`; ohne Angabe vergibt der Server einen Namen |
-| `sha256` | nein | Prüfsumme; wird serverseitig neu berechnet und verglichen |
-| `meta` | nein | JSON mit `trigger`, `file_count`, `source_bytes`, `app_version` |
+| `backup` | yes | the ZIP file |
+| `filename` | no | `backup-YYYYmmdd-HHMMSS[-N].zip`; the server assigns a name if omitted |
+| `sha256` | no | checksum; recomputed and compared server-side |
+| `meta` | no | JSON with `trigger`, `file_count`, `source_bytes`, `app_version` |
 
 ```bash
 curl -s https://manage.example.org/api/v1/backup.php \
-  -H "X-Manage-Instance: meinprojekt-prod" \
+  -H "X-Manage-Instance: myproject-prod" \
   -H "X-Manage-Token: $TOKEN" \
   -F "filename=backup-20260820-092104.zip" \
   -F "sha256=824f3f80…" \
@@ -113,7 +117,7 @@ curl -s https://manage.example.org/api/v1/backup.php \
 ```json
 {
     "success": true,
-    "instance": "meinprojekt-prod",
+    "instance": "myproject-prod",
     "filename": "backup-20260820-092104.zip",
     "size": 427,
     "sha256": "824f3f80…",
@@ -122,18 +126,18 @@ curl -s https://manage.example.org/api/v1/backup.php \
 }
 ```
 
-Der Server prüft in dieser Reihenfolge: Upload-Fehlercode, `is_uploaded_file`,
-Größenlimit, ZIP-Signatur, Dateinamensmuster, Prüfsumme nach dem Speichern. Weicht
-die Prüfsumme ab, wird die Datei wieder gelöscht und `400` gemeldet.
+The server checks in this order: upload error code, `is_uploaded_file`,
+size limit, ZIP signature, filename pattern, checksum after storing. If the
+checksum doesn't match, the file is deleted again and `400` is reported.
 
-Ein vorhandener Dateiname wird nie überschrieben: Der Server hängt `-2`, `-3` an.
+An existing filename is never overwritten: the server appends `-2`, `-3`.
 
-Fehler beim S3-Archivieren lassen den Upload **nicht** fehlschlagen – die lokale
-Kopie ist gespeichert und wird später nachgezogen.
+Errors during S3 archiving **do not** fail the upload — the local copy is
+stored and gets caught up later.
 
 ## POST heartbeat.php
 
-Statusmeldung. `application/json`:
+Status report. `application/json`:
 
 ```json
 {
@@ -148,24 +152,24 @@ Statusmeldung. `application/json`:
 ```json
 {
     "success": true,
-    "instance": "meinprojekt-prod",
+    "instance": "myproject-prod",
     "latest": "v1.3.0",
     "update_available": false,
     "server_time": "2026-08-20T09:23:11+00:00"
 }
 ```
 
-Alle Felder der Anfrage sind optional; fehlende Felder lassen den bisherigen Wert
-auf dem Server unverändert. Die Antwort ersetzt für einfache Überwachung einen
-eigenen Aufruf von `manifest.php`.
+All request fields are optional; missing fields leave the server's current
+value unchanged. The response replaces a separate call to `manifest.php`
+for simple monitoring.
 
-## Nebenwirkung jeder Anfrage
+## Side effect of every request
 
-Jede erfolgreich authentifizierte Anfrage aktualisiert `last_seen_at` und die
-letzte IP der Instanz. Die Übersicht im Manage-Server bleibt dadurch aktuell, auch
-wenn nur Backups laufen und nie ein Heartbeat gesendet wird.
+Every successfully authenticated request updates `last_seen_at` and the
+instance's latest IP. The overview in the Manage server stays current this
+way, even when only backups run and a heartbeat is never sent.
 
-## Weiter
+## Next
 
-- [09_TROUBLESHOOTING](09_TROUBLESHOOTING.md) – was einzelne Fehlermeldungen bedeuten
-- [10_SECURITY](10_SECURITY.md) – Umgang mit dem Token
+- [09_TROUBLESHOOTING](09_TROUBLESHOOTING.md) – what individual error messages mean
+- [10_SECURITY](10_SECURITY.md) – handling the token

+ 202 - 137
client-package/docs/09_TROUBLESHOOTING.md

@@ -1,116 +1,148 @@
-# Fehlersuche
+# Troubleshooting
 
-## Überblick
+## Overview
 
-Jede Fehlermeldung, die der Client erzeugen kann, mit Ursache und Behebung.
-Die Meldungen stammen aus `manage-client/lib/`.
+Every error message the client can produce, with cause and fix. The
+messages come from `manage-client/lib/`.
 
-Erste Anlaufstelle ist immer:
+Note: the client's actual messages are still German text (this is a German
+product), quoted verbatim in bold below. Each is followed by an *italic
+English gloss* and then an English explanation, so the heading itself stays
+searchable against what actually appears in your terminal or log.
+
+First stop is always:
 
 ```bash
 php manage-client/bin/manage-client.php status
 ```
 
-und danach das Protokoll in `MANAGE_LOG_FILE` (Standard
-`data/manage/manage-client.log`, eine JSON-Zeile pro Ereignis):
+and after that the log in `MANAGE_LOG_FILE` (default
+`data/manage/manage-client.log`, one JSON line per event):
 
 ```bash
 tail -20 data/manage/manage-client.log | php -r 'while($l=fgets(STDIN)) { $e=json_decode($l,true); echo $e["timestamp"]," ",$e["level"]," ",$e["message"],"\n"; }'
 ```
 
-## Konfiguration und Verbindung
+## Configuration and connection
 
 **`Manage-Client ist nicht konfiguriert. MANAGE_SERVER_URL, MANAGE_INSTANCE und MANAGE_TOKEN müssen in manage-client/config.php gesetzt sein.`**
-`config.php` fehlt oder einer der drei Werte ist leer. `config.sample.php` kopieren
-und die Werte aus dem Manage-Server eintragen. Prüfen, ob die Datei wirklich
-`manage-client/config.php` heißt.
-
-**`Manage-Server ist nicht erreichbar: <URL>`**
-Keine Antwort. Mögliche Ursachen: falsche `MANAGE_SERVER_URL`, DNS, Firewall,
-ausgehende Verbindungen auf dem Hoster gesperrt, TLS-Zertifikat nicht vertrauenswürdig.
-Prüfen mit `curl -v <URL>/api/v1/manifest.php` vom selben Server aus.
-
-**`Ungültige Server-URL: <URL>`**
-`MANAGE_SERVER_URL` ist keine gültige URL. Sie muss mit `https://` beginnen und darf
-weder `/api` noch einen Schrägstrich am Ende enthalten.
-
-**`Authentifizierung fehlgeschlagen. (HTTP 401)`**
-Instanz-Kennung oder Token stimmen nicht. Beides ist absichtlich nicht
-unterscheidbar. Im Manage-Server unter **Instanzen** ein neues Token erzeugen und
-eintragen; das alte wird dabei sofort ungültig.
-
-**`Diese Instanz ist deaktiviert. (HTTP 403)`**
-Die Instanz existiert, ist aber im Manage-Server deaktiviert. Dort wieder aktivieren.
-
-**`Zu viele Anfragen. Bitte später erneut versuchen. (HTTP 429)`**
-Zu viele fehlgeschlagene Authentifizierungen von dieser IP. Nach Ablauf des
-Zeitfensters (Standard 5 Minuten) mit korrektem Token erneut versuchen.
-
-**`Antwort des Servers ist kein gültiges JSON.`**
-Die Antwort kam nicht vom Manage-Server: meist eine Fehlerseite des Webservers,
-eine Umleitung oder ein Captive Portal. Antwort direkt mit `curl` ansehen.
+*("The Manage client is not configured. MANAGE_SERVER_URL, MANAGE_INSTANCE
+and MANAGE_TOKEN must be set in manage-client/config.php.")*
+`config.php` is missing, or one of the three values is empty. Copy
+`config.sample.php` and enter the values from the Manage server. Check that
+the file is really named `manage-client/config.php`.
+
+**`Manage-Server ist nicht erreichbar: <URL>`** *("Manage server is
+unreachable: <URL>")*
+No response. Possible causes: wrong `MANAGE_SERVER_URL`, DNS, a firewall,
+outbound connections blocked by the host, an untrusted TLS certificate.
+Check with `curl -v <URL>/api/v1/manifest.php` from the same server.
+
+**`Ungültige Server-URL: <URL>`** *("Invalid server URL: <URL>")*
+`MANAGE_SERVER_URL` isn't a valid URL. It must start with `https://` and
+must contain neither `/api` nor a trailing slash.
+
+**`Authentifizierung fehlgeschlagen. (HTTP 401)`** *("Authentication
+failed.")*
+Instance id or token don't match. Both are deliberately indistinguishable.
+In the Manage server, under **Instances**, generate a new token and enter
+it; the old one becomes invalid immediately.
+
+**`Diese Instanz ist deaktiviert. (HTTP 403)`** *("This instance is
+deactivated.")*
+The instance exists but is deactivated in the Manage server. Reactivate it
+there.
+
+**`Zu viele Anfragen. Bitte später erneut versuchen. (HTTP 429)`** *("Too
+many requests. Please try again later.")*
+Too many failed authentications from this IP. Retry with the correct token
+after the time window expires (default 5 minutes).
+
+**`Antwort des Servers ist kein gültiges JSON.`** *("The server's response
+is not valid JSON.")*
+The response didn't come from the Manage server: usually a web server error
+page, a redirect, or a captive portal. Inspect the response directly with
+`curl`.
 
 ## Update
 
 **`Es ist kein neueres Update verfügbar. Mit der Option "force" kann dasselbe Paket erneut ausgerollt werden.`**
-Kein Fehler. Auf der Kommandozeile `update --force`, in der Oberfläche das Häkchen
-"erneut ausrollen".
+*("No newer update is available. The "force" option can redeploy the same
+package.")*
+Not an error. On the command line, `update --force`; in the UI, the
+"redeploy" checkbox.
 
 **`Version im Manifest ist ungültig.` / `Prüfsumme im Manifest ist ungültig.` / `Paket-URL im Manifest ist ungültig.`**
-Der Server liefert ein unbrauchbares Manifest. Auf dem Server prüfen, ob ein Release
-veröffentlicht und als aktuell gesetzt ist. Bei "Paket-URL ungültig" ist meist
-`MANAGE_PUBLIC_URL` in der Serverkonfiguration nicht oder falsch gesetzt.
+*("Version in the manifest is invalid." / "Checksum in the manifest is
+invalid." / "Package URL in the manifest is invalid.")*
+The server returns an unusable manifest. On the server, check whether a
+release is published and set as current. For "package URL invalid",
+`MANAGE_PUBLIC_URL` in the server configuration is usually unset or wrong.
 
 **`Größe des heruntergeladenen Pakets stimmt nicht überein.` / `Prüfsumme des Pakets stimmt nicht überein.`**
-Das Paket entspricht nicht dem Manifest. Die heruntergeladene Datei wird sofort
-gelöscht und **nichts** ausgerollt. Ursachen: abgebrochener Download, ein Proxy der
-den Inhalt verändert, oder ein auf dem Server ausgetauschtes Paket. Release neu
-hochladen und erneut versuchen. Wiederholt sich der Fehler, ist die Übertragungskette
-zu prüfen, bevor ausgerollt wird.
-
-**`Das heruntergeladene Paket ist keine lesbare ZIP-Datei.`**
-Die Datei ist beschädigt oder es wurde etwas anderes als ein ZIP hochgeladen.
-
-**`Das Paket enthält einen unsicheren Pfad: <Pfad>`**
-Ein Eintrag versucht aus dem Zielverzeichnis auszubrechen (`..`, absoluter Pfad,
-Laufwerksbuchstabe, Nullbyte). Es wird nichts entpackt. Ein solches Paket darf nicht
-ausgerollt werden – Herkunft klären.
+*("Size of the downloaded package doesn't match." / "Checksum of the
+package doesn't match.")*
+The package doesn't match the manifest. The downloaded file is deleted
+immediately and **nothing** is deployed. Causes: an interrupted download, a
+proxy that alters the content, or a package swapped out on the server.
+Re-upload the release and try again. If it keeps happening, check the
+transfer chain before deploying.
+
+**`Das heruntergeladene Paket ist keine lesbare ZIP-Datei.`** *("The
+downloaded package is not a readable ZIP file.")*
+The file is corrupted, or something other than a ZIP was uploaded.
+
+**`Das Paket enthält einen unsicheren Pfad: <Pfad>`** *("The package
+contains an unsafe path: <path>")*
+An entry tries to break out of the target directory (`..`, an absolute
+path, a drive letter, a null byte). Nothing gets extracted. Such a package
+must not be deployed — find out where it came from.
 
 **`Das Paket sieht nicht wie ein Release dieser Anwendung aus (erwartet: index.php)`**
-Keiner der Pfade aus `MANAGE_UPDATE_SANITY_PATHS` ist im Paket. Fast immer wurde das
-ZIP mit einem Oberverzeichnis gebaut. Siehe
+*("The package doesn't look like a release of this application (expected:
+index.php)")*
+None of the paths from `MANAGE_UPDATE_SANITY_PATHS` is in the package.
+Almost always the ZIP was built with a top-level directory. See
 [06_UPDATE_PACKAGING](06_UPDATE_PACKAGING.md).
 
-**`Die PHP-Erweiterung ZipArchive ist nicht verfügbar.`**
-`ext-zip` fehlt. Updates brauchen sie; Backups funktionieren auch ohne, weil der
-Client dort einen eigenen ZIP-Writer verwendet. Beim Hoster aktivieren lassen.
+**`Die PHP-Erweiterung ZipArchive ist nicht verfügbar.`** *("The PHP
+ZipArchive extension is not available.")*
+`ext-zip` is missing. Updates need it; backups still work without it,
+because the client uses its own ZIP writer there. Have the host enable it.
 
 **`Datei konnte nicht ausgerollt werden: <Pfad>` / `Datei konnte nicht gesichert werden: <Pfad>`**
-Fehlende Schreibrechte im Anwendungsstamm. **Wichtig:** Dieser Fehler tritt
-mittendrin auf, das Ausrollen ist dann unvollständig. Rechte korrigieren und
-`update --force` erneut ausführen – der Lauf beginnt von vorn und stellt den
-vollständigen Zustand her.
+*("File could not be deployed: <path>" / "File could not be backed up:
+<path>")*
+Missing write permission in the application root. **Important:** this
+error occurs mid-run, so the deployment is then incomplete. Fix permissions
+and run `update --force` again — the run starts over and restores the
+complete state.
 
-**`Verzeichnis konnte nicht erstellt werden: <Pfad>`**
-Fehlende Schreibrechte auf dem übergeordneten Verzeichnis.
+**`Verzeichnis konnte nicht erstellt werden: <Pfad>`** *("Directory could
+not be created: <path>")*
+Missing write permission on the parent directory.
 
-**`Altes Backup-Verzeichnis konnte nicht entfernt werden: <Pfad>`**
-Das Ausrollen war erfolgreich, nur das Aufräumen alter Sicherungen scheiterte.
-Verzeichnis von Hand entfernen.
+**`Altes Backup-Verzeichnis konnte nicht entfernt werden: <Pfad>`** *("Old
+backup directory could not be removed: <path>")*
+Deployment succeeded; only cleaning up old backups failed. Remove the
+directory by hand.
 
-**`MANAGE_APP_ROOT existiert nicht: <Pfad>`**
-Der konfigurierte Anwendungsstamm ist falsch. Standard ist das Elternverzeichnis von
-`manage-client/`.
+**`MANAGE_APP_ROOT existiert nicht: <Pfad>`** *("MANAGE_APP_ROOT does not
+exist: <path>")*
+The configured application root is wrong. The default is the parent
+directory of `manage-client/`.
 
-## Migrationen und Hook
+## Migrations and hook
 
 **`Migration <id> liefert keine Funktion zurück und definiert kein up().`**
-Die Migrationsdatei muss entweder eine Funktion zurückgeben (`return function (array $context) {...};`)
-oder eine Funktion `up(array $context)` definieren.
+*("Migration <id> does not return a function and does not define up().")*
+The migration file must either return a function
+(`return function (array $context) {...};`) or define a function
+`up(array $context)`.
 
-**Migration schlägt mit einem eigenen Fehler fehl**
-Der Lauf stoppt, die folgenden Migrationen bleiben offen, die Dateien sind aber
-bereits ausgerollt. Ursache beheben, dann:
+**A migration fails with its own error**
+The run stops, the following migrations stay pending, but the files are
+already deployed. Fix the cause, then:
 
 ```bash
 php manage-client/bin/manage-client.php migrate --dry-run
@@ -118,108 +150,141 @@ php manage-client/bin/manage-client.php migrate
 ```
 
 **`Hook-Datei wurde nicht gefunden: <Pfad>` / `Hook-Callback ist nicht aufrufbar: <Name>`**
-`MANAGE_UPDATE_POST_HOOK` verweist auf eine fehlende Datei oder eine Funktion, die
-dort nicht definiert wird. Häufig, wenn die Hook-Datei nicht im Release-Paket liegt.
+*("Hook file was not found: <path>" / "Hook callback is not callable:
+<name>")*
+`MANAGE_UPDATE_POST_HOOK` points to a missing file, or a function that
+isn't defined there. Common when the hook file isn't in the release
+package.
 
-**`Post-Update-Hook meldet einen Fehler`**
-Der Callback hat `false` oder `["success" => false]` zurückgegeben. Die Dateien sind
-ausgerollt; die Details stehen im Protokoll.
+**`Post-Update-Hook meldet einen Fehler`** *("Post-update hook reports an
+error")*
+The callback returned `false` or `["success" => false]`. The files are
+deployed; the details are in the log.
 
 ## Backup
 
-**`Es läuft bereits ein Backup.`**
-Die Sperrdatei ist belegt: ein zweiter Lauf startete, während der erste noch lief.
-Meist überlappen Cron-Job und manueller Aufruf. Warten und erneut versuchen. Bleibt
-es dauerhaft, wurde ein früherer Lauf hart abgebrochen – die Sperre löst sich mit
-dem Prozessende von selbst; hilft das nicht, `data/manage/backups/.backup.lock`
-entfernen, wenn sicher kein Backup läuft.
+**`Es läuft bereits ein Backup.`** *("A backup is already running.")*
+The lock file is held: a second run started while the first was still
+going. Usually a cron job and a manual run overlap. Wait and retry. If it
+persists, an earlier run was killed hard — the lock releases itself when
+that process ends; if that doesn't help, remove
+`data/manage/backups/.backup.lock` once you're certain no backup is
+running.
 
 **`Keine Dateien für das Backup gefunden.` / `Keine lesbaren Dateien für das Backup gefunden.`**
-`MANAGE_BACKUP_SOURCES` trifft auf keine existierende Datei. Pfade sind relativ zu
-`MANAGE_APP_ROOT`. Prüfen mit:
+*("No files found for the backup." / "No readable files found for the
+backup.")*
+`MANAGE_BACKUP_SOURCES` matches no existing file. Paths are relative to
+`MANAGE_APP_ROOT`. Check with:
 
 ```bash
 php -r 'require "manage-client/lib/client.php"; print_r(manageBackupCollectSources());'
 ```
 
 **`Backup-ZIP konnte nicht erstellt werden.` / `Backup-ZIP konnte nicht finalisiert werden.`**
-Keine Schreibrechte auf `MANAGE_BACKUP_DIR` oder die Festplatte ist voll.
+*("Backup ZIP could not be created." / "Backup ZIP could not be
+finalized.")*
+No write access to `MANAGE_BACKUP_DIR`, or the disk is full.
 
 **`Datei ist zu groß für dieses Backup-Format: <Name>` / `Backup-ZIP ist zu groß für dieses Backup-Format.` / `Zu viele Dateien für dieses Backup-Format.`**
-Grenzen des ZIP-Formats ohne Zip64: 4 GB pro Datei, 4 GB pro Archiv, 65535 Einträge.
-Quellen aufteilen oder große Mediendateien getrennt sichern.
+*("File is too large for this backup format: <name>" / "Backup ZIP is too
+large for this backup format." / "Too many files for this backup
+format.")*
+Limits of the ZIP format without Zip64: 4 GB per file, 4 GB per archive,
+65535 entries. Split up the sources, or back up large media files
+separately.
 
 **`Ungültiger Pfad im Backup: <Name>` / `Pfad im Backup ist zu lang: <Name>`**
-Ein Dateiname enthält ungültige Zeichen oder das `as`-Präfix erzeugt einen ungültigen
-Archivpfad. Präfixe ohne führenden Schrägstrich und ohne `..` verwenden.
+*("Invalid path in the backup: <name>" / "Path in the backup is too long:
+<name>")*
+A filename contains invalid characters, or the `as` prefix produces an
+invalid archive path. Use prefixes without a leading slash and without
+`..`.
 
 **`Der Manage-Server hat das Backup abgelehnt: Ungültiger Backup-Dateiname.`**
-Der Dateiname entspricht nicht `backup-YYYYmmdd-HHMMSS[-N].zip`. Tritt nur bei
-selbst gebauten Uploads auf.
+*("The Manage server rejected the backup: invalid backup filename.")*
+The filename doesn't match `backup-YYYYmmdd-HHMMSS[-N].zip`. Only happens
+with self-built uploads.
 
 **`Der Manage-Server hat das Backup abgelehnt: Prüfsumme des Backups stimmt nicht überein.`**
-Das Archiv wurde unterwegs verändert oder unvollständig übertragen. Die Datei wird
-serverseitig gelöscht. Das lokale Archiv ist in Ordnung; erneut versuchen.
+*("The Manage server rejected the backup: backup checksum doesn't
+match.")*
+The archive was altered in transit, or transferred incompletely. The file
+is deleted server-side. The local archive is fine; try again.
 
 **`Backup überschreitet das Upload-Limit des Servers (upload_max_filesize / post_max_size).`**
-Die PHP-Grenzen auf dem **Manage-Server** sind kleiner als das Archiv. Dort
-`upload_max_filesize` und `post_max_size` erhöhen (beide!) oder den Backup-Umfang
-reduzieren. Die aktuellen Werte stehen im Manage-Server unter **Einstellungen → Diagnose**.
+*("Backup exceeds the server's upload limit (upload_max_filesize /
+post_max_size).")*
+The PHP limits on the **Manage server** are smaller than the archive.
+Increase `upload_max_filesize` and `post_max_size` there (both!), or reduce
+the backup's scope. The current values are shown in the Manage server
+under **Settings → Diagnostics**.
 
-Der Upload-Fehler macht das lokale Archiv nicht ungültig – es liegt vollständig in
-`data/manage/backups/`.
+The upload failure does not invalidate the local archive — it sits complete
+in `data/manage/backups/`.
 
-## Datenbank
+## Database
 
-**`Die PHP-PDO-Erweiterung ist nicht verfügbar.`**
-`pdo_mysql` fehlt. Ohne sie kann kein Dump erstellt werden;
-`MANAGE_BACKUP_DATABASE` auf `null` setzen oder die Erweiterung aktivieren lassen.
+**`Die PHP-PDO-Erweiterung ist nicht verfügbar.`** *("The PHP PDO extension
+is not available.")*
+`pdo_mysql` is missing. No dump can be created without it; set
+`MANAGE_BACKUP_DATABASE` to `null` or have the extension enabled.
 
-**`Datenbankverbindung fehlgeschlagen: <Meldung>`**
-DSN, Benutzer oder Passwort stimmen nicht, oder der Server ist nicht erreichbar. Die
-Originalmeldung von PDO steht dahinter.
+**`Datenbankverbindung fehlgeschlagen: <Meldung>`** *("Database connection
+failed: <message>")*
+DSN, user or password are wrong, or the server is unreachable. PDO's
+original message follows.
 
-**`MANAGE_BACKUP_DATABASE benötigt einen DSN.`**
-Das Array ist gesetzt, aber `dsn` fehlt oder ist leer.
+**`MANAGE_BACKUP_DATABASE benötigt einen DSN.`** *("MANAGE_BACKUP_DATABASE
+requires a DSN.")*
+The array is set, but `dsn` is missing or empty.
 
-**`Datenbank-Dump fehlgeschlagen: <Meldung>`**
-Meist fehlende Rechte: Der Benutzer braucht `SELECT` und `SHOW VIEW` auf allen
-Tabellen. Der unvollständige Dump wird gelöscht, das Backup bricht ab.
+**`Datenbank-Dump fehlgeschlagen: <Meldung>`** *("Database dump failed:
+<message>")*
+Usually missing permissions: the user needs `SELECT` and `SHOW VIEW` on all
+tables. The incomplete dump is deleted; the backup aborts.
 
-## Zusätzliche Ziele
+## Extra targets
 
-**`Die PHP-SSH2-Erweiterung ist nicht verfügbar.`**
-SFTP braucht `ext-ssh2`. Ohne sie schlägt nur dieses Ziel fehl; das lokale Archiv
-und alle anderen Ziele bleiben davon unberührt.
+**`Die PHP-SSH2-Erweiterung ist nicht verfügbar.`** *("The PHP SSH2
+extension is not available.")*
+SFTP needs `ext-ssh2`. Without it, only this target fails; the local
+archive and every other target are unaffected.
 
 **`SFTP-Zieldatei konnte nicht geöffnet werden. Existiert das Verzeichnis?`**
-Das entfernte Verzeichnis muss vorhanden und beschreibbar sein; es wird nicht angelegt.
+*("SFTP target file could not be opened. Does the directory exist?")*
+The remote directory must exist and be writable; it isn't created.
 
-**`S3-Ziel ist unvollständig konfiguriert.`**
-`bucket`, `region`, `access_key` und `secret_key` sind alle Pflicht.
+**`S3-Ziel ist unvollständig konfiguriert.`** *("S3 target is incompletely
+configured.")*
+`bucket`, `region`, `access_key` and `secret_key` are all required.
 
-**`S3-Upload fehlgeschlagen (HTTP 403)`**
-Bei `SignatureDoesNotMatch` stimmen Region oder Secret Key nicht. Bei `AccessDenied`
-passt meist die Adressierungsart nicht – für S3-kompatible Anbieter `endpoint` setzen.
+**`S3-Upload fehlgeschlagen (HTTP 403)`** *("S3 upload failed (HTTP
+403)")*
+With `SignatureDoesNotMatch`, region or secret key are wrong. With
+`AccessDenied`, the addressing style is usually wrong — set `endpoint` for
+S3-compatible providers.
 
-**`Unbekannter Backup-Zieltyp: <typ>`**
-`type` muss `s3`, `sftp` oder `custom` sein. Der frühere Typ `managed` entfällt: Der
-Upload zum Manage-Server ist eingebaut und wird über `MANAGE_BACKUP_UPLOAD` gesteuert.
+**`Unbekannter Backup-Zieltyp: <typ>`** *("Unknown backup target type:
+<type>")*
+`type` must be `s3`, `sftp` or `custom`. The earlier type `managed` is
+gone: the upload to the Manage server is built in and controlled via
+`MANAGE_BACKUP_UPLOAD`.
 
-## Rechte auf einen Blick
+## Permissions at a glance
 
 ```bash
-# Schreibrechte für PHP prüfen
+# Check PHP write access
 php -r '
 foreach (["data/manage/backups", "data/manage/work", "data/manage/updates", "."] as $d) {
-    printf("%-26s %s\n", $d, is_writable($d) ? "beschreibbar" : "NICHT beschreibbar");
+    printf("%-26s %s\n", $d, is_writable($d) ? "writable" : "NOT writable");
 }'
 ```
 
-Der letzte Eintrag `.` ist der Anwendungsstamm – ohne Schreibrecht dort sind keine
-Updates möglich.
+The last entry, `.`, is the application root — without write access there,
+no updates are possible.
 
-## Weiter
+## Next
 
-- [03_CONFIG_REFERENCE](03_CONFIG_REFERENCE.md) – alle Konstanten
-- [08_PROTOCOL](08_PROTOCOL.md) – Anfragen mit `curl` nachstellen
+- [03_CONFIG_REFERENCE](03_CONFIG_REFERENCE.md) – every constant
+- [08_PROTOCOL](08_PROTOCOL.md) – reproducing requests with `curl`

+ 99 - 94
client-package/docs/10_SECURITY.md

@@ -1,139 +1,144 @@
-# Sicherheit
+# Security
 
-## Überblick
+## Overview
 
-Was der Client tut, wem er vertraut und was in der Verantwortung des Projekts bleibt.
+What the client does, who it trusts, and what stays the project's
+responsibility.
 
-## Das Token
+## The token
 
-Das Instanz-Token ist ein 64-stelliger Hexadezimalwert aus 32 zufälligen Bytes. Es
-ist das einzige Geheimnis zwischen Instanz und Server.
+The instance token is a 64-character hexadecimal value from 32 random
+bytes. It's the only secret between instance and server.
 
-- Es wird **einmalig** beim Anlegen der Instanz angezeigt. Der Server speichert nur
-  den SHA-256-Hash und kann es nicht wieder ausgeben.
-- Es steht ausschließlich in `manage-client/config.php`.
-- Diese Datei gehört **nicht ins Repository** und **nicht ins Release-Paket**.
-- Bei Verdacht auf Kompromittierung im Manage-Server unter **Instanzen** →
-  "Token erneuern". Das alte Token ist damit sofort ungültig.
+- It's shown **once** when the instance is created. The server stores only
+  the SHA-256 hash and cannot display it again.
+- It lives exclusively in `manage-client/config.php`.
+- This file does **not** belong in the repository and does **not** belong
+  in the release package.
+- If compromise is suspected, in the Manage server go to **Instances** →
+  "Rotate token". The old token becomes invalid immediately.
 
 ```gitignore
 manage-client/config.php
 data/manage/
 ```
 
-Ein kompromittiertes Token erlaubt: Releases herunterzuladen und Backups
-hochzuladen – also Zugriff auf den Anwendungscode und das Belegen von
-Speicherplatz. Es erlaubt **nicht**, Backups herunterzuladen oder Releases zu
-verändern; beides geht nur über die Anmeldung am Manage-Server.
+A compromised token allows: downloading releases and uploading backups —
+so, access to the application code and the ability to use up storage. It
+does **not** allow downloading backups or changing releases; both require
+logging into the Manage server.
 
 ## Transport
 
-Alle Anfragen laufen über HTTPS. Der Client verwendet die PHP-Standardeinstellungen
-für die Zertifikatsprüfung; diese wird **nicht** abgeschaltet. Ein Server mit
-selbstsigniertem Zertifikat funktioniert deshalb nicht ohne passendes CA-Bundle im
-System – das ist Absicht.
+Every request runs over HTTPS. The client uses PHP's default certificate
+verification; it is **not** disabled. A server with a self-signed
+certificate therefore doesn't work without a matching CA bundle on the
+system — that's intentional.
 
-Umleitungen werden nicht verfolgt (`follow_location => 0`). Eine umgeleitete
-Anfrage schlägt fehl, statt Zugangsdaten an ein anderes Ziel zu senden.
+Redirects are not followed (`follow_location => 0`). A redirected request
+fails instead of sending credentials to a different destination.
 
-## Vertrauen ins Release-Paket
+## Trust in the release package
 
-Ein Update rollt fremden Code auf dem Server aus. Abgesichert ist das durch:
+An update deploys someone else's code on the server. That's secured by:
 
-1. **TLS** zum Manage-Server.
-2. **Token-Pflicht** für Manifest und Paket – beides ist nicht öffentlich abrufbar.
-3. **SHA-256-Prüfung** von Größe und Inhalt gegen das Manifest. Bei Abweichung wird
-   die Datei gelöscht und nichts ausgerollt.
-4. **Pfadprüfung** jedes ZIP-Eintrags gegen Ausbruch aus dem Zielverzeichnis.
-5. **Plausibilitätsprüfung** über `MANAGE_UPDATE_SANITY_PATHS`.
+1. **TLS** to the Manage server.
+2. **Token required** for manifest and package — neither is publicly
+   retrievable.
+3. **SHA-256 check** of size and content against the manifest. On mismatch,
+   the file is deleted and nothing is deployed.
+4. **Path check** on every ZIP entry against escaping the target directory.
+5. **Sanity check** via `MANAGE_UPDATE_SANITY_PATHS`.
 
-Die Grenze dieses Modells: Die Prüfsumme kommt vom selben Server wie das Paket. Wer
-den Manage-Server übernimmt, kann ein Paket **und** die passende Prüfsumme
-veröffentlichen. Eine Signatur mit einem im Client hinterlegten öffentlichen
-Schlüssel gibt es bewusst nicht – der Manage-Server muss entsprechend abgesichert
-werden.
+The limit of this model: the checksum comes from the same server as the
+package. Whoever takes over the Manage server can publish a package **and**
+the matching checksum. A signature backed by a public key held in the
+client deliberately doesn't exist — the Manage server has to be secured
+accordingly.
 
-## Backups enthalten Betriebsdaten
+## Backups contain operational data
 
-Ein Backup wird an den Manage-Server übertragen, dort gespeichert und kann von jedem
-heruntergeladen werden, der sich am Manage-Server anmeldet. Daraus folgt:
+A backup gets transferred to the Manage server, stored there, and can be
+downloaded by anyone who logs into the Manage server. It follows that:
 
-- **Keine Zugangsdaten ins Backup.** `config.php` mit Datenbankpasswörtern oder
-  API-Schlüsseln gehört nicht in `MANAGE_BACKUP_SOURCES`.
-- Enthält das Backup personenbezogene Daten – bei Bestell- oder Kundendaten die
-  Regel – gelten für den Manage-Server dieselben Anforderungen wie für die
-  Anwendung selbst: Zugriffsschutz, Verschlüsselung im Transport, Löschfristen.
-  Die Aufbewahrung auf dem Server ist einstellbar.
-- Das S3-Ziel legt Archive unverschlüsselt im Bucket ab. Der Bucket muss privat sein.
+- **No credentials in the backup.** `config.php` with database passwords or
+  API keys doesn't belong in `MANAGE_BACKUP_SOURCES`.
+- If the backup contains personal data — the rule for order or customer
+  data — the same requirements apply to the Manage server as to the
+  application itself: access control, transport encryption, deletion
+  deadlines. Retention on the server is configurable.
+- The S3 target stores archives unencrypted in the bucket. The bucket must
+  be private.
 
-## Lokale Verzeichnisse
+## Local directories
 
-Diese Verzeichnisse dürfen nicht über das Web erreichbar sein:
+These directories must not be reachable over the web:
 
-| Pfad | Inhalt |
+| Path | Content |
 |---|---|
-| `data/manage/backups/` | vollständige Betriebsdaten |
-| `data/manage/updates/` | Kopien der überschriebenen Anwendungsdateien |
-| `data/manage/work/` | entpackte Pakete während eines Updates |
-| `manage-client/config.php` | Instanz-Token |
+| `data/manage/backups/` | complete operational data |
+| `data/manage/updates/` | copies of the application files an update overwrote |
+| `data/manage/work/` | extracted packages during an update |
+| `manage-client/config.php` | instance token |
 
-Das mitgelieferte `manage-client/.htaccess` sperrt `config.php`, `lib/` und `bin/`.
-Für `data/` ist die `.htaccess` des Projekts zuständig. Auf nginx müssen die
-entsprechenden `location`-Regeln von Hand gesetzt werden – dort greift keine
-`.htaccess`.
+The bundled `manage-client/.htaccess` locks `config.php`, `lib/` and
+`bin/`. For `data/`, the project's own `.htaccess` is responsible. On
+nginx, the matching `location` rules have to be set by hand — `.htaccess`
+has no effect there.
 
-Prüfen lässt sich das direkt:
+This can be checked directly:
 
 ```bash
-curl -s -o /dev/null -w "%{http_code}\n" https://meinprojekt.example.org/manage-client/config.php
-curl -s -o /dev/null -w "%{http_code}\n" https://meinprojekt.example.org/data/manage/backups/
+curl -s -o /dev/null -w "%{http_code}\n" https://myproject.example.org/manage-client/config.php
+curl -s -o /dev/null -w "%{http_code}\n" https://myproject.example.org/data/manage/backups/
 ```
 
-Beides muss `403` oder `404` liefern, niemals `200`.
+Both must return `403` or `404`, never `200`.
 
-## Die Oberfläche
+## The UI
 
-`ui/panel.php` erlaubt es, Updates auszurollen und Backups herunterzuladen – es ist
-die mächtigste Seite der Anwendung. Deshalb:
+`ui/panel.php` allows deploying updates and downloading backups — it's the
+application's most powerful page. Because of that:
 
-- Das Projekt muss seine Anmeldung **vor** dem Einbinden prüfen.
-- Das Panel bringt eine zusätzliche Prüfung auf `$_SESSION["admin_logged_in"]` mit.
-- `MANAGE_PANEL_SKIP_AUTH_GUARD` deaktiviert nur diese zusätzliche Prüfung. Wer sie
-  setzt, ohne selbst zu prüfen, veröffentlicht Update- und Backup-Funktionen im Netz.
-- Alle Formulare sind CSRF-geschützt.
-- Der Download validiert den Dateinamen streng, damit kein beliebiger Pfad
-  ausgeliefert werden kann.
+- The project must check its login **before** including it.
+- The panel brings an extra check on `$_SESSION["admin_logged_in"]`.
+- `MANAGE_PANEL_SKIP_AUTH_GUARD` disables only this extra check. Setting it
+  without checking the login yourself publishes update and backup functions
+  on the network.
+- All forms are CSRF-protected.
+- The download strictly validates the filename, so no arbitrary path can be
+  served.
 
-Nach Möglichkeit sollte die Seite nur Administratoren zugänglich sein, nicht allen
-angemeldeten Benutzern.
+Where possible, the page should be accessible only to administrators, not
+to every logged-in user.
 
-## Kommandozeile
+## Command line
 
-`bin/manage-client.php` verweigert die Ausführung über HTTP (`PHP_SAPI`-Prüfung) und
-ist zusätzlich per `.htaccess` gesperrt. Auf dem Server sollte die Datei trotzdem
-nicht im öffentlichen Verzeichnisbaum liegen, wenn sich das vermeiden lässt.
+`bin/manage-client.php` refuses to run over HTTP (a `PHP_SAPI` check) and
+is additionally locked via `.htaccess`. On the server, the file should
+still avoid living in the public directory tree where that can be helped.
 
-## Protokolle
+## Logs
 
-Das Client-Protokoll enthält Dateinamen, Versionen, HTTP-Status und Fehlermeldungen.
-Zugangsdaten werden ausgefiltert: Aus Zielkonfigurationen übernimmt der Client nur
-eine Positivliste unkritischer Schlüssel; `access_key`, `secret_key`, `password` und
-das Instanz-Token erscheinen nie im Protokoll.
+The client log contains filenames, versions, HTTP status and error
+messages. Credentials are filtered out: from target configurations, the
+client only picks up an allowlist of non-sensitive keys; `access_key`,
+`secret_key`, `password` and the instance token never appear in the log.
 
-Antwortauszüge von fehlgeschlagenen Uploads werden auf 500 Zeichen gekürzt.
+Response excerpts from failed uploads are truncated to 500 characters.
 
-## Checkliste vor dem Produktivgang
+## Checklist before going live
 
-- [ ] `manage-client/config.php` ist in `.gitignore`
-- [ ] `config.php` und `manage-client/config.php` sind aus dem Release-Paket ausgeschlossen
-- [ ] `data/manage/` ist über das Web nicht erreichbar (geprüft mit `curl`)
-- [ ] `manage-client/config.php` ist über das Web nicht erreichbar (geprüft mit `curl`)
-- [ ] Die Panel-Seite verlangt eine Administrator-Anmeldung
-- [ ] `MANAGE_SERVER_URL` verwendet `https://`
-- [ ] Im Backup stecken keine Zugangsdaten
-- [ ] Ein Backup wurde einmal heruntergeladen und der Inhalt geprüft
+- [ ] `manage-client/config.php` is in `.gitignore`
+- [ ] `config.php` and `manage-client/config.php` are excluded from the release package
+- [ ] `data/manage/` is not reachable over the web (checked with `curl`)
+- [ ] `manage-client/config.php` is not reachable over the web (checked with `curl`)
+- [ ] The panel page requires an administrator login
+- [ ] `MANAGE_SERVER_URL` uses `https://`
+- [ ] No credentials are inside the backup
+- [ ] A backup has been downloaded once and its content checked
 
-## Weiter
+## Next
 
-- [08_PROTOCOL](08_PROTOCOL.md) – Authentifizierung im Detail
-- [05_BACKUP_SOURCES](05_BACKUP_SOURCES.md) – was ins Archiv gehört
+- [08_PROTOCOL](08_PROTOCOL.md) – authentication in detail
+- [05_BACKUP_SOURCES](05_BACKUP_SOURCES.md) – what belongs in the archive

+ 12 - 12
client-package/docs/index.php

@@ -4,8 +4,8 @@ declare(strict_types=1);
 
 /**
  * Documentation viewer (Markdown -> HTML via the vendored marked.js).
- * Adapted from the PSA order system (docs/index.php). Self-contained: no build
- * step and no external requests, so it also works from an unpacked ZIP.
+ * Self-contained: no build step and no external requests, so it also works
+ * from an unpacked ZIP.
  */
 
 $docsDir = __DIR__;
@@ -41,18 +41,18 @@ $docsTitle = basename(dirname($docsDir)) === "client-package"
 $requested = isset($_GET["doc"]) ? (string) $_GET["doc"] : "";
 $activeDoc = null;
 $markdown = null;
-$pageTitle = "Dokumentation";
+$pageTitle = "Documentation";
 
 if ($requested !== "") {
     if (!isset($docMap[$requested])) {
         http_response_code(404);
-        $pageTitle = "Nicht gefunden";
+        $pageTitle = "Not Found";
     } else {
         $activeDoc = $requested;
         $markdown = file_get_contents($docMap[$requested]);
         if ($markdown === false) {
             http_response_code(500);
-            $pageTitle = "Fehler";
+            $pageTitle = "Error";
             $markdown = null;
         } else {
             $pageTitle = docTitle($activeDoc);
@@ -68,7 +68,7 @@ if ($baseHref === "") {
 }
 ?>
 <!DOCTYPE html>
-<html lang="de">
+<html lang="en">
 <head>
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -82,8 +82,8 @@ if ($baseHref === "") {
     </div>
 </header>
 <div class="docs-layout">
-    <nav class="docs-nav" aria-label="Dokumentation">
-        <p class="docs-nav-title">Inhalt</p>
+    <nav class="docs-nav" aria-label="Documentation">
+        <p class="docs-nav-title">Contents</p>
         <ul>
             <?php foreach ($docMap as $key => $_path): ?>
                 <li>
@@ -97,8 +97,8 @@ if ($baseHref === "") {
     </nav>
     <main class="docs-main">
         <?php if ($requested === ""): ?>
-            <h1>Dokumentation</h1>
-            <p>Alle Dokumente in empfohlener Lesereihenfolge.</p>
+            <h1>Documentation</h1>
+            <p>All documents in the recommended reading order.</p>
             <ul class="docs-index-list">
                 <?php foreach ($docMap as $key => $_path): ?>
                     <li>
@@ -110,8 +110,8 @@ if ($baseHref === "") {
             </ul>
         <?php elseif ($markdown === null): ?>
             <h1><?php echo docEscape($pageTitle); ?></h1>
-            <p>Das angeforderte Dokument ist nicht verfügbar.</p>
-            <p><a href="<?php echo docEscape($baseHref); ?>index.php">Zur Übersicht</a></p>
+            <p>The requested document is not available.</p>
+            <p><a href="<?php echo docEscape($baseHref); ?>index.php">Back to the overview</a></p>
         <?php else: ?>
             <article id="doc-content" class="markdown-body"></article>
             <script type="application/json" id="doc-source"><?php

+ 15 - 16
client-package/examples/after-update.php

@@ -2,53 +2,52 @@
 
 declare(strict_types=1);
 
-// Vorlage für MANAGE_UPDATE_POST_HOOK.
+// Template for MANAGE_UPDATE_POST_HOOK.
 //
-// Wird nach jedem erfolgreichen Update ausgeführt – auch dann, wenn keine
-// Migration gelaufen ist. Läuft NACH den Migrationen und wird übersprungen,
-// wenn eine Migration fehlgeschlagen ist.
+// Runs after every successful update - even when no migration ran. Runs
+// AFTER the migrations and is skipped if a migration failed.
 //
-// In manage-client/config.php eintragen:
+// Register it in manage-client/config.php:
 //
 //     define("MANAGE_UPDATE_POST_HOOK", [
 //         "file"     => MANAGE_APP_ROOT . "/includes/after-update.php",
 //         "callback" => "myProjectAfterUpdate",
 //     ]);
 //
-// Diese Datei muss Teil des Release-Pakets sein, sonst fehlt sie nach dem
-// ersten Update.
+// This file must be part of the release package, otherwise it's missing
+// after the first update.
 
 /**
  * @param array $context app_root, instance, from_version, to_version,
- *                       backup_dir, run_id, migrations, ggf. pdo
+ *                       backup_dir, run_id, migrations, pdo if configured
  */
 function myProjectAfterUpdate(array $context): void
 {
-    // 1. Kompilierte Templates und Caches verwerfen.
+    // 1. Discard compiled templates and caches.
     foreach (glob($context["app_root"] . "/data/cache/*.php") ?: [] as $file) {
         @unlink($file);
     }
 
-    // 2. Verzeichnisse anlegen, die ein neues Release voraussetzt.
+    // 2. Create directories a new release requires.
     $newDir = $context["app_root"] . "/data/exports";
     if (!is_dir($newDir) && !mkdir($newDir, 02775, true) && !is_dir($newDir)) {
-        throw new RuntimeException("Verzeichnis konnte nicht angelegt werden: " . $newDir);
+        throw new RuntimeException("Could not create directory: " . $newDir);
     }
 
-    // 3. Für die Nachvollziehbarkeit protokollieren.
+    // 3. Log for traceability.
     @file_put_contents(
         $context["app_root"] . "/data/update-history.log",
         sprintf(
-            "%s  %s -> %s  (Migrationen: %s)%s",
+            "%s  %s -> %s  (migrations: %s)%s",
             date(DATE_ATOM),
-            $context["from_version"] !== "" ? $context["from_version"] : "unbekannt",
+            $context["from_version"] !== "" ? $context["from_version"] : "unknown",
             $context["to_version"],
-            $context["migrations"] === [] ? "keine" : implode(", ", $context["migrations"]),
+            $context["migrations"] === [] ? "none" : implode(", ", $context["migrations"]),
             PHP_EOL,
         ),
         FILE_APPEND | LOCK_EX,
     );
 
-    // Fehler melden: Exception werfen, false zurückgeben oder
+    // To report failure: throw an exception, return false, or
     // return ["success" => false, "error" => "…"].
 }

+ 19 - 19
client-package/examples/cron/manage-client.cron

@@ -1,28 +1,28 @@
-# Manage-Client – Cron-Beispiele
+# Manage Client - cron examples
 #
-# Pfade anpassen und mit `crontab -e` eintragen.
-# PHP-Pfad prüfen mit: which php
+# Adjust the paths and add with `crontab -e`.
+# Check the PHP path with: which php
 #
-# --quiet unterdrückt die normale Ausgabe. Fehler gehen weiterhin auf STDERR
-# und werden von Cron per Mail zugestellt.
+# --quiet suppresses normal output. Errors still go to STDERR and are
+# delivered by cron via mail.
 
 MAILTO=admin@example.org
 
-# Nächtliches Backup um 03:20 Uhr.
-20 3 * * * /usr/bin/php /var/www/meinprojekt/manage-client/bin/manage-client.php backup --trigger=cron --quiet
+# Nightly backup at 03:20.
+20 3 * * * /usr/bin/php /var/www/myproject/manage-client/bin/manage-client.php backup --trigger=cron --quiet
 
-# Statusmeldung an den Manage-Server, stündlich zur Minute 7.
-7 * * * * /usr/bin/php /var/www/meinprojekt/manage-client/bin/manage-client.php heartbeat --quiet
+# Status report to the Manage server, hourly at minute 7.
+7 * * * * /usr/bin/php /var/www/myproject/manage-client/bin/manage-client.php heartbeat --quiet
 
-# Update-Prüfung werktags um 08:00 Uhr.
-# Exit-Code 2 bedeutet "Update verfügbar" – Cron meldet das nicht von sich aus,
-# deshalb hier eine ausdrückliche Mail.
-0 8 * * 1-5 /usr/bin/php /var/www/meinprojekt/manage-client/bin/manage-client.php check --quiet || true
+# Update check on weekdays at 08:00.
+# Exit code 2 means "update available" - cron doesn't report that on its
+# own, hence the explicit mail here.
+0 8 * * 1-5 /usr/bin/php /var/www/myproject/manage-client/bin/manage-client.php check --quiet || true
 
-# Updates werden bewusst NICHT automatisch eingespielt.
-# Ein Update überschreibt Dateien im laufenden Betrieb und kann Migrationen
-# auslösen; das gehört unter Aufsicht. Falls es dennoch automatisiert werden
-# soll, vorher ein Backup erzwingen:
+# Updates are deliberately NOT installed automatically.
+# An update overwrites files while the application is live and can trigger
+# migrations; that belongs under supervision. If it should be automated
+# anyway, force a backup first:
 #
-# 30 2 * * 0 /usr/bin/php /var/www/meinprojekt/manage-client/bin/manage-client.php backup --trigger=update --quiet && \
-#            /usr/bin/php /var/www/meinprojekt/manage-client/bin/manage-client.php update --yes --quiet
+# 30 2 * * 0 /usr/bin/php /var/www/myproject/manage-client/bin/manage-client.php backup --trigger=update --quiet && \
+#            /usr/bin/php /var/www/myproject/manage-client/bin/manage-client.php update --yes --quiet

+ 8 - 8
client-package/examples/flat-file-project/migrations/2026-08-20-01-add-category-id.php

@@ -2,11 +2,11 @@
 
 declare(strict_types=1);
 
-// Beispielmigration für ein Projekt mit JSON-Dateien:
-// ergänzt ein neues Feld in allen bestehenden Datensätzen.
+// Example migration for a project with JSON files: adds a new field to
+// every existing record.
 //
-// Idempotent: bereits ergänzte Datensätze bleiben unverändert, damit die
-// Migration nach einem Teilfehler gefahrlos erneut laufen kann.
+// Idempotent: already-updated records stay unchanged, so the migration can
+// safely run again after a partial failure.
 
 return function (array $context): void {
     $file = $context["app_root"] . "/data/products.json";
@@ -16,7 +16,7 @@ return function (array $context): void {
 
     $data = json_decode((string) file_get_contents($file), true);
     if (!is_array($data)) {
-        throw new RuntimeException("products.json ist nicht lesbar oder kein gültiges JSON.");
+        throw new RuntimeException("products.json is not readable or not valid JSON.");
     }
 
     $changed = false;
@@ -36,13 +36,13 @@ return function (array $context): void {
         JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
     );
     if ($json === false) {
-        throw new RuntimeException("products.json konnte nicht kodiert werden.");
+        throw new RuntimeException("products.json could not be encoded.");
     }
 
-    // Atomar schreiben, damit ein Abbruch keine halbe Datei hinterlässt.
+    // Write atomically, so an interruption never leaves a half-written file.
     $tmpFile = $file . ".tmp";
     if (file_put_contents($tmpFile, $json, LOCK_EX) === false || !rename($tmpFile, $file)) {
         @unlink($tmpFile);
-        throw new RuntimeException("products.json konnte nicht geschrieben werden.");
+        throw new RuntimeException("products.json could not be written.");
     }
 };

+ 13 - 13
client-package/examples/integration-snippet.php

@@ -1,17 +1,17 @@
 <?php
 
-// Die Zeilen, die ein bestehendes Projekt braucht.
-// Zum Kopieren gedacht, nicht zum direkten Ausführen.
+// The lines an existing project needs.
+// Meant to be copied, not run directly.
 
 // ---------------------------------------------------------------------------
-// 1. Adminseite für Update und Backup: <projekt>/admin/manage.php
+// 1. Admin page for update and backup: <project>/admin/manage.php
 // ---------------------------------------------------------------------------
 
 require_once __DIR__ . "/../config.php";
 require_once __DIR__ . "/../includes/functions.php";
 
-// Anmeldung des Projekts zuerst prüfen – das Panel ist die mächtigste Seite
-// der Anwendung.
+// Check the project's own login first - the panel is the application's
+// most powerful page.
 if (empty($_SESSION["admin_logged_in"])) {
     header("Location: login.php");
     exit;
@@ -20,35 +20,35 @@ if (empty($_SESSION["admin_logged_in"])) {
 require __DIR__ . "/../manage-client/ui/panel.php";
 
 // ---------------------------------------------------------------------------
-// 2. Statusblock auf einer bestehenden Einstellungsseite
+// 2. Status block on an existing settings page
 // ---------------------------------------------------------------------------
 
 $manageStatusPanelUrl = "manage.php";
 include __DIR__ . "/../manage-client/ui/status-partial.php";
 
 // ---------------------------------------------------------------------------
-// 3. Automatisches Backup ohne Cron, z. B. auf dem Admin-Dashboard
+// 3. Automatic backup without cron, e.g. on the admin dashboard
 // ---------------------------------------------------------------------------
 
 require_once __DIR__ . "/../manage-client/lib/client.php";
 
 try {
-    // Legt nur an, wenn MANAGE_BACKUP_AUTO_INTERVAL_SECONDS abgelaufen ist.
+    // Only creates one once MANAGE_BACKUP_AUTO_INTERVAL_SECONDS has elapsed.
     manageBackupCreateAutomaticIfDue();
 } catch (Throwable $exception) {
-    // Ein fehlgeschlagenes Backup darf das Dashboard nicht blockieren.
-    error_log("Automatisches Backup fehlgeschlagen: " . $exception->getMessage());
+    // A failed backup must not block the dashboard.
+    error_log("Automatic backup failed: " . $exception->getMessage());
 }
 
 // ---------------------------------------------------------------------------
-// 4. Update-Hinweis in der eigenen Navigation
+// 4. Update notice in the project's own navigation
 // ---------------------------------------------------------------------------
 
 require_once __DIR__ . "/../manage-client/lib/client.php";
 
-$manageStatus = manageClientStatus(); // wirft nie
+$manageStatus = manageClientStatus(); // never throws
 if ($manageStatus["update"] !== null && $manageStatus["update"]["available"]) {
     echo '<a href="manage.php">Update ' .
         htmlspecialchars($manageStatus["update"]["latest"], ENT_QUOTES, "UTF-8") .
-        " verfügbar</a>";
+        " available</a>";
 }

+ 5 - 5
client-package/examples/mysql-project/migrations/2026-08-20-01-add-orders-index.php

@@ -2,16 +2,16 @@
 
 declare(strict_types=1);
 
-// Beispielmigration mit Datenbank.
+// Example migration with a database.
 //
-// $context["pdo"] ist die Verbindung aus MANAGE_BACKUP_DATABASE.
-// Idempotent: prüft erst, ob der Index bereits existiert. MySQL kennt kein
-// "ADD INDEX IF NOT EXISTS", deshalb die Abfrage über information_schema.
+// $context["pdo"] is the connection from MANAGE_BACKUP_DATABASE.
+// Idempotent: checks first whether the index already exists. MySQL has no
+// "ADD INDEX IF NOT EXISTS", hence the query against information_schema.
 
 return function (array $context): void {
     if (!isset($context["pdo"])) {
         throw new RuntimeException(
-            "Diese Migration benötigt eine Datenbank. MANAGE_BACKUP_DATABASE ist nicht konfiguriert.",
+            "This migration needs a database. MANAGE_BACKUP_DATABASE is not configured.",
         );
     }
 

+ 1 - 1
client-package/manage-client/config.sample.php

@@ -17,7 +17,7 @@ define("MANAGE_SERVER_URL", "https://manage.example.org");
 
 // Instance id and token, both shown when the instance is created on the server
 // (Instanzen -> Instanz anlegen). The token is displayed exactly once.
-define("MANAGE_INSTANCE", "psa-prod");
+define("MANAGE_INSTANCE", "example-prod");
 define("MANAGE_TOKEN", "");
 
 // Seconds per HTTP request. Package downloads and backup uploads use the long

+ 2 - 4
client-package/manage-client/lib/backup.php

@@ -5,10 +5,8 @@ declare(strict_types=1);
 // Client-side backup: collecting sources, writing the archive, local retention
 // and uploading to the manage server.
 //
-// Ported from the PSA order system (includes/backup.php). The one structural
-// change is that the source list is no longer hardcoded to data/*.json plus
-// data/uploads/**: it comes from MANAGE_BACKUP_SOURCES, plus an optional SQL
-// dump when MANAGE_BACKUP_DATABASE is configured.
+// The source list is not hardcoded: it comes from MANAGE_BACKUP_SOURCES, plus
+// an optional SQL dump when MANAGE_BACKUP_DATABASE is configured.
 
 function manageBackupDir(): string
 {

+ 1 - 1
client-package/manage-client/lib/mysql.php

@@ -9,7 +9,7 @@ declare(strict_types=1);
 // bounded by disk rather than memory_limit.
 //
 // Active only when MANAGE_BACKUP_DATABASE is configured. Projects without a
-// database (like the PSA order system) leave it at null and never touch this.
+// database leave it at null and never touch this.
 
 function manageDatabaseConfigured(): bool
 {

+ 3 - 4
client-package/manage-client/lib/remote.php

@@ -2,11 +2,10 @@
 
 declare(strict_types=1);
 
-// Extra backup destinations besides the manage server. Ported from the PSA
-// order system (includes/backup.php): s3, sftp and custom.
+// Extra backup destinations besides the manage server: s3, sftp and custom.
 //
-// The old "managed" target type is gone: uploading to the manage server is now
-// built in (manageBackupUpload) and configured through MANAGE_SERVER_URL /
+// There is no "managed" target type: uploading to the manage server is built
+// in (manageBackupUpload) and configured through MANAGE_SERVER_URL /
 // MANAGE_INSTANCE / MANAGE_TOKEN instead of a target entry.
 
 function manageRemoteTargets(): array

+ 4 - 5
client-package/manage-client/lib/updater.php

@@ -4,11 +4,10 @@ declare(strict_types=1);
 
 // Update pipeline: check, download, verify, extract, deploy, post-update hook.
 //
-// Ported from the PSA order system (admin/updater.php) with the HTML stripped
-// out and three hardcoded assumptions made configurable:
-//   - the version file (was includes/version.php with APP_VERSION)
-//   - the protected paths (was config.php, data/, .git/)
-//   - the package sanity marker (was index.php / admin/ / includes/)
+// Everything host-specific is configurable:
+//   - the version file (for example includes/version.php with APP_VERSION)
+//   - the protected paths (for example config.php, data/, .git/)
+//   - the package sanity marker (for example index.php / admin/ / includes/)
 //
 // Deployment is an overlay copy: every file in the package is written over the
 // application root, with each overwritten file copied aside first. Files that

+ 4 - 6
client-package/manage-client/lib/zip.php

@@ -2,13 +2,11 @@
 
 declare(strict_types=1);
 
-// Pure-PHP ZIP writer. Ported from the PSA order system (includes/backup.php),
-// which builds the archive with pack() rather than requiring ext-zip, exec or
-// a temp copy of the whole archive in memory.
+// Pure-PHP ZIP writer. Builds the archive with pack() rather than requiring
+// ext-zip, exec or a temp copy of the whole archive in memory.
 //
-// One addition against the original: optional deflate compression (method 8).
-// The original stored everything uncompressed, which is fine for JPEGs but
-// wasteful for the SQL dumps this client can now produce.
+// Deflate compression (method 8) is optional: storing everything uncompressed
+// is fine for JPEGs but wasteful for the SQL dumps this client can produce.
 //
 // Limits (no Zip64): 4 GB per entry, 4 GB per archive, 65535 entries.
 

+ 2 - 2
client-package/manage-client/ui/panel.php

@@ -5,8 +5,8 @@ declare(strict_types=1);
 // Drop-in admin page for the host application.
 //
 // The host is expected to have established its own session and authentication
-// BEFORE including this file. The default guard below matches the PSA order
-// system; adjust it to whatever the host project uses (see 02_INTEGRATION.md).
+// BEFORE including this file. Adjust the default guard below to whatever the
+// host project uses (see 02_INTEGRATION.md).
 //
 // Typical integration, as myproject/admin/manage.php:
 //

+ 5 - 6
scripts/create-release-zip.sh → client-package/scripts/create-release-zip.sh

@@ -2,12 +2,11 @@
 #
 # Builds a release package for a managed project.
 #
-# Generalized from the PSA order system (scripts/create-update-zip.sh): the
-# product name, the version file and the exclude list are variables at the top
-# instead of hardcoded paths.
+# The product name, the version file and the exclude list are variables at the
+# top instead of hardcoded paths.
 #
-# Copy this script into the project it builds, adjust the CONFIGURATION block,
-# and run it from the project root:
+# It ships with the manage client package. Copy it into `scripts/` of the project
+# it builds, adjust the CONFIGURATION block, and run it from the project root:
 #
 #     ./scripts/create-release-zip.sh v1.3.0
 #
@@ -20,7 +19,7 @@ set -euo pipefail
 # --- CONFIGURATION ----------------------------------------------------------
 
 # Package name prefix. Must match MANAGE_PACKAGE_PREFIX on the manage server.
-PRODUCT="psa-orderform"
+PRODUCT="example-orderform"
 
 # File holding the installed version, relative to the project root.
 VERSION_FILE="includes/version.php"

+ 2 - 2
config.sample.php

@@ -12,10 +12,10 @@
 // Product
 // ---------------------------------------------------------------------------
 // One manage deployment serves exactly one product. Deploy it again for another.
-define("MANAGE_PRODUCT_NAME", "PSA Orderform");
+define("MANAGE_PRODUCT_NAME", "Example Orderform");
 
 // Prefix for stored release packages: <prefix>-vX.Y.Z.zip
-define("MANAGE_PACKAGE_PREFIX", "psa-orderform");
+define("MANAGE_PACKAGE_PREFIX", "example-orderform");
 
 // ---------------------------------------------------------------------------
 // Public URL

+ 81 - 97
docs/ARCHITECTURE.md

@@ -1,65 +1,66 @@
-# Architektur
+# Architecture
 
-## Überblick
+## Overview
 
-`manage` besteht aus zwei Hälften: dem Server in diesem Repository und dem
-Client-Paket, das in jedes betreute Projekt kopiert wird.
+`manage` consists of two halves: the server in this repository, and the
+client package that gets copied into each served project.
 
-Eine Server-Installation betreut **ein Produkt** mit einer überschaubaren Zahl von
-Instanzen. Für ein weiteres Produkt wird `manage` erneut ausgerollt.
+One server installation serves **one product** with a manageable number of
+instances. For another product, `manage` is deployed again.
 
-Relevante Verzeichnisse:
+Relevant directories:
 
-- `admin/` – Oberfläche
-- `api/v1/` – Schnittstelle für Clients
-- `includes/` – gemeinsame Bibliothek
-- `storage/` – sämtlicher Zustand, nicht über das Web erreichbar
-- `client-package/` – der weitergebbare Ordner für Projekte
+- `admin/` – UI
+- `api/v1/` – client interface
+- `includes/` – shared library
+- `storage/` – all state, not reachable over the web
+- `client-package/` – the redistributable folder for projects
 
-## Datenfluss
+## Data flow
 
 ```text
-Projekt (Instanz)                        Manage-Server
-─────────────────                        ─────────────
+Project (instance)                       Manage server
+───────────────────                      ─────────────
 manage-client/
   bin/manage-client.php  ──── check ───►  api/v1/manifest.php  ──► storage/releases/manifest.json
   ui/panel.php           ──── update ──►  api/v1/package.php   ──► storage/releases/packages/
-  lib/*.php              ──── backup ──►  api/v1/backup.php    ──► storage/backups/<instanz>/
+  lib/*.php              ──── backup ──►  api/v1/backup.php    ──► storage/backups/<instance>/
                          ──── status ──►  api/v1/heartbeat.php ──► storage/instances.json
                                                                         │
                                                                         ▼
-                                                            admin/  (Anmeldung mit Passwort)
+                                                            admin/  (password login)
 ```
 
-Der Client zieht; der Server schiebt nie. Es gibt keine Verbindung vom Server zur
-Instanz, was den Betrieb hinter NAT und Firewalls unkompliziert macht.
+The client pulls; the server never pushes. There is no connection from the
+server to the instance, which makes running it behind NAT and firewalls
+straightforward.
 
-## Authentifizierung
+## Authentication
 
-Zwei getrennte Wege:
+Two separate paths:
 
-| Weg | Wer | Mittel |
+| Path | Who | Means |
 |---|---|---|
-| `admin/` | Menschen | ein Passwort, Sitzung, CSRF, Ratenbegrenzung |
-| `api/v1/` | Instanzen | Instanz-Kennung + Token in zwei Headern, zustandslos |
+| `admin/` | humans | a password, session, CSRF, rate limiting |
+| `api/v1/` | instances | instance id + token in two headers, stateless |
 
-Tokens werden als SHA-256-Hash gespeichert und in konstanter Zeit verglichen. Das
-Klartext-Token erscheint genau einmal, beim Anlegen oder Erneuern.
+Tokens are stored as a SHA-256 hash and compared in constant time. The
+plaintext token appears exactly once, when created or rotated.
 
 Details: [client-package/docs/08_PROTOCOL.md](../client-package/docs/08_PROTOCOL.md).
 
-## Instanzregister
+## Instance registry
 
-`storage/instances.json` ist das Bindeglied zwischen beiden Modulen. Es ersetzt
-zwei getrennte Mechanismen der Vorgängerlösung: die Namensliste des Backup-Servers
-und die vollständig fehlende Client-Identität des Update-Servers.
+`storage/instances.json` is the link between both modules. It replaces two
+separate mechanisms from the predecessor solution: the backup server's name
+list, and the update server's completely missing client identity.
 
 ```json
 {
     "instances": [
         {
-            "id": "psa-prod",
-            "label": "Stadt Freising Produktiv",
+            "id": "example-prod",
+            "label": "City of Freising Production",
             "enabled": true,
             "token_hash": "…",
             "created_at": "…",
@@ -78,16 +79,17 @@ und die vollständig fehlende Client-Identität des Update-Servers.
 }
 ```
 
-Jede erfolgreich authentifizierte Anfrage aktualisiert `last_seen_at` und `last_ip`.
-Die Übersicht bleibt dadurch aktuell, auch ohne eigenen Heartbeat.
+Every successfully authenticated request updates `last_seen_at` and
+`last_ip`. The overview stays current this way, even without its own
+heartbeat.
 
-Eine gelöschte Instanz kann nichts mehr hochladen; ihre bereits gespeicherten
-Backups bleiben aber erhalten und in der Oberfläche sichtbar.
+A deleted instance can no longer upload anything; its already-stored backups
+remain and stay visible in the UI.
 
 ## Releases
 
-`storage/releases/manifest.json` ist die Release-Datenbank, die Pakete liegen
-daneben in `packages/`:
+`storage/releases/manifest.json` is the release database; the packages sit
+next to it in `packages/`:
 
 ```json
 {
@@ -95,7 +97,7 @@ daneben in `packages/`:
     "releases": {
         "v1.3.0": {
             "version": "v1.3.0",
-            "package": "packages/psa-orderform-v1.3.0.zip",
+            "package": "packages/example-orderform-v1.3.0.zip",
             "sha256": "…",
             "size": 2199,
             "published_at": "…"
@@ -104,83 +106,65 @@ daneben in `packages/`:
 }
 ```
 
-Prüfsumme und Größe berechnet immer der Server nach dem Upload; sie werden nie vom
-Hochladenden übernommen. Ein Upload setzt das Release automatisch als `latest`.
+Checksum and size are always computed by the server after upload; they are
+never taken from the uploader. An upload automatically sets the release as
+`latest`.
 
-Die Download-URL wird aus `MANAGE_PUBLIC_URL` gebildet, **nicht** aus dem
-`Host`-Header. Die Vorgängerlösung leitete sie aus `HTTP_HOST` ab, also aus einem
-vom Client kontrollierten Wert.
+The download URL is built from `MANAGE_PUBLIC_URL`, **not** from the `Host`
+header. The predecessor solution derived it from `HTTP_HOST` — a value
+controlled by the client.
 
 ## Backups
 
 ```text
 storage/backups/
-  index.json                     Metadaten aller Backups
-  <instanz>/backup-YYYYmmdd-HHMMSS[-N].zip
+  index.json                     metadata of all backups
+  <instance>/backup-YYYYmmdd-HHMMSS[-N].zip
 ```
 
-Der Server prüft nach dem Speichern die Prüfsumme erneut und löscht die Datei bei
-Abweichung. Ein vorhandener Dateiname wird nie überschrieben.
+After storing a file the server re-checks the checksum and deletes it on
+mismatch. An existing filename is never overwritten.
 
-### Zwei Aufbewahrungsstufen
+### Two retention tiers
 
-Bei aktivem S3-Archiv arbeitet die lokale Platte als schneller Zwischenspeicher und
-der Bucket als vollständiges Archiv:
+With the S3 archive active, the local disk works as a fast staging area and
+the bucket as the complete archive:
 
-- **S3**: behält die neuesten `s3_retention` Sicherungen je Instanz (Standard 365).
-- **Lokal**: behält die neuesten `retention` Sicherungen (Standard 30), löscht eine
-  Datei aber **nie**, solange ihr S3-Upload noch aussteht.
+- **S3**: keeps the most recent `s3_retention` backups per instance (default 365).
+- **Local**: keeps the most recent `retention` backups (default 30), but
+  **never** deletes a file while its S3 upload is still pending.
 
-Ist S3 nicht erreichbar, wachsen die lokalen Kopien also über die Aufbewahrung
-hinaus, statt die einzige Kopie zu verlieren. Fehlgeschlagene S3-Uploads werden beim
-nächsten Upload derselben Instanz oder über die Schaltfläche in der Oberfläche
-nachgeholt.
+If S3 is unreachable, the local copies grow past their retention instead of
+losing the only copy. Failed S3 uploads are retried on the next upload from
+the same instance, or via the button in the UI.
 
-S3-Fehler lassen einen Client-Upload nie fehlschlagen: Die lokale Kopie liegt bereits
-vor. Protokolliert werden sie in `storage/logs/s3.log`.
+S3 errors never fail a client upload: the local copy already exists. They
+are logged to `storage/logs/s3.log`.
 
-## Speicherung
+## Storage
 
-Ausschließlich flache Dateien, kein Datenbankserver. Alle Schreibvorgänge laufen
-über `manageWriteJsonFile()`: erst in eine `.tmp`-Datei, dann `rename()`. Damit
-kann ein abgebrochener Request keinen halb geschriebenen Index hinterlassen.
+Flat files only, no database server. Every write goes through
+`manageWriteJsonFile()`: first into a `.tmp` file, then `rename()`. That way
+an aborted request can never leave a half-written index behind.
 
-Bekannte Grenze: Gleichzeitige Uploads derselben Instanz können sich beim
-Schreiben von `index.json` überschneiden. Bei einer Handvoll Instanzen mit
-nächtlichen Backups ist das praktisch ausgeschlossen; bei vielen gleichzeitigen
-Uploads wäre eine Sperre nötig.
+Known limit: concurrent uploads from the same instance can collide while
+writing `index.json`. With a handful of instances doing nightly backups this
+is practically excluded; with many concurrent uploads a lock would be
+needed.
 
-## Protokolle
+## Logs
 
-| Datei | Inhalt |
+| File | Content |
 |---|---|
-| `storage/logs/access.log` | JSONL: Anmeldungen, Releases, empfangene Backups, Downloads |
-| `storage/logs/error.log` | JSONL: fehlgeschlagene Anmeldungen, abgelehnte Uploads, interne Fehler |
-| `storage/logs/s3.log` | Klartext: S3-Diagnose mit Status, Umleitungen und Request-ID |
+| `storage/logs/access.log` | JSONL: logins, releases, received backups, downloads |
+| `storage/logs/error.log` | JSONL: failed logins, rejected uploads, internal errors |
+| `storage/logs/s3.log` | plain text: S3 diagnostics with status, redirects and request id |
 
-Die JSONL-Protokolle rotieren nach `MANAGE_LOG_MAX_BYTES` und werden nach
-`MANAGE_LOG_MAX_AGE_SECONDS` entfernt.
+The JSONL logs rotate past `MANAGE_LOG_MAX_BYTES` and are removed after
+`MANAGE_LOG_MAX_AGE_SECONDS`.
 
-## Herkunft
+## Next
 
-Der Code stammt aus dem PSA-Bestellsystem und wurde beim Herauslösen
-verallgemeinert:
-
-| Hier | Ursprung |
-|---|---|
-| `includes/s3.php` | `backup-server/s3.php` |
-| `includes/backups.php` | `backup-server/lib.php` |
-| `includes/releases.php` | `update-server/manage.php`, `update-server/manifest.php` |
-| `includes/auth.php` | die Anmeldeteile beider Vorgänger-Oberflächen |
-| `includes/storage.php`, `log.php`, `ratelimit.php` | `includes/functions.php` |
-| `client-package/manage-client/lib/updater.php` | `admin/updater.php` |
-| `client-package/manage-client/lib/backup.php`, `zip.php`, `remote.php` | `includes/backup.php` |
-
-Das PSA-Bestellsystem wurde dabei nicht verändert und läuft unverändert gegen seine
-bisherigen Server weiter. Siehe [MIGRATION_PSA](MIGRATION_PSA.md).
-
-## Weiter
-
-- [SERVER_SETUP](SERVER_SETUP.md) – Installation
-- [INSTANCE_MANAGEMENT](INSTANCE_MANAGEMENT.md) – Instanzen und Tokens
-- [RELEASING](RELEASING.md) – Releases veröffentlichen
+- [SERVER_SETUP](SERVER_SETUP.md) – installation
+- [INSTANCE_MANAGEMENT](INSTANCE_MANAGEMENT.md) – instances and tokens
+- [RELEASING](RELEASING.md) – publishing releases

+ 78 - 76
docs/CONFIG_REFERENCE.md

@@ -1,130 +1,132 @@
-# Konfigurationsreferenz (Server)
+# Configuration Reference (Server)
 
-## Überblick
+## Overview
 
-Alle Konstanten stehen in `config.php`, kopiert aus `config.sample.php`. Jede hat
-einen Standardwert in `includes/bootstrap.php`; eine minimale `config.php` braucht
-nur `MANAGE_PUBLIC_URL` und `MANAGE_ADMIN_PASSWORD_HASH`.
+All constants live in `config.php`, copied from `config.sample.php`. Each has
+a default in `includes/bootstrap.php`; a minimal `config.php` only needs
+`MANAGE_PUBLIC_URL` and `MANAGE_ADMIN_PASSWORD_HASH`.
 
-Die Konstanten des **Clients** stehen in
+The **client's** constants are in
 [../client-package/docs/03_CONFIG_REFERENCE.md](../client-package/docs/03_CONFIG_REFERENCE.md).
 
-## Produkt
+## Product
 
-| Konstante | Standard | Bedeutung |
+| Constant | Default | Meaning |
 |---|---|---|
-| `MANAGE_PRODUCT_NAME` | `"Managed Application"` | Anzeigename in der Oberfläche |
-| `MANAGE_PACKAGE_PREFIX` | `"release"` | Dateinamenspräfix gespeicherter Pakete: `<prefix>-vX.Y.Z.zip` |
+| `MANAGE_PRODUCT_NAME` | `"Managed Application"` | display name in the UI |
+| `MANAGE_PACKAGE_PREFIX` | `"release"` | filename prefix of stored packages: `<prefix>-vX.Y.Z.zip` |
 
-## Öffentliche URL
+## Public URL
 
-| Konstante | Standard | Bedeutung |
+| Constant | Default | Meaning |
 |---|---|---|
-| `MANAGE_PUBLIC_URL` | `""` | Absolute Basis-URL dieser Installation, ohne Schrägstrich am Ende |
+| `MANAGE_PUBLIC_URL` | `""` | absolute base URL of this installation, without a trailing slash |
 
-Aus diesem Wert wird die Download-URL gebildet, die Clients im Manifest erhalten.
-Er wird bewusst nicht aus dem `Host`-Header abgeleitet: Ein gefälschter Header
-könnte einen Client sonst auf einen fremden Server umlenken.
+This value builds the download URL clients receive in the manifest. It is
+deliberately not derived from the `Host` header: a spoofed header could
+otherwise redirect a client to a foreign server.
 
-Ist der Wert leer, meldet die Übersicht eine Warnung und `manifest.php` antwortet
-mit einem Fehler.
+If the value is empty, the overview reports a warning and `manifest.php`
+responds with an error.
 
-## Anmeldung
+## Login
 
-| Konstante | Standard | Bedeutung |
+| Constant | Default | Meaning |
 |---|---|---|
-| `MANAGE_ADMIN_PASSWORD_HASH` | – | bcrypt-Hash, mit `password_verify()` geprüft |
-| `MANAGE_ADMIN_PASSWORD` | – | Klartext-Alternative, mit `hash_equals()` geprüft |
+| `MANAGE_ADMIN_PASSWORD_HASH` | – | bcrypt hash, checked with `password_verify()` |
+| `MANAGE_ADMIN_PASSWORD` | – | plaintext alternative, checked with `hash_equals()` |
 
-Der Hash hat Vorrang. Er wird ignoriert, solange er noch den Platzhalter aus
-`config.sample.php` enthält – so scheitert eine unfertige Konfiguration sichtbar,
-statt eine offene Anmeldung zu erlauben.
+The hash takes precedence. It is ignored as long as it still holds the
+placeholder from `config.sample.php` — so an unfinished configuration fails
+visibly instead of allowing an open login.
 
 ```bash
-php -r 'echo password_hash("ein-langes-passwort", PASSWORD_DEFAULT), PHP_EOL;'
+php -r 'echo password_hash("a-long-password", PASSWORD_DEFAULT), PHP_EOL;'
 ```
 
-In einfache Anführungszeichen setzen, ein bcrypt-Hash enthält `$`.
+Use single quotes; a bcrypt hash contains `$`.
 
-## Speicher
+## Storage
 
-| Konstante | Standard | Bedeutung |
+| Constant | Default | Meaning |
 |---|---|---|
-| `MANAGE_STORAGE_DIR` | `__DIR__ . "/storage/"` | Wurzel für Instanzen, Releases, Backups, Protokolle |
+| `MANAGE_STORAGE_DIR` | `__DIR__ . "/storage/"` | root for instances, releases, backups, logs |
 
-Alle weiteren Pfade leiten sich davon ab und müssen nicht einzeln gesetzt werden:
-`storage/instances.json`, `storage/settings.json`, `storage/releases/manifest.json`,
-`storage/releases/packages/`, `storage/backups/`, `storage/logs/`.
+All other paths derive from this one and don't need to be set individually:
+`storage/instances.json`, `storage/settings.json`,
+`storage/releases/manifest.json`, `storage/releases/packages/`,
+`storage/backups/`, `storage/logs/`.
 
-Das Verzeichnis darf nicht über das Web erreichbar sein.
+The directory must not be reachable over the web.
 
 ## Backups
 
-| Konstante | Standard | Bedeutung |
+| Constant | Default | Meaning |
 |---|---|---|
-| `MANAGE_BACKUP_RETENTION` | `30` | Lokale Backups pro Instanz. Minimum 1 |
-| `MANAGE_BACKUP_MAX_UPLOAD_BYTES` | `0` | Zusätzliches Größenlimit; `0` deaktiviert |
+| `MANAGE_BACKUP_RETENTION` | `30` | local backups per instance. Minimum 1 |
+| `MANAGE_BACKUP_MAX_UPLOAD_BYTES` | `0` | extra size limit; `0` disables it |
 
-Der in der Oberfläche gespeicherte Wert (`storage/settings.json`) hat Vorrang vor
-`MANAGE_BACKUP_RETENTION`, sobald er einmal gespeichert wurde.
+The value stored in the UI (`storage/settings.json`) takes precedence over
+`MANAGE_BACKUP_RETENTION` once it has been saved.
 
-`MANAGE_BACKUP_MAX_UPLOAD_BYTES` liegt **über** den PHP-Grenzen: `upload_max_filesize`
-und `post_max_size` greifen ohnehin und sind meist niedriger.
+`MANAGE_BACKUP_MAX_UPLOAD_BYTES` sits **above** the PHP limits:
+`upload_max_filesize` and `post_max_size` apply regardless and are usually
+lower.
 
-## S3-Archiv
+## S3 archive
 
-| Konstante | Standard | Bedeutung |
+| Constant | Default | Meaning |
 |---|---|---|
-| `MANAGE_S3_ENABLED` | `false` | Archivierung einschalten |
-| `MANAGE_S3_ENDPOINT` | `""` | z. B. `https://fsn1.your-objectstorage.com` |
-| `MANAGE_S3_REGION` | `""` | Region für die Signatur |
-| `MANAGE_S3_BUCKET` | `""` | Bucket-Name |
-| `MANAGE_S3_PREFIX` | `""` | Schlüsselpräfix im Bucket, darf leer sein |
-| `MANAGE_S3_ACCESS_KEY` | `""` | Access Key |
-| `MANAGE_S3_SECRET_KEY` | `""` | Secret Key |
+| `MANAGE_S3_ENABLED` | `false` | turn archiving on |
+| `MANAGE_S3_ENDPOINT` | `""` | e.g. `https://fsn1.your-objectstorage.com` |
+| `MANAGE_S3_REGION` | `""` | region for the signature |
+| `MANAGE_S3_BUCKET` | `""` | bucket name |
+| `MANAGE_S3_PREFIX` | `""` | key prefix in the bucket, may be empty |
+| `MANAGE_S3_ACCESS_KEY` | `""` | access key |
+| `MANAGE_S3_SECRET_KEY` | `""` | secret key |
 | `MANAGE_S3_PATH_STYLE` | `false` | `false` = virtual-hosted, `true` = path-style |
-| `MANAGE_S3_TIMEOUT` | `120` | Sekunden je HTTP-Anfrage |
-| `MANAGE_S3_RETENTION` | `365` | S3-Backups pro Instanz |
+| `MANAGE_S3_TIMEOUT` | `120` | seconds per HTTP request |
+| `MANAGE_S3_RETENTION` | `365` | S3 backups per instance |
 
-Das Archiv gilt nur als aktiv, wenn `MANAGE_S3_ENABLED` gesetzt **und** Endpunkt,
-Region, Bucket, Access Key und Secret Key gefüllt sind. Eine halbe Konfiguration
-bleibt wirkungslos statt bei jedem Upload zu scheitern.
+The archive only counts as active when `MANAGE_S3_ENABLED` is set **and**
+endpoint, region, bucket, access key and secret key are all filled in. A
+half-done configuration stays inert instead of failing on every upload.
 
-Verhalten und Fehlersuche: [SERVER_SETUP](SERVER_SETUP.md).
+Behavior and troubleshooting: [SERVER_SETUP](SERVER_SETUP.md).
 
-## Ratenbegrenzung
+## Rate limiting
 
-| Konstante | Standard | Bedeutung |
+| Constant | Default | Meaning |
 |---|---|---|
-| `MANAGE_LOGIN_RATE_LIMIT_MAX` | `10` | Fehlversuche an der Oberfläche je Zeitfenster und IP |
-| `MANAGE_LOGIN_RATE_LIMIT_WINDOW` | `900` | Zeitfenster in Sekunden |
-| `MANAGE_API_RATE_LIMIT_MAX` | `240` | Fehlgeschlagene API-Authentifizierungen je Fenster und IP |
-| `MANAGE_API_RATE_LIMIT_WINDOW` | `300` | Zeitfenster in Sekunden |
+| `MANAGE_LOGIN_RATE_LIMIT_MAX` | `10` | failed UI attempts per window and IP |
+| `MANAGE_LOGIN_RATE_LIMIT_WINDOW` | `900` | window in seconds |
+| `MANAGE_API_RATE_LIMIT_MAX` | `240` | failed API authentications per window and IP |
+| `MANAGE_API_RATE_LIMIT_WINDOW` | `300` | window in seconds |
 
-Der Zustand liegt in `storage/ratelimit/`. Eine erfolgreiche Authentifizierung
-setzt den Zähler der IP zurück. Ist der Zustand nicht schreibbar, lässt die
-Begrenzung bewusst durch, statt alle auszusperren.
+State lives in `storage/ratelimit/`. A successful authentication resets the
+IP's counter. If the state directory isn't writable, the limiter
+deliberately lets requests through instead of locking everyone out.
 
-## Protokolle
+## Logs
 
-| Konstante | Standard | Bedeutung |
+| Constant | Default | Meaning |
 |---|---|---|
-| `MANAGE_LOG_MAX_BYTES` | `1048576` | Rotation ab dieser Größe |
-| `MANAGE_LOG_KEEP_FILES` | `5` | Anzahl rotierter Dateien |
-| `MANAGE_LOG_MAX_AGE_SECONDS` | `2592000` | Rotierte Dateien danach löschen (30 Tage) |
+| `MANAGE_LOG_MAX_BYTES` | `1048576` | rotate once a log reaches this size |
+| `MANAGE_LOG_KEEP_FILES` | `5` | number of rotated files kept |
+| `MANAGE_LOG_MAX_AGE_SECONDS` | `2592000` | delete rotated files after this (30 days) |
 
-Gilt für `access.log` und `error.log`. `s3.log` wächst unbegrenzt und wird bei
-Bedarf von Hand gekürzt.
+Applies to `access.log` and `error.log`. `s3.log` grows unbounded and is
+trimmed by hand when needed.
 
-## Beispiel für eine minimale config.php
+## Example minimal config.php
 
 ```php
 <?php
 
-define("MANAGE_PRODUCT_NAME", "PSA Orderform");
-define("MANAGE_PACKAGE_PREFIX", "psa-orderform");
+define("MANAGE_PRODUCT_NAME", "Example Orderform");
+define("MANAGE_PACKAGE_PREFIX", "example-orderform");
 define("MANAGE_PUBLIC_URL", "https://manage.example.org");
 define("MANAGE_ADMIN_PASSWORD_HASH", '$2y$12$…');
 ```
 
-Alles Weitere übernimmt die Standardwerte.
+Everything else takes the default values.

+ 75 - 71
docs/INSTANCE_MANAGEMENT.md

@@ -1,107 +1,111 @@
-# Instanzen verwalten
+# Managing Instances
 
-## Überblick
+## Overview
 
-Eine Instanz ist eine Installation des betreuten Produkts – etwa "Produktiv",
-"Test" oder die Installation eines bestimmten Kunden. Jede Instanz hat eine
-Kennung und ein geheimes Token.
+An instance is one installation of the served product — for example
+"Production", "Test", or a specific customer's installation. Every instance
+has an id and a secret token.
 
-Alles dazu unter **Instanzen** in der Oberfläche.
+Everything related lives under **Instances** in the UI.
 
-## Instanz anlegen
+## Creating an instance
 
-1. **Instanzen** öffnen, Kennung eintragen, optional Bezeichnung und Notiz.
-2. Anlegen. Direkt danach wird das Token angezeigt – **einmalig**, zusammen mit
-   einem fertigen Konfigurationsblock zum Kopieren.
-3. Den Block in die `manage-client/config.php` der Instanz eintragen.
+1. Open **Instances**, enter an id, optionally a label and a note.
+2. Create. Right after that the token is shown — **once**, together with a
+   ready-made configuration block to copy.
+3. Paste the block into the instance's `manage-client/config.php`.
 
-Kennungen dürfen Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten,
-müssen mit einem Buchstaben oder einer Zahl beginnen und höchstens 120 Zeichen lang
-sein. Sie erscheinen im Dateipfad der Backups; sprechende Namen wie `psa-prod` und
-`psa-test` zahlen sich aus.
+Ids may contain letters, digits, dot, underscore and hyphen, must start with
+a letter or a digit, and can be at most 120 characters long. They appear in
+the backup file path; descriptive names such as `example-prod` and
+`example-test` pay off.
 
-## Das Token
+## The token
 
-- 32 zufällige Bytes, als 64 Hex-Zeichen dargestellt.
-- Der Server speichert nur den SHA-256-Hash. Es gibt keinen Weg, ein Token später
-  wieder anzuzeigen.
-- Geht es verloren, wird ein neues erzeugt – das alte wird dabei sofort ungültig.
+- 32 random bytes, represented as 64 hex characters.
+- The server stores only the SHA-256 hash. There is no way to display a
+  token again later.
+- If it's lost, a new one is generated — the old one becomes invalid
+  immediately.
 
-Mit dem Token kann eine Instanz Releases herunterladen und Backups hochladen. Sie
-kann **nicht** Backups herunterladen und **nicht** Releases verändern; beides
-erfordert die Anmeldung an der Oberfläche.
+The token lets an instance download releases and upload backups. It
+**cannot** download backups or change releases; both require logging into
+the UI.
 
-### Token erneuern
+### Rotating the token
 
-**Instanzen → Token erneuern**. Das alte Token verliert sofort seine Gültigkeit;
-die Instanz meldet danach `Authentifizierung fehlgeschlagen`, bis das neue Token
-eingetragen ist. Kurze Ausfälle von Cron-Jobs sind also einzuplanen.
+**Instances → Rotate token**. The old token loses its validity immediately;
+the instance then reports `Authentifizierung fehlgeschlagen` ("authentication
+failed" — the literal, still German, text the API returns) until the new
+token is entered. Plan for short outages of cron jobs accordingly.
 
-Anlässe: Verdacht auf Kompromittierung, Personalwechsel, Übergabe eines Projekts.
+Reasons to rotate: suspected compromise, staff changes, handing over a
+project.
 
-## Deaktivieren statt löschen
+## Deactivate instead of delete
 
-**Deaktivieren** lässt die Instanz bestehen, weist aber jede API-Anfrage mit `403`
-ab. Der richtige Weg, wenn eine Installation vorübergehend stillgelegt wird oder
-etwas unklar ist – das Token bleibt gültig und die Instanz ist mit einem Klick
-wieder betriebsbereit.
+**Deactivate** leaves the instance in place but rejects every API request
+with `403`. The right move when an installation is temporarily shut down or
+something is unclear — the token stays valid and the instance is back in
+service with one click.
 
-**Entfernen** löscht den Registereintrag. Die gespeicherten Backups bleiben
-erhalten und unter **Backups** sichtbar und herunterladbar; sie werden dort als
-"nicht mehr registriert" gekennzeichnet. Neue Uploads sind nicht mehr möglich.
+**Remove** deletes the registry entry. The stored backups remain and stay
+visible and downloadable under **Backups**; there they are marked as "no
+longer registered". New uploads are no longer possible.
 
-## Statusanzeige
+## Status display
 
-Die Übersicht zeigt für jede Instanz:
+The overview shows, for each instance:
 
-| Feld | Herkunft |
+| Field | Source |
 |---|---|
-| Status | `aktiv`, `inaktiv` (länger als 7 Tage nicht gesehen) oder `deaktiviert` |
-| Version | letzte Meldung der Instanz |
-| Update | Vergleich dieser Version mit dem aktuellen Release |
-| Zuletzt gesehen | jede authentifizierte Anfrage aktualisiert diesen Wert |
-| Letztes Backup | Zeitpunkt des letzten empfangenen Backups |
-| Offene Migrationen | aus dem Heartbeat der Instanz |
+| Status | `active`, `inactive` (not seen for more than 7 days) or `deactivated` |
+| Version | the instance's latest report |
+| Update | comparison of this version against the current release |
+| Last seen | every authenticated request updates this value |
+| Last backup | timestamp of the last received backup |
+| Pending migrations | from the instance's heartbeat |
 
-`inaktiv` bei einer laufenden Installation bedeutet meist, dass der Cron-Job für
-den Heartbeat nicht läuft. Ohne Cron meldet sich eine Instanz nur, wenn jemand die
-Oberfläche im Projekt benutzt.
+`inactive` on a running installation usually means the heartbeat cron job
+isn't running. Without cron, an instance only checks in when someone uses
+the UI in the project.
 
-Offene Migrationen sind das wichtigste Warnsignal: Sie bedeuten, dass ein Update
-zwar ausgerollt wurde, ein Teil des Post-Update-Schritts aber fehlgeschlagen ist.
+Pending migrations are the most important warning sign: they mean an update
+was rolled out, but part of the post-update step failed.
 
-## Client-Paket übergeben
+## Handing over the client package
 
-Der Ordner `client-package/` enthält den Client **und** dessen vollständige
-Dokumentation. Zum Weitergeben:
+The `client-package/` folder contains the client **and** its complete
+documentation. To hand it over:
 
 ```bash
 ./scripts/build-client-package.sh --server-url https://manage.example.org
 ```
 
-Das Ergebnis liegt unter `build/manage-client-<datum>.zip`. Mit `--server-url` ist
-die Server-Adresse in der mitgelieferten `config.sample.php` bereits eingetragen;
-die empfangende Seite ergänzt nur noch Kennung und Token.
+The result lands under `build/manage-client-<date>.zip`. With
+`--server-url`, the server address is already filled into the bundled
+`config.sample.php`; the receiving side only has to add the id and token.
 
-Das Skript entfernt vor dem Packen jede `config.php` und alle Protokolldateien,
-damit kein Token aus einer Testinstallation mitgeliefert wird.
+The script removes every `config.php` and all log files before packing, so
+no token from a test installation is shipped along.
 
-## Mehrere Umgebungen
+## Multiple environments
 
-Übliches Vorgehen für ein Produkt mit Test- und Produktivsystem:
+Common setup for a product with a test and a production system:
 
-| Instanz | Zweck |
+| Instance | Purpose |
 |---|---|
-| `produkt-test` | bekommt neue Releases zuerst |
-| `produkt-prod` | folgt nach erfolgreichem Test |
+| `product-test` | gets new releases first |
+| `product-prod` | follows after a successful test |
 
-Beide holen sich dasselbe `latest`. Wer ein Release nur auf dem Testsystem haben
-will, veröffentlicht es und setzt vorübergehend das ältere wieder als aktuell –
-oder rollt auf dem Testsystem mit `update --force` gezielt aus. Getrennte Kanäle
-pro Instanz gibt es bewusst nicht; dafür wird ein zweiter Manage-Server ausgerollt.
+Both fetch the same `latest`. To keep a release on the test system only,
+publish it and temporarily set the older one back as current — or roll it
+out on the test system deliberately with `update --force`. Separate
+channels per instance are deliberately absent; a second Manage server is
+deployed for that.
 
-## Weiter
+## Next
 
-- [SERVER_SETUP](SERVER_SETUP.md) – Installation
-- [RELEASING](RELEASING.md) – Releases veröffentlichen
-- [../client-package/docs/08_PROTOCOL.md](../client-package/docs/08_PROTOCOL.md) – Authentifizierung im Detail
+- [SERVER_SETUP](SERVER_SETUP.md) – installation
+- [RELEASING](RELEASING.md) – publishing releases
+- [../client-package/docs/08_PROTOCOL.md](../client-package/docs/08_PROTOCOL.md) – authentication in detail

+ 0 - 108
docs/MIGRATION_PSA.md

@@ -1,108 +0,0 @@
-# Ablösung des PSA-Bestellsystems
-
-## Überblick
-
-**Dieses Dokument beschreibt einen Umbau, der noch nicht durchgeführt wurde.**
-
-Das PSA-Bestellsystem (`/var/www/html/psa`) wurde beim Herauslösen von `manage`
-nicht verändert. Es läuft unverändert weiter gegen seine eigenen Verzeichnisse
-`update-server/` und `backup-server/`. Dieses Dokument hält fest, was ein späterer
-Wechsel bedeuten würde – damit die Entscheidung bewusst getroffen werden kann und
-nicht nebenbei passiert.
-
-## Warum es kein automatischer Wechsel ist
-
-`manage` spricht ein anderes Protokoll. Der bisherige Stand:
-
-| | bisher (psa) | jetzt (manage) |
-|---|---|---|
-| Manifest | öffentlich abrufbar | Token-Pflicht |
-| Paket-Download | öffentlich abrufbar | Token-Pflicht |
-| Backup-Upload | ohne Anmeldung, nur Namensliste | Token-Pflicht |
-| Instanz-Identität | nur beim Backup, als Klartext-Name | Registereintrag mit Token-Hash |
-
-Es gibt bewusst keine Kompatibilitätsschicht für das alte Protokoll. Der bestehende
-Client kann also nicht einfach auf `manage` gezeigt werden; er müsste ersetzt werden.
-
-## Was zu tun wäre
-
-### 1. Instanz anlegen
-
-Im Manage-Server eine Instanz anlegen, zum Beispiel `psa-prod`, und das einmalig
-angezeigte Token notieren.
-
-### 2. Client einbauen
-
-`client-package/manage-client/` nach `psa/manage-client/` kopieren und
-`config.php` anlegen. Die Zuordnung der bisherigen Konstanten:
-
-| bisher in `psa/config.php` | neu in `psa/manage-client/config.php` |
-|---|---|
-| `UPDATE_MANIFEST_URL` | entfällt – ersetzt durch `MANAGE_SERVER_URL` + Token |
-| `UPDATE_WORK_DIR` | `MANAGE_WORK_DIR` |
-| `UPDATE_BACKUP_DIR` | `MANAGE_UPDATE_BACKUP_DIR` |
-| `BACKUP_DIR` | `MANAGE_BACKUP_DIR` |
-| `BACKUP_LOCAL_RETENTION` | `MANAGE_BACKUP_LOCAL_RETENTION` |
-| `BACKUP_AUTO_INTERVAL_SECONDS` | `MANAGE_BACKUP_AUTO_INTERVAL_SECONDS` |
-| `BACKUP_REMOTE_TARGETS` mit `type => managed` | entfällt – eingebaut, über `MANAGE_BACKUP_UPLOAD` |
-| `BACKUP_REMOTE_TARGETS` mit `s3`/`sftp`/`custom` | `MANAGE_BACKUP_REMOTE_TARGETS`, unverändertes Format |
-| – | `MANAGE_VERSION_FILE` = `includes/version.php`, `MANAGE_VERSION_CONSTANT` = `APP_VERSION` |
-
-Die bisherigen Backup-Quellen entsprechen genau:
-
-```php
-define("MANAGE_BACKUP_SOURCES", [
-    ["as" => "data", "glob" => "data/*.json"],
-    ["as" => "data/uploads", "dir" => "data/uploads"],
-]);
-```
-
-Damit sind die erzeugten Archive inhaltlich identisch zu den bisherigen.
-
-### 3. Oberfläche ersetzen
-
-`psa/admin/updater.php` würde durch eine Seite ersetzt, die
-`manage-client/ui/panel.php` einbindet – mit der bestehenden Anmeldeprüfung von
-psa davor. Der Statusblock in `psa/admin/settings.php`
-(`settingsGetUpdaterStatus()`) würde durch
-`manage-client/ui/status-partial.php` ersetzt.
-
-Der Aufruf `backupCreateAutomaticIfDue()` in `psa/admin/index.php` würde zu
-`manageBackupCreateAutomaticIfDue()`.
-
-### 4. Altes entfernen
-
-Danach könnten entfallen:
-
-- `psa/admin/updater.php`
-- `psa/includes/backup.php`
-- `psa/update-server/`
-- `psa/backup-server/`
-- `psa/scripts/create-update-zip.sh` (ersetzt durch die angepasste Vorlage aus
-  `manage/scripts/create-release-zip.sh`)
-
-Die zugehörigen Abschnitte in `psa/docs/BACKUP_CONFIGURATION.md` und
-`psa/docs/CONFIG_REFERENCE.md` würden auf die Client-Dokumentation verweisen.
-
-### 5. Bestehende Daten
-
-- **Backups**: Die bisherigen Archive liegen auf dem alten Backup-Server. Sie
-  lassen sich nicht automatisch übernehmen; entweder werden sie dort belassen, bis
-  ihre Aufbewahrungsfrist abläuft, oder sie werden von Hand nach
-  `manage/storage/backups/psa-prod/` kopiert und in `index.json` eingetragen.
-- **Releases**: Die ZIPs aus `psa/update-server/packages/` können über die
-  Oberfläche unter **Releases** hochgeladen werden; Prüfsummen berechnet der
-  Server neu.
-
-## Empfohlene Reihenfolge
-
-1. Manage-Server aufsetzen und mit einer **Testinstanz** vollständig durchspielen.
-2. Instanz `psa-test` anlegen und den Umbau an einer Kopie von psa erproben.
-3. Erst danach die Produktivinstallation umstellen – mit frischem Backup über den
-   **alten** Weg, bevor der alte Weg abgeschaltet wird.
-4. Alte Server-Verzeichnisse einige Wochen laufen lassen, bevor sie entfernt werden.
-
-## Weiter
-
-- [ARCHITECTURE](ARCHITECTURE.md) – woher welcher Code stammt
-- [../client-package/docs/02_INTEGRATION.md](../client-package/docs/02_INTEGRATION.md) – Einbindung im Projekt

+ 61 - 59
docs/RELEASING.md

@@ -1,96 +1,98 @@
-# Releases veröffentlichen
+# Publishing Releases
 
-## Überblick
+## Overview
 
-Ein Release ist ein ZIP, dessen Wurzel der Anwendungsstamm des Projekts ist. Der
-Client rollt es über die bestehende Installation aus.
+A release is a ZIP whose root is the project's application root. The client
+rolls it out over the existing installation.
 
-Ablauf: bauen → hochladen → als aktuell setzen → Instanzen holen es ab.
+Flow: build → upload → set as current → instances fetch it.
 
-## 1. Paket bauen
+## 1. Build the package
 
-`scripts/create-release-zip.sh` ist die Vorlage. Sie wird einmal pro Projekt
-angepasst (Produktname, Versionsdatei, Ausschlussliste), ins Projekt kopiert und
-dort aufgerufen:
+`client-package/scripts/create-release-zip.sh` is the template. It ships
+with the client package, gets adjusted once per project (product name,
+version file, exclusion list), gets copied into the project, and is run
+there:
 
 ```bash
 ./scripts/create-release-zip.sh v1.3.0
 ```
 
-Das Skript
+The script
 
-1. schreibt die Version in die Versionsdatei,
-2. prüft nach, dass das Schreiben tatsächlich funktioniert hat,
-3. packt alle von Git verfolgten Dateien abzüglich der Ausschlussliste,
-4. gibt Dateizahl, Größe und SHA-256 aus.
+1. writes the version into the version file,
+2. verifies that the write actually took effect,
+3. packs every file tracked by Git, minus the exclusion list,
+4. prints file count, size and SHA-256.
 
-Es warnt, wenn die Arbeitskopie ungesicherte Änderungen enthält: Weil die
-Dateiliste aus `git ls-files` stammt, landen nicht eingecheckte Änderungen sonst
-still nicht im Paket.
+It warns if the working copy has uncommitted changes: since the file list
+comes from `git ls-files`, uncommitted changes would otherwise silently be
+left out of the package.
 
-Ausführlich – Aufbau des Pakets, Ausschlüsse, häufige Fehler:
+Details — package layout, exclusions, common mistakes:
 [../client-package/docs/06_UPDATE_PACKAGING.md](../client-package/docs/06_UPDATE_PACKAGING.md).
 
-## 2. Hochladen
+## 2. Upload
 
-**Releases** öffnen, Version im Format `vX.Y.Z` eintragen, ZIP auswählen,
-hochladen.
+Open **Releases**, enter a version in `vX.Y.Z` format, choose the ZIP,
+upload.
 
-Der Server
+The server
 
-- prüft die Endung und die ZIP-Signatur der Datei,
-- speichert sie als `<MANAGE_PACKAGE_PREFIX>-<version>.zip`,
-- berechnet SHA-256 und Größe **selbst** und trägt sie ins Manifest ein,
-- setzt das Release als aktuell.
+- checks the file's extension and ZIP signature,
+- stores it as `<MANAGE_PACKAGE_PREFIX>-<version>.zip`,
+- computes SHA-256 and size **itself** and records them in the manifest,
+- sets the release as current.
 
-Die angezeigte Prüfsumme sollte mit der des Build-Skripts übereinstimmen. Tut sie
-das nicht, wurde eine andere Datei hochgeladen.
+The checksum shown should match the one from the build script. If it
+doesn't, a different file was uploaded.
 
-Schlägt der Upload ohne erkennbaren Grund fehl, ist meist das PHP-Upload-Limit
-kleiner als das Paket. Die geltenden Werte stehen auf der Releases-Seite und unter
-**Einstellungen → Diagnose**.
+If the upload fails for no apparent reason, the PHP upload limit is usually
+smaller than the package. The current values are shown on the Releases page
+and under **Settings → Diagnostics**.
 
-## 3. Aktuelles Release wählen
+## 3. Choose the current release
 
-Ein Upload setzt das neue Release automatisch als aktuell. Über **Als aktuell
-setzen** kann jederzeit ein anderes gewählt werden – das ist auch der Weg, um nach
-einem missglückten Release wieder auf die vorherige Fassung zu zeigen.
+An upload automatically sets the new release as current. **Set as current**
+can select a different one at any time — this is also the way to point back
+at the previous version after a botched release.
 
-Wichtig: Das ändert nur, was Instanzen künftig herunterladen. Bereits ausgerollte
-Instanzen bleiben, wo sie sind – der Client kennt keine Rücknahme. Eine Instanz auf
-die ältere Fassung zurückzubringen heißt, sie mit `update --force` erneut ausrollen
-zu lassen; Datenänderungen aus Migrationen macht das nicht rückgängig.
+Important: this only changes what instances download from now on. Already
+deployed instances stay where they are — the client has no rollback. Bringing
+an instance back to an older version means rolling it out again with
+`update --force`; that does not undo data changes made by migrations.
 
-## 4. Ausrollen
+## 4. Deploy
 
-Auf der Instanz:
+On the instance:
 
 ```bash
-php manage-client/bin/manage-client.php check     # Exit 2 = Update verfügbar
-php manage-client/bin/manage-client.php backup    # vorher sichern
+php manage-client/bin/manage-client.php check     # exit 2 = update available
+php manage-client/bin/manage-client.php backup    # back up first
 php manage-client/bin/manage-client.php update
 ```
 
-Oder über die Oberfläche im Adminbereich des Projekts.
+Or via the UI in the project's admin area.
 
-Updates laufen nicht automatisch. Sie überschreiben Dateien im laufenden Betrieb
-und können Migrationen auslösen; das gehört unter Aufsicht.
+Updates don't run automatically. They overwrite files while the application
+is live and can trigger migrations; that belongs under supervision.
 
-## Release löschen
+## Deleting a release
 
-**Löschen** entfernt den Manifest-Eintrag und die ZIP-Datei. War es das aktuelle
-Release, hat der Server danach keines – Instanzen melden dann `Es ist kein gültiges
-Release veröffentlicht`. Vorher ein anderes als aktuell setzen.
+**Delete** removes the manifest entry and the ZIP file. If it was the
+current release, the server then has none — instances report `Es ist kein
+gültiges Release veröffentlicht` ("no valid release is published" — the
+literal, still German, API text). Set another one as current first.
 
-## Versionsnummern
+## Version numbers
 
-Format `vX.Y.Z`, sonst nichts. Client und Server lehnen alles andere ab. Eine
-bereits veröffentlichte Version erneut hochzuladen überschreibt das Paket – bei
-einem fehlerhaften Release ist eine neue Patch-Version die sauberere Wahl, weil
-Instanzen sonst je nach Zeitpunkt Unterschiedliches installiert haben.
+Format `vX.Y.Z`, nothing else. Both client and server reject anything else.
+Re-uploading an already-published version overwrites the package — for a
+broken release, a new patch version is the cleaner choice, because otherwise
+instances end up with different installs depending on when they updated.
 
-## Weiter
+## Next
 
-- [../client-package/docs/06_UPDATE_PACKAGING.md](../client-package/docs/06_UPDATE_PACKAGING.md) – Paketaufbau
-- [../client-package/docs/07_POST_UPDATE_HOOKS.md](../client-package/docs/07_POST_UPDATE_HOOKS.md) – Migrationen im Paket
-- [INSTANCE_MANAGEMENT](INSTANCE_MANAGEMENT.md) – Instanzen
+- [../client-package/docs/06_UPDATE_PACKAGING.md](../client-package/docs/06_UPDATE_PACKAGING.md) – package layout
+- [../client-package/docs/07_POST_UPDATE_HOOKS.md](../client-package/docs/07_POST_UPDATE_HOOKS.md) – migrations in the package
+- [INSTANCE_MANAGEMENT](INSTANCE_MANAGEMENT.md) – instances

+ 85 - 83
docs/SERVER_SETUP.md

@@ -1,74 +1,74 @@
-# Server einrichten
+# Setting Up the Server
 
-## Überblick
+## Overview
 
-Installation und Betrieb des Manage-Servers. Voraussetzungen: PHP 8.0 oder neuer
-und ein Webserver. Kein Datenbankserver, kein Composer, kein Build-Schritt.
+Installation and operation of the Manage server. Requirements: PHP 8.0 or
+newer and a web server. No database server, no Composer, no build step.
 
 ## Installation
 
-1. Repository in ein Verzeichnis des Webservers legen, zum Beispiel
+1. Put the repository into a directory of the web server, for example
    `/var/www/manage`.
 
-2. Konfiguration anlegen:
+2. Create the configuration:
 
    ```bash
    cp config.sample.php config.php
    ```
 
-3. Passwort-Hash erzeugen und eintragen:
+3. Generate a password hash and enter it:
 
    ```bash
-   php -r 'echo password_hash("ein-langes-passwort", PASSWORD_DEFAULT), PHP_EOL;'
+   php -r 'echo password_hash("a-long-password", PASSWORD_DEFAULT), PHP_EOL;'
    ```
 
    ```php
    define("MANAGE_ADMIN_PASSWORD_HASH", '$2y$12$…');
    ```
 
-   Einfache Anführungszeichen verwenden – ein bcrypt-Hash enthält `$`.
+   Use single quotes — a bcrypt hash contains `$`.
 
-4. Öffentliche URL setzen. Ohne diesen Wert kann kein Client ein Paket
-   herunterladen:
+4. Set the public URL. Without this value, no client can download a
+   package:
 
    ```php
    define("MANAGE_PUBLIC_URL", "https://manage.example.org");
    ```
 
-   Absolut, ohne Schrägstrich am Ende. Liegt die Installation in einem
-   Unterverzeichnis, gehört es dazu: `https://example.org/manage`.
+   Absolute, without a trailing slash. If the installation lives in a
+   subdirectory, that belongs too: `https://example.org/manage`.
 
-5. Produkt benennen. `MANAGE_PACKAGE_PREFIX` bestimmt den Dateinamen der
-   gespeicherten Pakete und sollte zum Build-Skript des Projekts passen:
+5. Name the product. `MANAGE_PACKAGE_PREFIX` determines the filename of
+   stored packages and should match the project's build script:
 
    ```php
-   define("MANAGE_PRODUCT_NAME", "PSA Orderform");
-   define("MANAGE_PACKAGE_PREFIX", "psa-orderform");
+   define("MANAGE_PRODUCT_NAME", "Example Orderform");
+   define("MANAGE_PACKAGE_PREFIX", "example-orderform");
    ```
 
-6. Schreibrechte auf `storage/` sicherstellen. Das Verzeichnis wird bei Bedarf
-   selbst angelegt:
+6. Make sure `storage/` is writable. The directory is created on demand if
+   needed:
 
    ```bash
    mkdir -p storage && chown www-data:www-data storage && chmod 2775 storage
    ```
 
-7. Oberfläche öffnen: `https://manage.example.org/admin/login.php`.
+7. Open the UI: `https://manage.example.org/admin/login.php`.
 
-Unter **Einstellungen → Diagnose** steht danach, ob alles Wesentliche stimmt:
-öffentliche URL, Schreibrechte, Passwort, Upload-Limits.
+Under **Settings → Diagnostics** you can then see whether everything
+essential checks out: public URL, write access, password, upload limits.
 
-## Webserver
+## Web server
 
 ### Apache
 
-Die mitgelieferte `.htaccess` sperrt `storage/`, `includes/`, `client-package/`
-sowie `config.php` und setzt Sicherheits-Header. Sie funktioniert nur, wenn
-`AllowOverride All` für das Verzeichnis gesetzt ist.
+The bundled `.htaccess` locks `storage/`, `includes/`, `client-package/` and
+`config.php`, and sets security headers. It only works when
+`AllowOverride All` is set for the directory.
 
 ### nginx
 
-Für nginx greift keine `.htaccess`. Die Sperren müssen von Hand gesetzt werden:
+`.htaccess` has no effect under nginx. The locks must be set by hand:
 
 ```nginx
 location ^~ /storage/       { deny all; return 404; }
@@ -78,19 +78,20 @@ location = /config.php      { deny all; return 404; }
 location ~ /\.              { deny all; return 404; }
 ```
 
-Prüfen, dass die Sperren greifen:
+Verify the locks are effective:
 
 ```bash
 curl -s -o /dev/null -w "%{http_code}\n" https://manage.example.org/config.php
 curl -s -o /dev/null -w "%{http_code}\n" https://manage.example.org/storage/instances.json
 ```
 
-Beides muss `403` oder `404` liefern.
+Both must return `403` or `404`.
 
-## Upload-Limits
+## Upload limits
 
-Backups und Release-Pakete werden per HTTP hochgeladen. Beide PHP-Grenzen müssen
-groß genug sein, `post_max_size` mindestens so groß wie `upload_max_filesize`:
+Backups and release packages are uploaded over HTTP. Both PHP limits need to
+be large enough, with `post_max_size` at least as large as
+`upload_max_filesize`:
 
 ```ini
 upload_max_filesize = 256M
@@ -99,83 +100,84 @@ max_execution_time = 300
 memory_limit = 256M
 ```
 
-Die aktuellen Werte zeigt die Diagnose-Seite. Ist der Wert zu klein, meldet der
-Client eine Fehlermeldung, die die Ursache ausdrücklich benennt.
+The current values are shown on the diagnostics page. If a value is too
+small, the client reports an error message that names the cause explicitly.
 
-`memory_limit` wird relevant, wenn das S3-Archiv aktiv ist: Ein Upload zu S3 hält
-die Datei vollständig im Speicher.
+`memory_limit` becomes relevant when the S3 archive is active: an upload to
+S3 holds the file in memory in full.
 
-## Aufbewahrung
+## Retention
 
-Unter **Einstellungen** einstellbar, gespeichert in `storage/settings.json`. Die
-Werte dort haben Vorrang vor den Konstanten in `config.php`.
+Configurable under **Settings**, stored in `storage/settings.json`. The
+values there take precedence over the constants in `config.php`.
 
-- **Lokale Backups pro Instanz** – Standard 30, Minimum 1
-- **S3-Backups pro Instanz** – Standard 365, nur bei aktivem S3-Archiv
+- **Local backups per instance** – default 30, minimum 1
+- **S3 backups per instance** – default 365, only with the S3 archive active
 
-Änderungen werden sofort angewendet, nicht erst beim nächsten Upload.
+Changes apply immediately, not only on the next upload.
 
-## S3-Archiv (optional)
+## S3 archive (optional)
 
-Ohne S3 liegen alle Backups auf der lokalen Platte. Mit S3 wird jedes empfangene
-Backup zusätzlich in ein S3-kompatibles Objektspeicher-System geschoben; lokal
-bleiben nur die neuesten Kopien.
+Without S3, all backups sit on the local disk. With S3, every received
+backup is additionally pushed to an S3-compatible object storage system;
+locally, only the newest copies remain.
 
 ```php
 define("MANAGE_S3_ENABLED", true);
 define("MANAGE_S3_ENDPOINT", "https://fsn1.your-objectstorage.com");
 define("MANAGE_S3_REGION", "fsn1");
-define("MANAGE_S3_BUCKET", "mein-backup-bucket");
+define("MANAGE_S3_BUCKET", "my-backup-bucket");
 define("MANAGE_S3_PREFIX", "manage-backups");
 define("MANAGE_S3_ACCESS_KEY", "…");
 define("MANAGE_S3_SECRET_KEY", "…");
 ```
 
-Objekte liegen unter `<prefix>/<instanz>/<dateiname>`.
+Objects live under `<prefix>/<instance>/<filename>`.
 
-Adressierung: Standard ist virtual-hosted (`https://<bucket>.<endpoint>/<key>`),
-was Hetzner und die meisten Anbieter erwarten. Verlangt der Anbieter path-style,
-`MANAGE_S3_PATH_STYLE` auf `true` setzen.
+Addressing: the default is virtual-hosted
+(`https://<bucket>.<endpoint>/<key>`), which Hetzner and most providers
+expect. If the provider requires path-style, set `MANAGE_S3_PATH_STYLE` to
+`true`.
 
-Verhalten:
+Behavior:
 
-- S3-Fehler lassen einen Client-Upload nie fehlschlagen.
-- Eine lokale Kopie wird erst gelöscht, wenn sie aus der lokalen Aufbewahrung
-  gefallen **und** die S3-Kopie bestätigt ist.
-- Fehlgeschlagene Uploads werden beim nächsten Upload derselben Instanz oder über
-  "S3-Uploads jetzt nachholen" wiederholt.
-- Der Bucket kann und soll privat bleiben: Downloads laufen über die Oberfläche.
-- Beim Aktivieren auf einer bestehenden Installation einmal "S3-Uploads jetzt
-  nachholen" drücken, damit das Archiv aufgeholt wird.
+- S3 errors never fail a client upload.
+- A local copy is only deleted once it has fallen out of local retention
+  **and** the S3 copy is confirmed.
+- Failed uploads are retried on the next upload from the same instance, or
+  via "Catch up S3 uploads now".
+- The bucket can and should stay private: downloads go through the UI.
+- When enabling this on an existing installation, press "Catch up S3
+  uploads now" once so the archive catches up.
 
-Fehlersuche über `storage/logs/s3.log` und den Auszug unter **Einstellungen**:
-`AccessDenied` oder eine Umleitung in der Statuskette deuten fast immer auf die
-falsche Adressierungsart hin, `SignatureDoesNotMatch` auf falsche Region oder
-falschen Secret Key. Umleitungen werden bewusst nicht verfolgt, damit eine
-Fehlkonfiguration sichtbar wird.
+Troubleshooting via `storage/logs/s3.log` and the excerpt under
+**Settings**: `AccessDenied` or a redirect in the status chain almost always
+points to the wrong addressing style; `SignatureDoesNotMatch` to a wrong
+region or secret key. Redirects are deliberately not followed, so a
+misconfiguration stays visible.
 
-## Sicherung des Servers
+## Backing up the server
 
-Der Manage-Server hält Release-Pakete und die Backups aller Instanzen – er ist
-selbst sicherungswürdig. Zu sichern sind:
+The Manage server holds release packages and every instance's backups — it
+is itself worth backing up. To back up:
 
-- `storage/` – Instanzen, Manifest, Pakete, Backups, Einstellungen
-- `config.php` – Zugangsdaten
+- `storage/` – instances, manifest, packages, backups, settings
+- `config.php` – credentials
 
-Bei aktivem S3-Archiv liegen die Backups zusätzlich im Bucket; `storage/instances.json`
-und `storage/releases/` aber nicht.
+With the S3 archive active, backups additionally live in the bucket; but
+`storage/instances.json` and `storage/releases/` do not.
 
-## Betrieb
+## Operation
 
-- Die Anmeldung ist pro IP begrenzt (Standard 10 Versuche je 15 Minuten).
-- Ratenbegrenzung gilt auch für fehlgeschlagene API-Authentifizierungen.
-- Protokolle liegen unter `storage/logs/` und rotieren automatisch.
-- Ein Passwortwechsel erfolgt in `config.php`; angemeldete Sitzungen bleiben bis
-  zum Ablauf bestehen. Sollen sie sofort enden, `session.save_path` leeren oder
-  `session_name` in `includes/auth.php` ändern.
+- Login is rate-limited per IP (default 10 attempts per 15 minutes).
+- Rate limiting also applies to failed API authentications.
+- Logs live under `storage/logs/` and rotate automatically.
+- A password change happens in `config.php`; logged-in sessions remain
+  valid until they expire. To end them immediately, clear
+  `session.save_path` or change `session_name` in `includes/auth.php`.
 
-## Weiter
+## Next
 
-- [INSTANCE_MANAGEMENT](INSTANCE_MANAGEMENT.md) – Instanzen anlegen und Tokens vergeben
-- [RELEASING](RELEASING.md) – Releases bauen und veröffentlichen
-- [CONFIG_REFERENCE](CONFIG_REFERENCE.md) – alle Serverkonstanten
+- [INSTANCE_MANAGEMENT](INSTANCE_MANAGEMENT.md) – creating instances and issuing tokens
+- [RELEASING](RELEASING.md) – building and publishing releases
+- [CONFIG_REFERENCE](CONFIG_REFERENCE.md) – every server constant

+ 12 - 12
docs/index.php

@@ -4,8 +4,8 @@ declare(strict_types=1);
 
 /**
  * Documentation viewer (Markdown -> HTML via the vendored marked.js).
- * Adapted from the PSA order system (docs/index.php). Self-contained: no build
- * step and no external requests, so it also works from an unpacked ZIP.
+ * Self-contained: no build step and no external requests, so it also works
+ * from an unpacked ZIP.
  */
 
 $docsDir = __DIR__;
@@ -41,18 +41,18 @@ $docsTitle = basename(dirname($docsDir)) === "client-package"
 $requested = isset($_GET["doc"]) ? (string) $_GET["doc"] : "";
 $activeDoc = null;
 $markdown = null;
-$pageTitle = "Dokumentation";
+$pageTitle = "Documentation";
 
 if ($requested !== "") {
     if (!isset($docMap[$requested])) {
         http_response_code(404);
-        $pageTitle = "Nicht gefunden";
+        $pageTitle = "Not Found";
     } else {
         $activeDoc = $requested;
         $markdown = file_get_contents($docMap[$requested]);
         if ($markdown === false) {
             http_response_code(500);
-            $pageTitle = "Fehler";
+            $pageTitle = "Error";
             $markdown = null;
         } else {
             $pageTitle = docTitle($activeDoc);
@@ -68,7 +68,7 @@ if ($baseHref === "") {
 }
 ?>
 <!DOCTYPE html>
-<html lang="de">
+<html lang="en">
 <head>
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -82,8 +82,8 @@ if ($baseHref === "") {
     </div>
 </header>
 <div class="docs-layout">
-    <nav class="docs-nav" aria-label="Dokumentation">
-        <p class="docs-nav-title">Inhalt</p>
+    <nav class="docs-nav" aria-label="Documentation">
+        <p class="docs-nav-title">Contents</p>
         <ul>
             <?php foreach ($docMap as $key => $_path): ?>
                 <li>
@@ -97,8 +97,8 @@ if ($baseHref === "") {
     </nav>
     <main class="docs-main">
         <?php if ($requested === ""): ?>
-            <h1>Dokumentation</h1>
-            <p>Alle Dokumente in empfohlener Lesereihenfolge.</p>
+            <h1>Documentation</h1>
+            <p>All documents in the recommended reading order.</p>
             <ul class="docs-index-list">
                 <?php foreach ($docMap as $key => $_path): ?>
                     <li>
@@ -110,8 +110,8 @@ if ($baseHref === "") {
             </ul>
         <?php elseif ($markdown === null): ?>
             <h1><?php echo docEscape($pageTitle); ?></h1>
-            <p>Das angeforderte Dokument ist nicht verfügbar.</p>
-            <p><a href="<?php echo docEscape($baseHref); ?>index.php">Zur Übersicht</a></p>
+            <p>The requested document is not available.</p>
+            <p><a href="<?php echo docEscape($baseHref); ?>index.php">Back to the overview</a></p>
         <?php else: ?>
             <article id="doc-content" class="markdown-body"></article>
             <script type="application/json" id="doc-source"><?php

+ 1 - 1
includes/api.php

@@ -7,7 +7,7 @@ declare(strict_types=1);
 // Protocol v1: every request carries the instance id and its secret token in
 // two headers. There is no session, no cookie and no shared password.
 //
-//   X-Manage-Instance: psa-prod
+//   X-Manage-Instance: example-prod
 //   X-Manage-Token:    <64 hex chars>
 
 require_once __DIR__ . "/bootstrap.php";

+ 2 - 3
includes/auth.php

@@ -2,9 +2,8 @@
 
 declare(strict_types=1);
 
-// Admin session, password check and CSRF for the management UI. Merges the two
-// near-identical login halves of the PSA update-server/manage.php and
-// backup-server/manage.php into one implementation.
+// Admin session, password check and CSRF for the management UI. One shared
+// implementation for the release and backup areas.
 
 require_once __DIR__ . "/bootstrap.php";
 

+ 1 - 2
includes/backups.php

@@ -3,8 +3,7 @@
 declare(strict_types=1);
 
 // Server-side backup storage: receiving, indexing, S3 archiving and the two
-// retention tiers. Ported from the PSA order system (backup-server/lib.php),
-// with the instance allowlist replaced by the token-authenticated registry in
+// retention tiers. Instances are authenticated against the token registry in
 // includes/instances.php.
 
 require_once __DIR__ . "/bootstrap.php";

+ 1 - 2
includes/log.php

@@ -2,8 +2,7 @@
 
 declare(strict_types=1);
 
-// JSONL logging with size-based rotation. Ported from the PSA order system
-// (includes/functions.php logError/logAccess/rotateLogFileIfNeeded).
+// JSONL logging with size-based rotation.
 
 function manageErrorLogFile(): string
 {

+ 1 - 2
includes/ratelimit.php

@@ -2,8 +2,7 @@
 
 declare(strict_types=1);
 
-// File-based per-IP rate limiting for hosting without Redis. Ported from the
-// PSA order system (includes/functions.php rateLimitEvaluate). State lives
+// File-based per-IP rate limiting for hosting without Redis. State lives
 // under storage/ratelimit/ and is therefore not web-readable.
 
 function manageRateLimitClientIp(): string

+ 2 - 3
includes/releases.php

@@ -2,10 +2,9 @@
 
 declare(strict_types=1);
 
-// Release storage and manifest handling. Ported from the PSA order system
-// (update-server/manage.php and update-server/manifest.php).
+// Release storage and manifest handling.
 //
-// Two deliberate changes against the original:
+// Two deliberate design decisions:
 //  - the package filename prefix is configurable (MANAGE_PACKAGE_PREFIX)
 //  - the package download URL is built from MANAGE_PUBLIC_URL instead of the
 //    client-controlled Host header

+ 2 - 3
includes/s3.php

@@ -3,9 +3,8 @@
 declare(strict_types=1);
 
 // Dependency-free client for S3-compatible object storage (AWS Signature V4).
-// Ported from the PSA order system (backup-server/s3.php) with the constant
-// prefix changed to MANAGE_S3_*. No SDK and no cURL are required; plain PHP
-// HTTPS streams are enough.
+// Configured through the MANAGE_S3_* constants. No SDK and no cURL are
+// required; plain PHP HTTPS streams are enough.
 
 require_once __DIR__ . "/bootstrap.php";
 

+ 2 - 4
includes/storage.php

@@ -2,10 +2,8 @@
 
 declare(strict_types=1);
 
-// Flat-file storage primitives. Ported from the PSA order system
-// (includes/functions.php readJsonFile/writeJsonFile): atomic writes via a
-// temp file plus rename, so a crashed request can never leave a half-written
-// index behind.
+// Flat-file storage primitives: atomic writes via a temp file plus rename, so
+// a crashed request can never leave a half-written index behind.
 
 function manageEnsureDirectory(string $dir): void
 {

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff