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.
Requires
- php: ^8.2
- ext-sodium: *
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/events: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/queue: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^10.11|^11.1
- pestphp/pest: ^3.8|^4.7
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,deactivatedandlimit_reachedare 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.