nextdevit/ndit-license-client

Laravel licence client for self-hosted NextDevIT products: activation, Ed25519 verification, 72-hour heartbeat and entitlement gating, with zero network calls in the request path.

Maintainers

Package info

github.com/NextDevIT/ndit-license-client

Documentation

pkg:composer/nextdevit/ndit-license-client

Transparency log

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.5.1 2026-08-06 07:18 UTC

This package is not auto-updated.

Last update: 2026-08-07 02:36:24 UTC


README

The client half of the NDIT licensing system: one Laravel package installed inside every Type A (self-hosted) NextDevIT product. The Hub issues Ed25519-signed licenses; this package consumes them — activation, signature verification, local storage, the 72-hour heartbeat, state resolution and entitlement gating.

The design law, in one sentence: the package owns mechanism, the product owns policy. It knows how to verify a signature, count down grace and answer NditLicense::can('module.x'). It has no opinion about what locking module.x means. Each product wires the events, middleware and gates below to its own feature boundaries — its degrade map.

Two consequences worth internalising before you wire anything:

  • Nothing here ever blocks a request on the network. Gates are pure reads of the stored token. Re-validation happens on the scheduler, or via a queued job. This is enforced by a test that makes any HTTP call during a page load an outright failure.
  • The package can only report state. It ships a lock middleware but never applies it. Where an install gets locked out is your call, in your app. A package that force-locks a customer's routes can brick their business.

Customers receive the full source of this package. That is deliberate: it holds no secrets, only public keys. Its protective value comes from unforgeable server responses, gates scattered at feature boundaries, and the moats enforced server-side — not from hiding code.

Requirements

PHP ≥ 8.2, with ext-sodium (bundled since PHP 7.2)
Laravel 12 or 13
Dependencies illuminate/* only — zero third-party packages
Network Outbound 443 to app.nextdevit.com (a blocked host degrades gracefully, see Troubleshooting)

Works on hostile hosting by design: database queues, cron-only scheduling, no Redis, shared hosting.

Install

composer require nextdevit/ndit-license-client
php artisan migrate

Published on Packagist — no repositories entry needed.

The service provider is auto-discovered and the migration loads itself — a fresh install answers ndit-license:status with never_activated and nothing else is required to boot.

Configure

php artisan vendor:publish --tag=ndit-license-config
Env var Default Notes
NDIT_LICENSE_PRODUCT Required. Hub product slug; must equal the token's product claim
NDIT_LICENSE_API_BASE https://app.nextdevit.com/api/v1/license Must be https
NDIT_LICENSE_KEY_K1 production k1 Base64 Ed25519 public key. Set it only when NDIT_LICENSE_API_BASE points at your own Hub — key and Hub move together
NDIT_LICENSE_DRIVER live live or fake
NDIT_LICENSE_FAKE_SCENARIO active See the fake driver table below
NDIT_LICENSE_ALLOW_FAKE false Escape hatch for a deliberate production demo
NDIT_LICENSE_PRODUCT_VERSION Reported in telemetry
NDIT_LICENSE_CONNECT_TIMEOUT / NDIT_LICENSE_TIMEOUT 5 / 15 Seconds

Platform policy — 72 h heartbeat, 14 d grace, 30 d token, ±48 h clock tolerance — is hardcoded as defaults. Override keys exist for emergencies and are never varied in practice.

Publish tags: ndit-license-config · ndit-license-migrations · ndit-license-vue · ndit-license-lang

Wire it

a. The activation page

The package ships a Vue component and no route, because where activation lives is a product decision.

php artisan vendor:publish --tag=ndit-license-vue
php artisan vendor:publish --tag=ndit-license-lang   # optional, to reword copy

The whole controller recipe:

// routes/web.php
Route::get('/setup/license', [LicenseSetupController::class, 'show'])->name('ndit-license.activate');
Route::post('/setup/license', [LicenseSetupController::class, 'store'])->name('ndit-license.activate.store');
class LicenseSetupController extends Controller
{
    public function show(): Response
    {
        return Inertia::render('NditLicenseActivate', [
            'shareable' => NditLicense::shareable(),
            'submitUrl' => route('ndit-license.activate.store'),
            'portalUrl' => 'https://app.nextdevit.com/portal',
            'continueUrl' => route('setup.step-1'),
            't' => trans('ndit-license::activate') + ['errors' => trans('ndit-license::errors')],
        ]);
    }

    public function store(Request $request): RedirectResponse
    {
        $result = NditLicense::activate($request->string('key')->toString());

        return back()->with('ndit_result', [
            'ok' => $result->ok(),
            'error_code' => $result->errorCode(),
            'message' => $result->message(),
            'activation_summary' => $result->activationSummary(),
            'retry_after' => $result->retryAfter(),
        ]);
    }
}

Then decide where — if anywhere — a never-activated install should be pushed to that page:

Route::middleware(['auth', 'ndit-licensed'])->group(function () {
    // admin routes
});

ndit-licensed is registered as an alias and applied nowhere by default. Leave your point-of-sale, checkout or any other business-critical route out of it. A customer whose license server is unreachable must still be able to serve their customers.

b. Gates

Four ways to ask the same pure question:

// route middleware
Route::middleware('entitled:module.accounting')->group(/* … */);

