<?php
/*
 * Owner/admin-only guide: Licensing.
 * Served by modules/Help/HelpController (topic = "licensing").
 * $c() prints an HTML-safe code block from a nowdoc so samples containing
 * <, > and & display literally.
 */
$c = static fn (string $s): string => '<pre><code>' . htmlspecialchars($s) . '</code></pre>';
?>

<h1><i class="bi bi-shield-check"></i> Licensing</h1>
<p class="lead">How <?= htmlspecialchars(\PpPhp\Core\Brand::name()) ?> licenses itself: one licence server you run, signed 24&#8209;hour leases
that rotate daily, and a lapse that locks <em>your console</em> without ever touching a
customer's live application.</p>

<div class="alert alert-info">
    <strong>Who is this for?</strong> Owners and administrators. Hidden from regular users.
    Issuing licences is owner-only; activating this installation is administrator-level.
</div>

<h2>Contents</h2>
<ul>
    <li><a href="#start">Getting started &mdash; the two roles, in order</a></li>
    <li><a href="#model">The model &mdash; entitlements, seats, leases</a></li>
    <li><a href="#setup">Vendor setup &mdash; four steps, once</a></li>
    <li><a href="#mirrors">The primary and its backup servers</a></li>
    <li><a href="#editions">Two editions &mdash; server and client</a></li>
    <li><a href="#issue">Issuing a licence and sending the code</a></li>
    <li><a href="#customer">What the customer does</a></li>
    <li><a href="#enforce">What happens when a licence lapses</a></li>
    <li><a href="#attacks">What it defends against &mdash; and what it doesn't</a></li>
    <li><a href="#hardening">Anti-tamper &mdash; integrity, protected builds, the strict lock</a></li>
    <li><a href="#installs">The Installations view &mdash; seeing your estate</a></li>
    <li><a href="#key">Looking after the signing key</a></li>
    <li><a href="#owner">The owner account on client installs</a></li>
    <li><a href="#runbook">Operations runbook &mdash; day-to-day, step by step</a></li>
    <li><a href="#api">The public API</a></li>
    <li><a href="#trouble">Troubleshooting</a></li>
</ul>

<hr>

<h2 id="start">Getting started &mdash; the two roles, in order</h2>

<p>The single most important thing to get straight: <strong>a licence server is never
&ldquo;activated.&rdquo;</strong> There are two kinds of <?= htmlspecialchars(\PpPhp\Core\Brand::name()) ?> install, and each is one or the other:</p>

<table class="table table-sm">
    <thead><tr><th></th><th>Licence server</th><th>Customer install</th></tr></thead>
    <tbody>
        <tr><td>Who runs it</td><td><strong>You</strong> (the vendor)</td><td>Your customers</td></tr>
        <tr><td>Bundle</td><td>Server edition</td><td>Client edition</td></tr>
        <tr><td>Its job</td><td><em>Issues</em> licences, answers activations</td><td><em>Activates</em> against your licence server</td></tr>
        <tr><td>Gets activated?</td><td><strong>No &mdash; never</strong></td><td>Yes, with a code you give it</td></tr>
        <tr><td>Needs a licence?</td><td>No</td><td>Yes</td></tr>
    </tbody>
</table>

<p>Trying to activate a licence server against itself is the most common first mistake &mdash; it is
the vending machine trying to buy its own product. The <em>Activate this installation</em> page is
only ever for a <strong>customer</strong> install.</p>

<div class="alert alert-info">
    <strong>An unstamped install never needs activating.</strong> Your own licence servers are built
    without stamping (enforcement off), so their Licence page says &ldquo;Licensing not enforced &mdash;
    nothing here will ever lock.&rdquo; That is correct; ignore it. Enforcement only exists in the
    <em>stamped client bundles</em> you ship to customers.
</div>

