<?php
/*
 * Owner/admin-only guide: Online Updates + the App Bundle engine.
 * Served by modules/Help/HelpController (topic = "updates").
 * No $baseUrl in scope here — use anchor (#id) or relative (sibling) links only.
 */
$c = static fn (string $s): string => '<pre><code>' . htmlspecialchars($s) . '</code></pre>';
?>

<h1><i class="bi bi-cloud-arrow-down"></i> Online Updates &amp; the App Bundle</h1>
<p class="lead">One owner console generates every package &mdash; full installers and tiny over&#8209;the&#8209;air (OTA)
   deltas &mdash; publishes them to a host you control, and every licensed install upgrades itself from a
   <strong>signed</strong> index it verifies offline.</p>

<div class="alert alert-info">
    <strong>Who is this for?</strong> Owners and administrators.
    <strong>Generating and publishing packages is owner&#8209;only</strong> (<code>admin-ppradhan</code>) &mdash; the
    <b>App&nbsp;Bundle</b> console. <em>Checking for</em> and <em>installing</em> an update is administrator&#8209;level,
    from the <b>Platform&nbsp;Updates</b> page, exactly like Update&nbsp;Locally.
</div>

<h2>Contents</h2>
<ul>
    <li><a href="#parts">The moving parts</a></li>
    <li><a href="#keys">The signing key &mdash; the one thing that must match</a></li>
    <li><a href="#console">The App Bundle console, tab by tab</a></li>
    <li><a href="#host">Setting up the update host (once)</a></li>
    <li><a href="#publish">Publishing an OTA update</a></li>
    <li><a href="#deltaonly">Why OTA is delta&#8209;only</a></li>
    <li><a href="#index">The signed index on the host</a></li>
    <li><a href="#check">What <em>Check for Updates</em> does</a></li>
    <li><a href="#customer">What a customer sees &mdash; installing and updating</a></li>
    <li><a href="#trouble">Troubleshooting</a></li>
</ul>

<h2 id="parts">The moving parts</h2>
<table class="table table-sm">
    <thead><tr><th>Piece</th><th>Where</th><th>Job</th></tr></thead>
    <tbody>
        <tr><td><b>Publisher</b> (your dev box)</td><td>localhost</td>
            <td>Builds bundles, builds deltas, and <b>signs</b> the release <code>index.json</code>, then uploads
                everything to the host. This is where you press <em>Build&nbsp;&amp;&nbsp;publish</em>.</td></tr>
        <tr><td><b>Authorities</b></td><td>your primary + backup licence servers</td>
            <td>Server&#8209;edition installs. They read the signed index <b>directly</b> and self&#8209;update.
                They also answer the licence&#8209;gated feed for customers.</td></tr>
        <tr><td><b>Update host</b></td><td>e.g. <code>ppphp&#8209;updates.&hellip;</code> (FTP/SFTP)</td>
            <td>Just a file store: <code>full/</code> holds full installers, <code>delta/</code> holds patches, and
                <code>index.json</code> sits at the root. Clients download over <b>https</b>.</td></tr>
        <tr><td><b>Customers</b></td><td>anywhere</td>
            <td>Client&#8209;edition installs. Their <em>Check&nbsp;for&nbsp;Updates</em> passes <b>through</b> a licence
                server, which confirms a genuine, active licence before offering the delta.</td></tr>
    </tbody>
</table>

<h2 id="keys">The signing key &mdash; the one thing that must match</h2>
<p>Updates and licences share <strong>one</strong> Ed25519 signing key
   (<code>storage/license/server-key.json</code>). That single key signs both the licence leases <em>and</em> the
   update <code>index.json</code>; every install verifies both against <strong>one</strong> public key pinned in its
   bundle (<code>core/Licensing/LicenseTrust.php</code>).</p>

<div class="alert alert-danger">
    <strong>Every machine that <em>signs</em> must hold the same key file.</strong> That includes the box you
    <em>publish</em> from. Because <code>storage/</code> is (correctly) never bundled, a publish box with its own,
    different key will produce an index the estate <b>cannot verify</b> &mdash; and <em>Check&nbsp;for&nbsp;Updates</em>
    then reports <em>&ldquo;the update index &hellip; is signed by a key this server does not trust.&rdquo;</em>
</div>