// in code
if (NditLicense::can('module.accounting')) { /* … */ }

// in Blade — note the space: Blade will not compile `yes@endentitled`
@entitled('module.accounting') … @else … @endentitled
@notentitled('module.accounting') … @endnotentitled

// composed into a policy
Gate::allows('ndit-entitled', 'module.accounting');

entitled: answers a JSON request with 403 {code, entitlement, state}. For web requests it returns a plain 403, or — if you set ndit-license.routes.locked_redirect — redirects there with the blocked entitlement flashed as ndit-license.locked, which is what you want for an upsell page.

Share state with the front end once, in your Inertia middleware:

public function share(Request $request): array
{
    return [...parent::share($request), 'license' => NditLicense::shareable()];
}

shareable() gives you state, entitlements, key_masked, grace countdown, renewal countdown, upsell_url and a banner with a translation key and params — everything a banner or settings page needs, with no raw key anywhere in it.

c. Your degrade map

Listen to these seven events and decide what each one means for your product:

Event Payload Typically
LicenseActivated claims, domain Seed module registry, finish setup wizard
LicenseValidated claims, previous previous was grace/degraded ⇒ this IS the recovery signal
EnteredGrace lastValidatedAt, daysLeft Start warning; change nothing else
LicenseDegraded lastValidatedAt Switch off non-essential modules
LicenseRevoked reason (revoked|expired) Same lock, different copy
LicenseDeactivated source (local|remote|limit_reached) Back to the activation page
EntitlementsUpdated old, new Add or retire modules — no customer action
class SyncModuleRegistry
{
    public function handle(EntitlementsUpdated $event): void
    {
        foreach (array_diff($event->new, $event->old) as $added) { /* enable */ }
        foreach (array_diff($event->old, $event->new) as $removed) { /* retire */ }
    }
}

Design your map so that every state leaves the customer's core business workable. Grace and degraded are for feature reduction, never for a locked front door.

d. Scheduling

// routes/console.php
Schedule::command('ndit-license:heartbeat')->everySixHours()->withoutOverlapping();

The command no-ops internally until the window opens, so any frequency is safe.

For hosts whose cron is dead or throttled, append the opportunistic middleware to an admin group. It dispatches a queued job when a check is overdue and returns immediately — it never makes an admin wait on a network call:

Route::middleware(['auth', 'ndit-license.opportunistic'])->group(/* admin */);

e. Telemetry disclosure

Every activation and heartbeat sends exactly this:

Field Value
product_version NDIT_LICENSE_PRODUCT_VERSION
php PHP_VERSION
laravel Framework version

Plus the license key, product slug, domain and instance UUID that the call is about. No customer data, no usage data, no PII.

Add product-specific fields from a service provider:

