Skip to content

Renewal & revocation

Renewal & revocation

Renewal

A subscription renewal extends the paid window, so the license must be re-minted to match. LicenseMint::reissue() re-mints for the same deployment, customer, and profile with an extended window and a fresh id:

$renewed = app(LicenseMint::class)->reissue(
    $existing,                                     // the prior IssuedLicense
    newExpiresAt: new DateTimeImmutable('2028-01-01T00:00:00Z'),
    issuedAt: new DateTimeImmutable(),
);

app(IssuedLicenseStore::class)->save($renewed);    // supersedes the deployment's current license

The plan, entitlements, limits, deployment, customer, and domain binding all carry over from the prior record; notBefore is preserved and expiresAt moves out. The new id makes the renewed artifact a distinct, independently-revocable license — revoking last year's does not touch this year's.

Tracking the paid period

SubscriptionLicensePolicy is a small pure helper that derives a license's expiresAt from the subscription's current paid-period end, plus a configurable grace buffer (billing.licensing.grace_seconds):

$expiresAt = app(SubscriptionLicensePolicy::class)
    ->expiresAtFor($subscription->currentPeriodEnd());

$renewed = app(LicenseMint::class)->reissue($existing, $expiresAt, new DateTimeImmutable());

The grace buffer keeps the offline artifact valid slightly past the period boundary — covering the lag between a renewal being paid and the new license being pulled by the deployment — without letting the license outlive the paid period by more than the buffer you chose. The helper only computes a date; the app decides when to renew and calls reissue() with the result.

Revocation

Revocation is issuer-side state plus a signed feed the verifier consults offline. RevocationRegistry holds the revoked ids; RevocationPublisher cuts a freshly signed revocation list from it:

app(RevocationRegistry::class)->revoke($issued->id); // idempotent

$signedList = app(RevocationPublisher::class)
    ->currentList(new DateTimeImmutable());           // compact, signed EdDSA JWT

Deliver $signedList to deployments. Like the mint, the publisher is key-agnostic — it signs with the host-bound RevocationListIssuer and the same private key used for licenses. The default InMemoryRevocationRegistry is not durable; a host binds a connection-backed registry so revocations survive a restart and are shared across issuer nodes.

The verifier side treats a missing or malformed revocation list as fail-open ("no revocations known") so a broken feed cannot brick a legitimately licensed deployment. That behaviour lives in cboxdk/license, on the verifier — not in this package.

Verifying what you minted

Verification is not this package's job — it belongs to the self-hosted app, which bundles the public key. But the same core verifier is what your issuer tests use to prove a minted artifact reads back exactly as the deployment will see it:

use Cbox\License\Ed25519LicenseVerifier;
use Cbox\License\ValueObjects\{RevocationList, VerificationContext};

$verifier = new Ed25519LicenseVerifier($publicKeyBase64);

$result = $verifier->verify($issued->key, new VerificationContext(
    deploymentId: 'dep_acme_prod',
    domain: 'acme.example',
    now: new DateTimeImmutable(),
    revocations: RevocationList::fromSigned($signedList, $publicKeyBase64),
));

$result->isLicensed();     // true only when Valid or InGrace
$result->entitlements();   // [] unless in force — deny-by-default
$result->status;           // Valid | Expired | Revoked | BindingMismatch | …

Entitlement gating and limit enforcement (reading $result->entitlements() / $result->limits() and refusing features accordingly) are the consuming app's responsibility. This package guarantees only that the artifact it produced is a well-formed, correctly-bound, signed license.