<h3>Getting the key right, once</h3>
<ol>
    <li>Decide the <b>authority</b> key is canonical (your primary licence server&rsquo;s
        <code>server-key.json</code>) &mdash; never re&#8209;key it, or you invalidate every customer lease.</li>
    <li>Copy that same <code>storage/license/server-key.json</code> onto your <b>publish box</b> and every
        <b>backup</b> (backups get it automatically at <a href="licensing#mirrors">pairing</a>).</li>
    <li><b>Stamp</b> its public key into <code>LicenseTrust</code> (Licence&nbsp;server &rarr; <em>Stamp public key</em>)
        so every bundle pins it. An empty pin means client installs have <em>no</em> trusted key and OTA can&rsquo;t
        run at all.</li>
    <li>Re&#8209;publish so the index is (re&#8209;)signed with the canonical key, and rebuild bundles so they carry
        the stamped pin.</li>
</ol>
<p>See <a href="licensing#key">Licensing &rarr; Looking after the signing key</a> for backups, permissions and
   rotation.</p>

<h2 id="console">The App Bundle console, tab by tab</h2>
<p>Owner&#8209;only, linked from <b>Platform&nbsp;Updates &rarr; App&nbsp;Bundle&nbsp;(owner)</b>. Everything about
   producing and shipping releases lives here.</p>
<ul>
    <li><b>Overview</b> &mdash; the current code version, the latest published release per edition, and the host
        connection state at a glance.</li>
    <li><b>Update host</b> &mdash; the FTP/SFTP connection and public URL. Locked by default and shown as
        <span class="badge bg-success">Connected</span> so you can&rsquo;t fat&#8209;finger a working config; click
        <em>Edit</em> to change it. <em>Test&nbsp;connection</em> also browses the remote folders.</li>
    <li><b>Publish (OTA)</b> &mdash; cut a release and push it to the host. Choose the edition (server / client /
        both), what to build (<em>Full&nbsp;+&nbsp;deltas</em> / <em>Deltas&nbsp;only</em> / <em>Full&nbsp;only</em>),
        and whether it goes <b>live</b> or <b>staged</b>. The page <b>locks with a live progress overlay</b> while it
        works and releases itself the moment it finishes.</li>
    <li><b>Download</b> &mdash; get a bundle to your machine: a <b>Full&nbsp;Installer</b> (fresh servers) or an
        <b>Updates&#8209;only</b> patch from a baseline you pick. Also locks with progress.</li>
    <li><b>Releases</b> &mdash; every published version with edition, Live/Staged badge, size, deltas and notes; flip
        staged&#8596;live or delete.</li>
</ul>

<h2 id="host">Setting up the update host (once)</h2>
<p>On <b>Update host</b>:</p>
<ol>
    <li><b>Protocol / Host / Port / Credentials</b> &mdash; FTP, FTPS or SFTP. The password is stored, never shown
        again; leave it blank when editing to keep it.</li>
    <li><b>Upload directory</b> &mdash; the absolute path bundles are written under (the engine creates
        <code>full/</code> and <code>delta/</code> inside it automatically).</li>
    <li><b>Public download URL</b> &mdash; the <b>https</b> address that serves that directory. Clients fetch from
        here; they never FTP. The <code>index.json</code> is served from its root.</li>
    <li><b>Test connection</b> for a stage&#8209;by&#8209;stage probe + folder listing, then <b>Save</b>.</li>
</ol>
<div class="alert alert-warning">
    OTA needs both a signing key (<a href="#keys">above</a>) <em>and</em> a reachable host. Web hosts often cap uploads
    (&ldquo;larger than 2&nbsp;MB not allowed&rdquo;) &mdash; which is exactly why OTA ships small deltas and full
    installers are downloaded, not pushed over the air.
</div>

<h2 id="publish">Publishing an OTA update</h2>
<ol>
    <li>Bump <code>core/Version.php</code> (<code>PLATFORM</code>) on the publish box &mdash; the single source of
        truth for the release number.</li>
    <li><b>Publish (OTA)</b> &rarr; set the version (defaults to next patch), edition, channel and notes; pick
        <em>Full&nbsp;+&nbsp;deltas</em>; leave <em>Make available over&#8209;the&#8209;air</em> on. <b>Build&nbsp;&amp;&nbsp;publish</b>.</li>
    <li>The engine builds the full bundle, builds a <b>delta</b> against each recent baseline (published releases
        <em>and</em> your local full builds), uploads full&rarr;<code>full/</code> and deltas&rarr;<code>delta/</code>,
        records them, then <b>re&#8209;signs and re&#8209;uploads <code>index.json</code></b>.</li>
    <li>Every install upgrades on its next <em>Check&nbsp;for&nbsp;Updates</em> &mdash; authorities immediately,
        customers once their licence checks out.</li>