NditLicense::telemetry(['niche' => config('karbar.niche')]);

Registered at runtime, so it survives config:cache. Whatever you add here, document it in your product's own privacy notes. That rule is not optional.

The fake driver

Set NDIT_LICENSE_DRIVER=fake and your entire licensing integration becomes CI-testable — including tamper, grace, revoke and limit-reached — with no Hub.

Fixture responses are signed with a committed test keypair and travel the exact production verification path, so a tamper test tests something real.

NDIT_LICENSE_FAKE_SCENARIO What you get
active Everything works
grace Activates, then backdates 4 days: grace banner without time travel
degraded Activates, then backdates 15 days
revoked Validate returns a signed revoked negative
invalid_key Every endpoint answers invalid_key 401
limit_reached Activation blocked with a plausible activation summary
updates_expired License fine, downloads refused with a renewal link
unreachable Throws the same ConnectionException the live transport does

Two extra switches reach codes no scenario owns: ndit-license.fake.force_rate_limit (429 + retry_after) and ndit-license.fake.force_error (e.g. wrong_product, not_activated).

The fake driver refuses to construct in production unless NDIT_LICENSE_ALLOW_FAKE=true.

Commands

Command Purpose
ndit-license:activate {key} {--domain=} Scripted activation. Exit 0 activated · 1 the Hub said no · 2 no trustworthy answer, retry later
ndit-license:status Full local report: state, masked key, entitlements, dates, next heartbeat window
ndit-license:heartbeat {--force} Scheduler entry. Exits 0 even on failure (a failed heartbeat is normal grace behaviour); --force exits 1 so a human gets an answer
ndit-license:deactivate {--confirm} Frees the activation slot and clears the local license

Troubleshooting

"Grace" or "Limited" banner, and the customer swears nothing changed. The server cannot reach app.nextdevit.com:443. Grace lasts 14 days from the last successful validation, then features reduce. Run ndit-license:heartbeat --force to see the actual error. Nine times in ten it is an outbound firewall rule or a DNS change on the host.

Clock skew. Tokens are rejected if iat is more than 48 hours from the server's clock. A wildly wrong server clock looks exactly like an unreachable Hub. Check date on the box.

"All activation slots are in use." The license is active on other domains. The error carries the list with last-seen dates — free one in the customer portal and retry. Dev and staging domains do not consume slots.

APP_KEY was rotated. The stored key becomes unreadable, heartbeats stop and the install slides into grace with a "re-activate" banner. Re-activating with the same key is enough: the instance UUID lives in a plain column, so the same Hub slot is reused rather than a new one consumed.

A cloned database. The clone reuses the instance UUID but presents a new domain, which triggers the re-activation flow — by design.

Security posture

  • No secrets, anywhere. Only public keys. The package verifies signatures; it never signs anything.
  • Verification failure maps to unreachable, never to valid and never to revoked. This is the single most important line in the package: a broken or hostile response can slow an install into grace, but it can neither unlock it nor brick it.
  • Signed negatives only. revoked, expired, deactivated and limit_reached are the only inputs that may set a terminal flag, and only after a signature check. An HTTP error code cannot lock anyone out.
  • The key is encrypted at rest and masked everywhere else — logs, exception messages, command output, shareable(). Customer log files end up in support tickets.
  • Zero network in the request path, asserted in CI.
  • No dynamic code paths. No eval, no remote config execution.

Versioning

Semver. Private repo, consumed via a Composer VCS entry.

Version Meaning
v0.1.0 Fake driver complete — a product can build and CI-test its whole integration
v0.2.0 Heartbeat, events, commands, activation UI
v1.0.0 Live driver verified against Hub staging

Supported: PHP 8.2 / 8.3 / 8.4 × Laravel 12 / 13.

CI runs the full suite on all five combinations that can exist. PHP 8.2 with Laravel 13 is not one of them — Laravel 13 itself requires PHP ≥ 8.3.

Key rotation is a minor release: a k2 entry is added to keys and both are accepted while the Hub migrates.