<h3>First run &mdash; do these in order</h3>
<ol>
    <li><strong>Stand up one primary licence server.</strong> Install the <em>server</em> edition at your
        licence-server address (e.g. <code>https://ppphp.ppradhan.com</code>). On its
        <strong>Licence server</strong> page: create the signing key, set <em>Primary</em> to that same
        address. Do not activate it. (Skip backup servers for now &mdash; add them once this works.)</li>
    <li><strong>Build the client bundle you will sell.</strong> On the machine that holds your signing
        key, set <em>Primary</em> to your licence-server address and <strong>Stamp into source</strong>,
        then download the <em>client</em> edition. Stamping is what turns enforcement on for customers.</li>
    <li><strong>Issue a licence and activate a client.</strong> On the licence server, <em>Issue a
        licence</em> and copy its activation code. Install the client bundle somewhere, open
        <em>Licence &rarr; Activate</em>, and paste the code. It activates against your server. &#10003;</li>
</ol>

<div class="alert alert-warning">
    <strong>Testing on one machine?</strong> The activation code embeds whatever <em>Primary</em> was
    when you issued it. If your licence server is running locally, set Primary to that local URL (e.g.
    <code>http://localhost/PpPhp/public</code>) and turn <em>Refuse plain HTTP</em> off before issuing,
    or the code will point at your production domain and the client will get a 404. Switch Primary back
    to your real HTTPS address before issuing codes for real customers.
</div>

<hr>

<h2 id="model">The model &mdash; entitlements, seats, leases</h2>
<p>Three ideas, deliberately separated:</p>
<table class="table table-sm">
    <thead><tr><th>Thing</th><th>What it is</th><th>Lives in</th></tr></thead>
    <tbody>
        <tr><td><strong>Licence</strong></td>
            <td>An entitlement sold to a holder: a plan, a <em>seat limit</em>, an optional end
                date. It is <strong>not</strong> bound to a machine.</td>
            <td><code>pp_licenses</code></td></tr>
        <tr><td><strong>Instance</strong></td>
            <td>One activated installation using one seat. Identified by an id and a secret it
                was given at activation.</td>
            <td><code>pp_license_instances</code></td></tr>
        <tr><td><strong>Lease</strong></td>
            <td>A signed permission slip valid for 24&nbsp;hours. Renewed daily, verified
                offline.</td>
            <td>the customer's <code>storage/license/lease.json</code></td></tr>
    </tbody>
</table>

<p>One licence therefore covers <em>N</em> installations, and the customer can move them between
servers freely. Seat counting happens on your server, where a client cannot argue with it.</p>

<h3>Why a daily lease instead of a permanent key</h3>
<p>A permanent key is a single string that works forever, everywhere, the moment it leaks. A
lease expires by itself, so a leaked one is worth at most a day &mdash; and because every lease is
signed, a customer's install can check it without asking you, which means <strong>your licence
server going down never takes their site with it</strong>.</p>

<h3>Why not bind to an IP address</h3>
<p>Because IP addresses change: providers rotate them, customers move hosts, load balancers add
them. Hard IP binding is the classic licensing mistake &mdash; it generates support tickets
without stopping anyone determined. <?= htmlspecialchars(\PpPhp\Core\Brand::name()) ?> records the IP for the audit trail, and uses a
<em>fuzzy fingerprint</em> (hostname, install path, OS, PHP version, server software, database
name &mdash; all hashed) purely as a signal. Traits drifting is normal and allowed; everything
changing at once is flagged for you to look at, not blocked, because a restore from backup and a
copied install look exactly alike.</p>

<hr>

<h2 id="setup">Vendor setup &mdash; four steps, once</h2>
<p>Go to <strong>Licence server</strong> (owner menu). On a fresh install it has no key and does
nothing, which is the correct state for every copy except yours.</p>
<ol>
    <li><strong>Create the signing key.</strong> Written to
        <code>storage/license/server-key.json</code>, never the database, never a bundle.
        Back it up immediately &mdash; see <a href="#key">below</a>.</li>
    <li><strong>Set the addresses</strong> installations should call &mdash; the primary
        (<code>https://ppphp.ppradhan.com</code>) and any backup servers. See below.</li>
    <li><strong>Stamp the public key into the source.</strong> This writes it into
        <code>core/Licensing/LicenseTrust.php</code> so it travels inside every bundle you build.
        Until you do this, <em>enforcement is off everywhere</em> &mdash; which is exactly what you
        want while developing.</li>
    <li><strong>Build a bundle</strong> as usual. The stamped key ships with it; the signing key
        does not (the packager aborts the build if it ever would).</li>
</ol>

<div class="alert alert-warning">
    <strong>Stamping is what turns licensing on.</strong> An unstamped copy accepts any activation
    code and never locks. A stamped copy accepts only codes signed by your key. There is no
    separate on/off switch to forget.
</div>

<hr>

<h2 id="mirrors">The primary and its backup servers</h2>
<p>The licence service lives permanently at <code>https://ppphp.ppradhan.com</code>. You can list
<strong>backup servers</strong> alongside it on the Licence server page; clients try them in order
when the primary does not answer.</p>

<h3>How clients learn the list</h3>
<p>Two ways, both tamper-proof:</p>
<ul>
    <li>The <strong>activation code</strong> carries it, so even a first activation can fail over.</li>
    <li>Every <strong>signed lease</strong> carries it, so the list refreshes daily &mdash; inside the
        signature, where nobody can redirect it.</li>
</ul>
<p>That second one has a useful consequence: <strong>changing the primary address re-points every
installation you have within 24 hours</strong>, with no customer action. It also means a typo here is
a typo everywhere, which is why the field is validated and shown back to you as an ordered list.</p>

<h3>Setting up a backup &mdash; automatic pairing (no manual copying)</h3>
<p>A backup is a <?= htmlspecialchars(\PpPhp\Core\Brand::name()) ?> install built as the <strong>backup server</strong> edition (bundle page &rarr;
Server edition &rarr; &ldquo;Backup licence server&rdquo;, with the main's address). You never copy the
signing key or the database by hand. Instead:</p>
<ol>
    <li>On the <strong>main</strong>: Licence server &rarr; Servers &amp; keys &rarr; <em>Backup servers</em>
        &rarr; <strong>Generate pairing code</strong>. It is one-time and short-lived.</li>
    <li>On the <strong>backup</strong>: Licence server &rarr; &ldquo;This is a backup server&rdquo; &rarr;
        enter the main's address and the pairing code &rarr; <strong>Pair</strong>.</li>
</ol>
<p>Pairing pulls everything the backup needs in one authenticated call &mdash; the signing key, the owner
password, the configuration, and a first copy of the licence data. From then on the backup re-pulls a
fresh snapshot <strong>every 15 minutes</strong> on its own (no cron needed). This is why a backup never
has the credential-mismatch problem a hand-built one does: it adopts the main's identity during pairing.</p>
<div class="alert alert-info">
    <strong>The signing key does cross the wire once, at pairing.</strong> That is inherent to any HA
    setup with offline-verifiable leases &mdash; a second server that can sign must hold the key. Pairing
    therefore requires HTTPS and a one-time code, and the code is single-use. After pairing, the ongoing
    15-minute sync never re-sends the key.
</div>
<p>Remember to add the backup's address to the <strong>Backup servers</strong> list (Service addresses)
too, so customers' installs fail over to it when the main is unreachable.</p>
<div class="alert alert-info">
    <strong>Failing over is for reads, not for issuing.</strong> A backup answers customers' renewals
    from its synced copy, but it is <em>read-only</em> &mdash; it refuses to issue licences, because the
    main is the single writer and the two must never disagree about seats. Issue on the main; the backup
    picks the new licence up at its next 15-minute sync.
</div>

<h3>Failure behaviour, in order</h3>
<ol>
    <li>Primary answers &rarr; done, one request.</li>
    <li>Primary unreachable &rarr; next address; whichever answers is remembered and tried first next
        time, so an outage costs one slow page load, not one per visit.</li>
    <li>Nobody answers &rarr; retry at most every 15 minutes, and keep working on the 14-day grace.</li>
    <li>A server that answers <em>"no such licence"</em> ends it there. That is an authoritative
        answer; asking a mirror the same question would be a slower way to hear the same thing.</li>
</ol>

<hr>

<h2 id="editions">Two editions &mdash; server and client</h2>
<p>The bundle page offers a choice, and <strong>client is the default</strong>:</p>
<table class="table table-sm">
    <thead><tr><th></th><th>Client edition</th><th>Server edition</th></tr></thead>
    <tbody>
        <tr><td>Who runs it</td><td>Your customers</td><td>You &mdash; primary and backups</td></tr>
        <tr><td>Build apps, deploy, update</td><td>Yes</td><td>Yes</td></tr>
        <tr><td>Activate against a licence server</td><td>Yes</td><td>Yes</td></tr>
        <tr><td>Issue licences / hold a signing key</td><td><strong>No code for it</strong></td><td>Yes</td></tr>
        <tr><td>Serve <code>/api/license/*</code></td><td><strong>No</strong></td><td>Yes</td></tr>
        <tr><td>Build release bundles</td><td><strong>No</strong></td><td>Yes</td></tr>
    </tbody>
</table>
<p>The client edition ships without <code>LicenseServer</code>, <code>LicenseKeys</code>,
<code>LicenseConfig</code>, the licence-server controller and view, the API controller, and the
packager &mdash; and their routes are stripped from the module manifests in the same pass, so nothing
points at a file that is not there. That last part is checked at build time.</p>
<p>The point is not disk space. It is that a customer's install has <strong>no code path</strong> to
becoming a licence authority. Capability that is absent cannot be misconfigured, and cannot be
re-enabled by editing a role check.</p>

<hr>

<h2 id="issue">Issuing a licence and sending the code</h2>
<p>Fill in the holder, seat count and (optionally) an end date, then press <strong>Issue</strong>.
You get back a licence key like <code>PPP-K4M2X-9WQ7B-TR5NC-8HJ3D</code> and, more usefully, an
<strong>activation code</strong>:</p>
<?= $c('PPK1.eyJ1IjoiaHR0cHM6Ly9saWNlbnNlLnlvdXJkb21haW4uY29tIiwiayI6Ii4uLiIsImwiOiJQUFAtLi4uIn0') ?>
<p>That single string carries the server address, your public key and the licence key, so the
customer pastes one thing instead of three. Send it in their welcome email. The same code works
for every seat on the licence.</p>

<p>Use <strong>Manage</strong> on any licence to change the seat count, suspend it for
non&#8209;payment, set an end date, see every installation using it, and revoke one.</p>

<hr>

<h2 id="customer">What the customer does</h2>
<p>They open <strong>Licence</strong> from the account menu, paste the code, and press Activate.
That is the whole flow. Behind it:</p>
<ol>
    <li>Their install calls <code>/api/license/activate</code> once, and receives an instance id,
        a secret, and a first lease.</li>
    <li>Every day, a page load triggers a background renewal &mdash; a new signed lease with a new
        serial. Nothing is scheduled; no cron is required.</li>
    <li>If your server is unreachable, the attempt is retried at most every 15 minutes and the
        install keeps working on its <strong>14&#8209;day grace period</strong>, counted from the
        last <em>successful</em> contact.</li>
</ol>
<p>A never-activated install gets the same 14 days from first run, so somebody unzipping a release
at 2am can finish setting up before anything nags them.</p>

<p>Moving servers? <strong>Release this seat</strong> on the old one, then activate on the new one
with the same code. Re-activating the <em>same</em> machine (after a restore, say) reclaims its own
seat rather than burning a second one.</p>

<hr>

<h2 id="enforce">What happens when a licence lapses</h2>
<p>Exactly one thing: <strong>the design console locks.</strong></p>
<div class="row g-3 my-2">
    <div class="col-md-6">
        <div class="p-3 border rounded h-100">
            <h5 class="text-danger"><i class="bi bi-lock"></i> Stops</h5>
            <ul class="mb-0">
                <li>Designing, building, deploying</li>
                <li>Every console page except sign-in and the licence page</li>
                <li>Console API routes (they answer <code>402</code> with JSON)</li>
            </ul>
        </div>
    </div>
    <div class="col-md-6">
        <div class="p-3 border rounded h-100">
            <h5 class="text-success"><i class="bi bi-check-circle"></i> Keeps running</h5>
            <ul class="mb-0">
                <li>Every application already built</li>
                <li>Their users, orders, forms, data &mdash; untouched</li>
                <li>Nothing is deleted; unlocking is instant</li>
            </ul>
        </div>
    </div>
</div>
<p>This is not a courtesy, it is structural: <code>copyCore()</code> ships an explicit list of
runtime classes into a build and <code>core/Licensing</code> is not on it. A generated application
contains no licensing code, so it <em>cannot</em> be switched off from here even by mistake.</p>

<hr>

<h2 id="attacks">What it defends against &mdash; and what it doesn't</h2>
<table class="table table-sm">
    <thead><tr><th>Attempt</th><th>Result</th></tr></thead>
    <tbody>
        <tr><td>Forge a lease</td><td>Rejected &mdash; Ed25519 signature fails.</td></tr>
        <tr><td>Edit the expiry inside a real lease</td><td>Rejected &mdash; signature covers the payload.</td></tr>
        <tr><td>Replay an old lease after being revoked</td><td>Rejected &mdash; serial must never go backwards.</td></tr>
        <tr><td>Wind the server clock back to extend grace</td><td>No effect &mdash; a high-water mark of time seen is kept.</td></tr>
        <tr><td>Point the install at a fake licence server</td><td>Rejected on a stamped build &mdash; the public key is pinned in source.</td></tr>
        <tr><td>Share one licence across ten servers</td><td>Refused at activation &mdash; seats are counted server-side.</td></tr>
        <tr><td>Guess licence keys against the API</td><td>Rate-limited: 30 failures per IP per 10 minutes, indexed so it stays fast as the log grows.</td></tr>
        <tr><td>Send a licence key over plain HTTP</td><td>Refused &mdash; activation requires TLS unless you switch that off.</td></tr>
        <tr><td>Hand the seat back, then keep renewing</td><td>Refused &mdash; a released instance gets a signed revocation, not a lease.</td></tr>
        <tr><td>Run past the licence end date on grace</td><td>No &mdash; grace covers an unreachable server, not an expired term.</td></tr>
        <tr><td>Point an install at a mirror you control</td><td>Rejected &mdash; a mirror must sign with the vendor's key, and the pinned key is in the source.</td></tr>
        <tr><td>Copy an activated install to a second machine</td><td>Works, but is flagged <em>clone-suspected</em> in your console, with the drifted traits listed. Revoke it in one click.</td></tr>
        <tr><td>Read the owner password from a bundle or lease</td><td>Only a bcrypt hash is there &mdash; no plaintext to read.</td></tr>
        <tr><td>Edit out the licence check in the PHP source</td>
            <td><strong>Detected.</strong> The edited file no longer matches the signed integrity
                manifest, so the copy locks itself and reports in. In a <em>protected</em> build there
                is no check to edit &mdash; see <a href="#hardening">Anti-tamper</a>.</td></tr>
        <tr><td>Delete the integrity manifest instead</td>
            <td>Fails closed &mdash; a bundle without a valid manifest is treated as modified.</td></tr>
        <tr><td>Re-sign the manifest to match edited files</td>
            <td>Rejected &mdash; that needs your private signing key, which never leaves your server.</td></tr>
        <tr><td>Run a protected build without a licence</td>
            <td>Impossible &mdash; the build engine is encrypted and the key only arrives in a valid lease.</td></tr>
    </tbody>
</table>

<div class="alert alert-warning">
    <strong>The one honest limit.</strong> <?= htmlspecialchars(\PpPhp\Core\Brand::name()) ?> ships as readable PHP, so a determined expert with
    filesystem access to their own server can still unpick a standard build &mdash; they must now
    defeat the integrity manifest in several places at once, and the attempt is self-reporting, but
    it is not impossible. Two things close that gap properly: ship a
    <strong><a href="#hardening">protected build</a></strong> (the build engine is encrypted, so there
    is nothing to bypass), and/or run the source through a commercial encoder such as ionCube or
    SourceGuardian, which compiles PHP to bytecode.
</div>

<hr>

<h2 id="hardening">Anti-tamper &mdash; integrity, protected builds, the strict lock</h2>

<p>Three layers sit on top of ordinary licensing. The first two are automatic; the third is a
choice you make per bundle.</p>

<h3>1. Signed integrity manifest &mdash; every bundle</h3>
<p>When you build a bundle, <?= htmlspecialchars(\PpPhp\Core\Brand::name()) ?> records the SHA-256 of each enforcement-critical file
(<code>LicenseGate</code>, <code>LicenseClient</code>, <code>LicenseProtocol</code>,
<code>LicenseTrust</code>, <code>CodeIntegrity</code>, <code>ProtectedCode</code>,
<code>Fingerprint</code> and <code>public/index.php</code>) into
<code>core/licence-integrity.json</code> and <strong>signs it with your licence signing key</strong>.
Every client re-hashes those files and compares.</p>
<ul>
    <li><strong>Edit any of them</strong> &rarr; hashes differ &rarr; the copy locks and names the files.</li>
    <li><strong>Delete the manifest</strong> &rarr; fails closed; a real bundle always ships one.</li>
    <li><strong>Forge a manifest</strong> &rarr; rejected; signing needs your private key.</li>
</ul>
<p class="text-muted">A working source tree (no <code>release.json</code>) is deliberately exempt, so
your own development copy and any install mid-update never lock themselves.</p>

<h3>2. The strict lock screen</h3>
<p>A tampered copy does not get the normal licence page. Every route &mdash; including the API &mdash;
serves one self-contained notice: inline HTML and CSS with no dependency on the app's stylesheets or
scripts, so nothing about it can be disabled by editing the application around it. It returns
<code>402</code> with <code>no-store</code>, carries your support address and the list of modified
files, and offers <em>no activation form</em>, because activating cannot repair edited files. The fix
is to restore the original release.</p>

<h3>3. Protected builds &mdash; the strongest option</h3>
<p>Every conventional licence check is ultimately an <code>if</code> somebody can invert. A protected
build removes the <code>if</code> entirely:</p>
<ul>
    <li>The <strong>build engine ships encrypted</strong> (AES-256-GCM) inside the bundle.</li>
    <li>The decryption key is <strong>never in the bundle</strong>. It travels inside the customer's
        signed licence lease.</li>
    <li>No valid licence &rarr; no key &rarr; the code is ciphertext. There is nothing to bypass, and
        patching a check achieves nothing because the plaintext was never present.</li>
</ul>
<p>Turn it on with <strong>Protected build</strong> on the App&nbsp;Bundle page. It applies to
<strong>customer (client) bundles only</strong> &mdash; sealing your own servers' build engine would
strand you if a lease ever lapsed. Each protected build mints a <em>fresh</em> key, so a key captured
from an earlier release opens nothing new.</p>
<div class="alert alert-info">
    <strong>What this means for customers:</strong> they must reach your licence server at least once
    after updating before they can build. Their already-built applications are unaffected, as always.
    The bundle's <code>DEPLOY.txt</code> and the Platform&nbsp;Updates page both say so on screen &mdash;
    but put it in your release notes too. See
    <a href="updates#customer">what a customer sees</a>.
</div>
<p class="text-muted">Residual risk, stated plainly: a customer who <em>was</em> licensed can capture
the key or the decrypted code while it is in memory. Rotating the key per release bounds that to the
release they legitimately held.</p>

<hr>

<h2 id="installs">The Installations view &mdash; seeing your estate</h2>

<p><strong>Licence server &rarr; Installations</strong> lists every copy of <?= htmlspecialchars(\PpPhp\Core\Brand::name()) ?> running against your
licence server. Each install reports in every hour, so this is close to live.</p>

<table class="table table-sm">
    <thead><tr><th>Column</th><th>What it tells you</th></tr></thead>
    <tbody>
        <tr><td><strong>Where</strong></td><td>The domain the copy answers on, its server IP, and the IP the
            console was last used from. A licence appearing on an unexpected domain shows up here first.</td></tr>
        <tr><td><strong>Administrator</strong></td><td>The admin email configured <em>inside</em> that
            installation &mdash; a real person to contact &mdash; plus the buyer on the licence record.</td></tr>
        <tr><td><strong>Licence</strong></td><td>Key prefix, plan, seat limit, licence status, and the <?= htmlspecialchars(\PpPhp\Core\Brand::name()) ?>
            and PHP versions that install is running.</td></tr>
        <tr><td><strong>Files</strong></td><td><em>verified</em> = the copy matches the release you signed.
            <em>modified</em> = it does not, and it has already locked itself. A red flag adds the reason.</td></tr>
        <tr><td><strong>Last seen</strong></td><td>Time of the last successful check-in. Anything silent for
            more than 48&nbsp;hours is marked, which usually means an unreachable or retired install.</td></tr>
    </tbody>
</table>

<p>The search box filters across every field at once &mdash; domain, IP, email, holder or licence key.
<strong>Revoke</strong> frees the seat and tells that install to stop at its next check-in.</p>

<div class="alert alert-secondary">
    <strong>Flagged never means blocked.</strong> A flag is information for you, not an automatic
    penalty. A restore from backup looks identical to a clone, so <?= htmlspecialchars(\PpPhp\Core\Brand::name()) ?> records it and lets you judge
    rather than locking out a paying customer on a guess. Tampering is the one exception: a copy whose
    files have been edited locks <em>itself</em>, without waiting for you.
</div>

<div class="alert alert-secondary">
    <strong>What is collected, and what is not.</strong> Only what identifies the <em>installation</em>:
    domain, IP addresses, the administrator's email, PHP and app version, and whether the program files
    still match. Never any of their end-user data, customer records, or anything about the applications
    they build. Say so in your terms of service.
</div>

<hr>

<h2 id="key">Looking after the signing key</h2>
<p><code>storage/license/server-key.json</code> is the one file that matters.</p>
<ul>
    <li><strong>Back it up now, offline.</strong> If it is lost, no existing licence can be
        renewed and every customer needs re-activating against a new key and a new build.</li>
    <li><strong>It never leaves the server.</strong> Not in the database, not in a bundle &mdash;
        <code>PlatformPackager</code> lists <code>storage/license/</code> as a forbidden entry and
        aborts the build rather than ship it.</li>
    <li><strong>Updates never touch it.</strong> <code>storage/</code> is a protected path in
        <code>PlatformUpdater</code>, so applying a release leaves keys and leases alone.</li>
    <li><strong>Check permissions.</strong> The page warns if the file is group/world readable;
        <code>chmod 600</code> it if your host allows.</li>
</ul>

<h3 id="key-estate">One key across the whole estate &mdash; including the machine you publish from</h3>
<p>There is exactly <strong>one</strong> signing key for your infrastructure, and <em>every</em> machine that
   <strong>signs</strong> must hold that same <code>server-key.json</code>:</p>
<ul>
    <li>the <strong>primary</strong> licence server signs leases with it,</li>
    <li>each <strong>backup</strong> gets it automatically at <a href="#mirrors">pairing</a>,</li>
    <li>and &mdash; easy to miss &mdash; the <strong>machine you publish updates from</strong> (your dev box)
        signs the update <code>index.json</code> with it. If that box has a <em>different</em> key, the estate
        can&rsquo;t verify the index and <em>Check&nbsp;for&nbsp;Updates</em> silently finds nothing.</li>
</ul>
<div class="alert alert-danger">
    <strong>The most common OTA failure is a split key.</strong> Because <code>storage/</code> is (correctly)
    excluded from every bundle, a fresh install that later &ldquo;creates a signing key&rdquo; ends up with a
    <em>different</em> key from your publish box. Fix: put the authority&rsquo;s
    <code>storage/license/server-key.json</code> on the publish machine too, then <a href="#setup">stamp</a>
    that public key into <code>LicenseTrust</code> and rebuild. One key signs both leases <em>and</em> the update
    index; one pinned public key verifies both. See <a href="updates#keys">Online&nbsp;Updates &rarr; The signing key</a>.
</div>

<hr>

<h2 id="owner">The owner account on client installs</h2>
<p>The hidden owner account (<code>admin-ppradhan</code>) exists on every installation, customers'
included, so you can reach it for support. You set its password in one place &mdash; <strong>Licence
server &rarr; Owner account password</strong> &mdash; and it propagates everywhere.</p>

<h3>The one rule</h3>
<div class="alert alert-danger">
    <strong>Only the hash moves. Never the plaintext.</strong> You type the password; the bundle, the
    lease and every client's database carry its bcrypt hash and nothing else. A leaked bundle or a
    captured lease yields a hash of a strong password &mdash; expensive to crack, and rotated out from
    under an attacker within a day.
</div>

<h3>How it propagates</h3>
<ul>
    <li><strong>New installs</strong> &mdash; the hash ships in <code>config/owner-seed.json</code>
        inside the bundle and seeds the account on first run.</li>
    <li><strong>Running clients</strong> &mdash; the hash rides inside the signed lease (<code>opw</code>
        claim) and is applied on each daily renewal, so a change reaches the whole estate within a day
        (comfortably inside 72 hours). Updates deliberately do <em>not</em> carry it &mdash; the lease
        channel is authenticated and automatic, and <code>config/</code> is left untouched by updates
        for the same reason your signing key survives them.</li>
</ul>

<h3>Before you had configured one</h3>
<p>There is no built-in default password any more &mdash; an identical password on every install was a
shared-secret waiting to leak. Until you set one, a fresh install mints a random password and writes it
to <code>storage/owner-initial-password.txt</code> (readable only by the owner). Set a real one and that
file stops being used.</p>

<h3>Three ways to choose owner access</h3>
<p>On the Licence server page you pick one of three modes:</p>
<table class="table table-sm">
    <thead><tr><th></th><th>One shared password</th><th>Unique per install</th><th>Access tokens only</th></tr></thead>
    <tbody>
        <tr><td>What it is</td><td>One password for every install.</td>
            <td>A different password per install.</td>
            <td><strong>No password anywhere.</strong> You sign in with a signed token.</td></tr>
        <tr><td>What's on the client</td><td>A bcrypt hash of your password.</td>
            <td>A bcrypt hash of a random 120-bit password.</td>
            <td><strong>Nothing.</strong> Only your public key (already there for licences).</td></tr>
        <tr><td>How you log into install X</td><td>Type the shared password.</td>
            <td>Reveal its password (re-derived live).</td>
            <td>Mint a token for X, paste it as the password.</td></tr>
        <tr><td>If X is fully compromised</td><td>Rotate everywhere.</td>
            <td>Only X is affected.</td>
            <td>Nothing to steal &mdash; there is no owner secret on X.</td></tr>
    </tbody>
</table>

<h3>Access tokens &mdash; no secret on the client at all</h3>
<p>This is the answer to "can we keep the password off the customer's box entirely?" Yes &mdash; by not
having a password there. In this mode an install carries <strong>no owner credential</strong>, not even
a hash. To sign in as the owner you mint a short-lived <em>access token</em> from the licence server
(<strong>Manage</strong> &rarr; the key button on an install, or a bootstrap token for one that hasn't
activated), and paste it in place of the password.</p>
<?= $c('token = sign(your signing key, { install id, expiry })   →   PPT1.<payload>.<signature>') ?>
<ul>
    <li><strong>Verified offline</strong> &mdash; the install checks the signature against the public key
        it already pins. No call to your server at login, so it works with no network.</li>
    <li><strong>Works on a locked box</strong> &mdash; token login does not depend on a valid licence, so
        you can get into an install whose licence has lapsed precisely to fix it. This is the whole point
        of break-glass access, and it is why verifying a password against your server at login time would
        be worse: that fails exactly when you need it.</li>
    <li><strong>Bound &amp; short-lived</strong> &mdash; a token names one install and an expiry (15 min by
        default). It won't open a different box, and a captured one dies quickly.</li>
    <li><strong>Unforgeable</strong> &mdash; only the signing-key holder can mint one; the client only ever
        holds the public half.</li>
</ul>
<p>Every mint is written to the licence audit trail. Note that a live token is a bearer credential for its
short window &mdash; treat it like the temporary key it is.</p>

<h3>How per-install passwords work &mdash; nothing is stored</h3>
<p>The clever bit, and the answer to "how would you ever know each one": you don't store them and you
don't look them up. Each install's password is <em>derived</em>:</p>
<?= $c('password(install) = KDF(your signing key, install id, generation)') ?>
<ul>
    <li><strong>Deterministic</strong> &mdash; the same install always derives the same password, so
        <em>Reveal</em> recomputes it on the spot. Even if the licence database were wiped, you could
        still derive any install's password from the signing key.</li>
    <li><strong>Nothing at rest</strong> &mdash; there is no password table to breach. The client holds
        only a bcrypt hash (delivered in its signed lease); the server holds only the signing key it
        already guards.</li>
    <li><strong>Isolated</strong> &mdash; fully compromising one install yields a bcrypt hash of that
        one password. It reveals neither your signing key nor any other install's password.</li>
    <li><strong>Rotatable</strong> &mdash; <em>Rotate all per-install passwords</em> bumps a generation
        counter; every install adopts a fresh password on its next daily lease, nothing to redistribute
        by hand.</li>
</ul>
<p>The master is your <strong>licence signing key</strong> &mdash; the same one-file secret you already
back up, cryptographically separated so it cannot be worked backwards from a lease. There is no second
secret to manage, and the derivation code (and that key) never ship to a client &mdash; a client that
could derive passwords would defeat the whole point.</p>

<h3>Whichever mode, one rule holds</h3>
<ul>
    <li>This is a <strong>vendor account on customer machines, hidden from their own admins</strong>.
        That is a legitimate support/break-glass pattern, but the honest default is to disclose it in
        your terms of service.</li>
    <li>No plaintext is ever stored, bundled or transmitted &mdash; only bcrypt hashes, and in
        <em>access-token</em> mode not even those. In shared mode choose a strong password (its hash is
        offline-crackable from a bundle). Per-install and access-token modes are the stronger defaults for
        a real customer estate &mdash; per-install if you want a standing password to reveal, access-token
        if you want nothing on the client at all.</li>
</ul>

<hr>

<h2 id="runbook">Operations runbook &mdash; day-to-day, step by step</h2>
<p>Once the primary and its backup are live, everything below is routine. Each task is a short,
ordered checklist you can follow without re-reading the theory above. <strong>The golden rule that
shapes all of them: you never fresh-install a live server again.</strong> New features and fixes
arrive as <em>updates</em>, so the licence data and settings on that host are never lost.</p>

<div class="alert alert-warning">
    <strong>The one URL gotcha, learned the hard way.</strong> <?= htmlspecialchars(\PpPhp\Core\Brand::name()) ?> runs from the
    <code>/public</code> folder, so a server's address in any licence field &mdash; the main address a
    backup pairs to, the primary/backup addresses customers fail over to &mdash; must include it:
    <code>https://ppphp.ppradhan.com/public</code>, not <code>https://ppphp.ppradhan.com</code>.
    Leaving <code>/public</code> off is the usual cause of a <em>404</em> or &ldquo;no licence server
    here&rdquo; when pairing or activating.
</div>

<h3>1. Cut and push an update (the way you will do it most often)</h3>
<p>Never delete or re-install. Build a new bundle and push it over the existing install; the updater
writes only the files that changed and keeps <code>config/</code>, <code>storage/</code> and the
database untouched.</p>
<ol>
    <li>On this development copy, on the <strong>App bundle</strong> page, set a <strong>Release
        version</strong> higher than what the servers run (the field pre-fills the next patch). Leave
        <em>&ldquo;Also set this development copy to that version&rdquo;</em> ticked so your dev build and
        your servers stay in step.</li>
    <li>Pick the edition that matches the target: <strong>Server</strong> for your own primary/backup
        hosts, <strong>Client</strong> for a customer&rsquo;s install.</li>
    <li>Download the bundle, then on the target: <strong>Settings &rarr; Updates</strong> &rarr; upload
        it (or drop it into <code>storage/updates/incoming/</code> and use <em>Update via FTP</em> if it
        is larger than the host&rsquo;s upload limit) &rarr; <strong>Inspect</strong> &rarr;
        <strong>Install</strong>.</li>
    <li>A restore point is taken automatically before anything is written. If the far side reports
        &ldquo;same version&rdquo; and does nothing, the release number was not bumped in step 1.</li>
</ol>
<div class="alert alert-info">
    <strong>Why the version matters.</strong> An install decides a bundle is a real update by comparing
    its release version with what it already runs. Same number &rarr; no-op. That is the single most
    common reason an update &ldquo;isn&rsquo;t accepted.&rdquo;
</div>

<h3>2. Add or re-pair a backup server</h3>
<ol>
    <li>Build a <strong>Server</strong> bundle with the <strong>Backup licence server</strong> option and
        the main&rsquo;s address (with <code>/public</code>). Install it on the backup host.</li>
    <li>On the <strong>main</strong>: Licence server &rarr; Servers &amp; keys &rarr; Backup servers &rarr;
        <strong>Generate pairing code</strong> (one-time, short-lived).</li>
    <li>On the <strong>backup</strong>: Licence server &rarr; &ldquo;This is a backup server&rdquo; &rarr;
        <strong>Test connection</strong> to confirm the main answers &rarr; enter the code &rarr;
        <strong>Pair</strong>. It pulls the signing key, owner password, config and a first data copy, then
        re-syncs every 15 minutes on its own.</li>
    <li>Add the backup&rsquo;s address to the <strong>Backup servers</strong> list so customers fail over
        to it. A backup needs <strong>no stamping</strong> &mdash; it adopts the main&rsquo;s key and never
        builds customer bundles.</li>
</ol>

<h3>3. Manage pairings on the main</h3>
<p>Licence server &rarr; Servers &amp; keys &rarr; Backup servers lists every peer with its status:</p>
<ul>
    <li><strong>Suspend</strong> &mdash; temporarily stop a backup syncing (e.g. while you work on it).
        Its next sync is refused with a clear reason; <strong>Resume</strong> restores it.</li>
    <li><strong>Revoke</strong> &mdash; permanently cut a backup off. Use for a host you no longer
        control.</li>
    <li><strong>Delete</strong> (trash) &mdash; remove a stale or failed pairing row entirely, e.g. a
        pairing attempt that never completed.</li>
</ul>

<h3>4. Rotate the signing key (rare &mdash; only if it may be exposed)</h3>
<div class="alert alert-warning">
    <strong>This invalidates every existing lease and activation code.</strong> Every customer must
    re-activate and every client build must be re-stamped and redeployed. Do it only for a suspected key
    compromise, and plan a maintenance window.</div>
<ol>
    <li>On the <strong>main</strong>: Licence server &rarr; Servers &amp; keys &rarr; generate a new key,
        then <strong>Stamp into source</strong> so future client bundles carry the new public key.</li>
    <li>Re-pair every backup (the old pairing carried the old key).</li>
    <li>Rebuild and redeploy client bundles; re-issue activation codes. Customers on the old key keep
        working on grace until they re-activate.</li>
</ol>

<h3>5. Disaster recovery &mdash; the primary host is lost</h3>
<p>Because a backup already holds the signing key and a &le;15-minute-old copy of the licence data, it
can become the primary:</p>
<ol>
    <li>Stand up a host at the primary address (or re-point DNS to the backup).</li>
    <li>On the backup: Licence server &rarr; <strong>Unpair</strong>, which flips it to a main (it already
        has the key and the data).</li>
    <li>Correct the <strong>primary address</strong> in Service addresses if it changed. Within 24 hours
        every customer&rsquo;s next signed lease carries the corrected list &mdash; no customer action.</li>
    <li>When the original primary returns, make it the backup and pair it the other way, so writes have a
        single owner again.</li>
</ol>

<h3>6. Everyday licence work</h3>
<ul>
    <li><strong>Issue a licence:</strong> always on the <strong>main</strong> (a backup is read-only and
        refuses). Send the customer the activation code it produces.</li>
    <li><strong>Free a seat:</strong> open <strong>Manage</strong> on the licence and revoke the instance;
        the seat frees immediately and the client gets a signed &ldquo;revoked&rdquo; answer.</li>
    <li><strong>Suspend for non-payment:</strong> set the licence to suspended; renewals and activations
        are blocked with a &ldquo;contact the vendor&rdquo; message until you reinstate it.</li>
</ul>

<hr>

<h2 id="api">The public API</h2>
<p>Three unauthenticated endpoints, JSON in and out. Everything that matters in a reply is signed,
so the transport is not what is being trusted.</p>
<?= $c('POST /api/license/activate     { licenseKey, fingerprint, appVersion, label }
POST /api/license/renew        { instanceId, secret, fingerprint, appVersion }
POST /api/license/deactivate   { instanceId, secret }') ?>

<p>A lease is compact and self-describing:</p>
<?= $c('PPL1.<base64url(payload)>.<base64url(ed25519 signature)>

payload = {
  v:1, iss:"ppphp-license",
  iid: "<instance id>",     // which installation it is for
  lic: "<licence hash>",    // never the licence key itself
  sn:  42,                  // serial — must not go backwards
  iat, nbf, exp,            // issued / not-before / expires (24h)
  st:  "ok" | "revoked",
  plan, holder, seats, lexp // shown in the customer\'s UI
}') ?>

<p>Verifying one takes three lines anywhere you have the public key:</p>
<?= $c('use PpPhp\\Core\\Licensing\\LicenseProtocol;

$claims = LicenseProtocol::verify($token, $publicKeyRaw);   // null if forged
$verdict = LicenseProtocol::evaluate($claims, $instanceId, time(), $lastContact);
// $verdict[\'status\'] === LicenseProtocol::OK') ?>

<hr>

<h2 id="trouble">Troubleshooting</h2>
<dl>
    <dt>The customer says "activation code was issued by a different vendor"</dt>
    <dd>Their build was stamped with a different key than the one that signed the code. Re-stamp
        and rebuild, or re-issue the code from the server whose key is in their build.</dd>

    <dt>"Could not reach the licence server"</dt>
    <dd>Every address was tried and none answered: their host blocks outbound HTTPS, or all of your
        servers are down. Their install keeps working on grace and retries every 15 minutes. Adding a
        backup server on the licence page reaches them automatically at their next successful renewal.</dd>

    <dt>"Activation must use https://"</dt>
    <dd>They are activating against an <code>http://</code> address. Fix the primary address on the
        server page and re-issue the code. On a local development server, turn the switch off
        deliberately rather than leaving it off in production.</dd>

    <dt>A backup server's leases are rejected by customers</dt>
    <dd>It has its own signing key. Copy <code>storage/license/server-key.json</code> from the primary
        &mdash; the whole point of a mirror is that its signature is indistinguishable from the
        primary's.</dd>

    <dt>Seats look full but the customer only has one server</dt>
    <dd>Old instances from earlier installs. Open <strong>Manage</strong> on the licence and revoke
        the ones that are not theirs any more &mdash; each revoke frees its seat immediately.</dd>

    <dt>An installation is flagged <em>clone-suspected</em></dt>
    <dd>Its machine traits changed wholesale under the same credentials. Legitimate causes: restore
        from backup, migration to a new host, a container rebuild. Check the history on the licence
        before acting; revoke only if it really is a second copy.</dd>

    <dt>Nothing is being enforced on a copy that should be licensed</dt>
    <dd>It is unstamped. Check <code>core/Licensing/LicenseTrust.php</code> in that build &mdash;
        <code>PUBLIC_KEY</code> will be empty. Stamp, rebuild, redeploy.</dd>

    <dt>You are testing and want enforcement on/off explicitly</dt>
    <dd>Define <code>PP_LICENSE_ENFORCE</code> before the router runs; it overrides the stamp check
        in both directions.</dd>
</dl>

<hr>
<p class="text-muted"><i class="bi bi-broadcast"></i> The daily check‑in this page describes is also the channel that carries
   <a href="../advisories/">Owner→client pushes</a> — signed notices and critical security patches — and the vendor‑set
   <b>server display names</b> (e.g. <code>SEC‑SRV‑NP</code>) that clients show instead of the raw address.</p>