</ol>
<p><b>Staged vs live:</b> turning OTA off uploads and records the release but leaves it <em>staged</em> &mdash; it is
   not offered until you flip it to <b>Live</b> on the Releases tab. Use it to pre&#8209;position a release, then go
   live deliberately.</p>
<div class="alert alert-warning">
    Publish deltas for <b>every edition your estate runs and from every recent version</b>. A version with no delta
    <em>from</em> a given install can&rsquo;t reach it over the air &mdash; that install is told it&rsquo;s a
    full&#8209;installer&#8209;only jump (see next).
</div>

<h2 id="deltaonly">Why OTA is delta&#8209;only</h2>
<p>A release changes a handful of files out of ~5,200; shipping the rest every time is waste, and most hosts refuse
   multi&#8209;MB uploads anyway. So over the air we deliver <b>only deltas</b> &mdash; the changed files plus the
   complete signed manifest, so the updater still knows exactly what to keep, add and remove. Tens of KB, not tens of
   MB.</p>
<p>If a newer version exists but has <b>no delta from your installed version</b>, OTA does <em>not</em> silently say
   &ldquo;up to date&rdquo; &mdash; it tells you it&rsquo;s available as a <b>full installer only</b>. Download that
   from <b>App&nbsp;Bundle &rarr; Download</b> and apply it with <b>Update&nbsp;Locally</b> (or Update&nbsp;via&nbsp;FTP
   for big files). Every full build is quietly recorded as a baseline, so future deltas can start from it.</p>

<h2 id="index">The signed index on the host</h2>
<p><code>index.json</code> is an Ed25519&#8209;signed (prefix <code>PPUX</code>) listing of every live release and the
   deltas available <em>from</em> each prior version, per channel and edition. It carries every download URL <b>inside
   the signature</b>, so switching hosts is tamper&#8209;proof: change the public URL and re&#8209;publish, and the
   whole estate follows on its next check &mdash; nothing for a customer to re&#8209;enter.</p>
<p>Two roles read it two ways:</p>
<ul>
    <li><b>Authorities</b> (your servers, server edition) read the index <b>directly</b> and pick their artifact.
        They are the vendor &mdash; not licence&#8209;gated.</li>
    <li><b>Customers</b> (client edition) never touch the host for the <em>decision</em>. They POST their version to a
        licence server&rsquo;s <code>/api/update/feed</code>; it confirms a genuine, active licence, reads the same
        host index, and returns a signed answer they verify offline.</li>
</ul>

<h2 id="check">What <em>Check for Updates</em> does</h2>
<p>The <b>Platform&nbsp;Updates</b> page runs a live, phased check &mdash; you watch each step:</p>
<ol>
    <li><b>Verifying this is a licensed copy</b> &mdash; entitlement is confirmed (authorities are the vendor and pass
        inherently).</li>
    <li><b>Reading the signed update host</b> &mdash; fetch the index and verify its signature against the pinned key.</li>
    <li><b>Finding the right update for this version</b> &mdash; select the delta from <em>your</em> installed
        version.</li>
    <li><b>Downloading &amp; verifying</b> &mdash; stream the delta, check size and SHA&#8209;256 (both inside the
        signature).</li>
    <li><b>Applying &amp; patching</b> &mdash; a <b>restore point</b> is written first, then the manifest is applied;
        <code>projects/</code>, <code>output/</code>, <code>storage/</code> and <code>config/</code> are never
        touched.</li>
</ol>
<p>Each outcome now reads back plainly &mdash; up to date, a delta is available, a full&#8209;only jump, the host is
   unreachable, or the index signature didn&rsquo;t verify &mdash; so you never get a vague &ldquo;nothing found.&rdquo;</p>

<h2>The Platform Updates page</h2>
<p>Everything an administrator needs, in foldable cards (folded on first load, your choices remembered):</p>
<ul>
    <li><b>Check for Updates</b> &mdash; the phased OTA flow above.</li>
    <li><b>Your data is never touched</b> &mdash; what an update refuses to overwrite.</li>
    <li><b>Server readiness</b> &mdash; PHP, ZIP and folder&#8209;writability preflight, with a pass/fix badge.</li>
    <li><b>Update Locally</b> &mdash; upload a <code>.zip</code>, review the exact plan, then install.</li>
    <li><b>Update via FTP</b> &mdash; for bundles above the upload limit: drop the zip into
        <code>storage/updates/incoming</code> and Refresh.</li>
    <li><b>Restore points</b> &mdash; every update snapshots what it changed; roll back in one click.</li>
</ul>

<h2 id="customer">What a customer sees &mdash; installing and updating</h2>

<p>Worth knowing exactly what lands in front of the person on the other end, because it is what
your support inbox will be about.</p>

<h3>Installing</h3>
<ol>
    <li><strong>Unzip and open <code>install.php</code>.</strong> A two-step wizard: connect the
        database, then create the administrator.</li>
    <li><strong>A completion screen confirms it worked</strong> and tells them what to do next &mdash;
        sign in, activate their licence (only shown on a licensed build), then build. It also reminds
        them to delete <code>install.php</code>.</li>
</ol>
<p class="text-muted">The installer refuses to run twice: once <code>config/database.php</code> exists,
any later visit is redirected into the app.</p>

<h3>Updating</h3>
<p>The Platform&nbsp;Updates page shows the whole sequence <em>before</em> they start &mdash; check,
verify, restore point, apply &mdash; then a live phased progress panel while it runs. A rollback
snapshot is taken automatically, and the page states plainly which paths an update can never touch
(<code>projects/</code>, <code>output/</code>, <code>storage/</code>,
<code>config/database.php</code>).</p>

<h3>If you ship protected builds</h3>
<div class="alert alert-info">
    <strong>Tell customers this once, in your release notes.</strong> A licence&#8209;protected build
    keeps its build engine encrypted, so the copy needs to reach your licence server:
    <ul class="mb-0 mt-2">
        <li><strong>Activate before the first build.</strong></li>
        <li><strong>Stay online after an update</strong> &mdash; it collects a fresh key automatically
            on its next hourly check&#8209;in.</li>
    </ul>
    <p class="mb-0 mt-2">Applications they have already built keep running either way. The bundle's
    <code>DEPLOY.txt</code> says the same thing, and the Updates page shows an on&#8209;screen note,
    but neither replaces a line in the release notes.</p>
</div>

<hr>

<h2 id="trouble">Troubleshooting</h2>
<table class="table table-sm">
    <thead><tr><th>Symptom</th><th>Cause &amp; fix</th></tr></thead>
    <tbody>
        <tr><td>Check says <em>&ldquo;signed by a key this server does not trust&rdquo;</em></td>
            <td><b>Split signing key.</b> The publish box signed the index with a different key than the estate trusts.
                Put the authority&rsquo;s <code>server-key.json</code> on the publish box, <a href="#keys">stamp</a> and
                re&#8209;publish.</td></tr>
        <tr><td>Check says <em>&ldquo;up to date&rdquo;</em> but you just published</td>
            <td>The index didn&rsquo;t refresh, or you published a different edition/channel. Re&#8209;publish (it
                re&#8209;signs <code>index.json</code>); confirm the Releases tab shows it <b>Live</b>.</td></tr>
        <tr><td>Check says <em>&ldquo;full installer only&rdquo;</em></td>
            <td>No delta exists <em>from</em> your version. Either publish that delta, or download the Full Installer
                and apply with Update&nbsp;Locally.</td></tr>
        <tr><td>Check says <em>&ldquo;online updates not configured (no trusted key)&rdquo;</em></td>
            <td>The bundle&rsquo;s <code>LicenseTrust</code> pin is empty. <a href="#keys">Stamp</a> the public key and
                rebuild.</td></tr>
        <tr><td>Check can&rsquo;t reach the index</td>
            <td>Wrong public URL or host offline. Fix the <b>Public download URL</b> on the Update host tab and
                confirm <code>&lt;url&gt;/index.json</code> loads in a browser.</td></tr>
    </tbody>
</table>

<h2>Safety &amp; separation</h2>
<ul>
    <li><b>Your data is never touched.</b> Updates replace platform code only; a restore point makes every install
        reversible.</li>
    <li><b>Clients can never become an authority.</b> The publisher, host config, delta builder and feed endpoint are
        stripped from client bundles &mdash; an absent capability can&rsquo;t be re&#8209;enabled by editing a role
        check.</li>
</ul>

<h2>Ops overrides</h2>
<p>Point one install at a specific authority or index without the database (staging, testing):</p>
<?= $c("PP_UPDATE_AUTHORITY=https://updates.example.com/public\nPP_UPDATE_INDEX_URL=https://updates.example.com/index.json\nPP_UPDATE_TRUST_KEY=<base64 ed25519 public key>\nPP_UPDATE_EDITION=server   # force the edition branch") ?>

<hr>
<p class="text-muted"><i class="bi bi-broadcast"></i> <strong>Want an update on every install without waiting for each admin to click Install?</strong>
   Publish the release here, then mark it a <b>critical security patch</b> from
   <a href="../advisories/">Push to Clients</a> — licensed installs apply it automatically (signed, restore‑point first).</p>
