nexthop / service-kit
Licensing for Nexthop apps: every page needs a valid license from Nexthop Central, with ready-made License, error and reminder pages. No code in the app.
Requires
- php: ^8.3
- laravel/framework: ^13.0
- paragonie/sodium_compat: ^2.0
- symfony/yaml: ^7.0|^8.0
Requires (Dev)
- orchestra/testbench: ^11.0
- pestphp/pest: ^5.0
- pestphp/pest-plugin-laravel: ^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Licensing for every Nexthop app, in one Composer package. An app installs it and adds one file
(nexthop-app.yaml). From then on:
- every page of the app needs a valid license, which is issued in Nexthop Central;
- an admin activates, checks, renews and moves the license on one page:
/admin/license; - when the license is not valid, users see the kit's error page.
The app writes no licensing code: no middleware in routes, no checks in controllers, no frontend changes. The app keeps its own users, roles and permissions; Central never logs anyone in.
How it works
Nexthop Central (staff) The app + nexthop/service-kit
─────────────────────── ─────────────────────────────
App → Installation → Licenses → Devices /admin/license: paste the key (needs internet)
key XXXXX-XXXXX-XXXXX-XXXXX-XXXXX ──▶ the server becomes one of the license's devices
▲ every web page: the kit checks the license
└── check-in every 24 h (set per install) ◀── valid → the app works
health, users, error-log summary, in grace → admins get a reminder once per login
device hashes invalid → the kit's error page
- Activation always goes through Central, online: the admin pastes the license key (25 characters,
XXXXX-XXXXX-XXXXX-XXXXX-XXXXX; the last one is a check character, so a typo is caught before anything is sent). Central checks the key, learns which app it is, and registers this server as a device of the license. - Each license has a device limit. Activating sends the device's fingerprint and details (see Devices). When every seat is taken, activation says so ("already used on 2 of 2 devices"); only Nexthop staff can free a seat, by inactivating or removing a device in Central. The app on that device finds out at its next check-in and stops.
- Deactivating does not free the seat. Activating again on the same server and folder reuses it.
- After activation the app checks in with Central (every 24 hours by default; staff change the interval per install), sending health, the user count, an error-log summary and the device hashes, and bringing back renewals.
- Offline lease (optional). A license may carry
lease_until: when the app has not checked in by then, it stops (offline_too_long) until it is connected and checks in again (Check now on the License page). Every check-in brings a new lease. - Licenses are signed by Central (Ed25519) and checked locally: for this installation and this device, so a license file can't be copied to another server, and it cannot be edited.
- The server clock can't extend a license or a lease. Every license Central sends carries Central's signed time; the kit judges by the latest real time it has seen, so setting the clock back changes nothing.
Add the kit to an app
Install the package.
composer require nexthop/service-kitOptional: describe the app. Without
nexthop-app.yaml, the app is known to Central byAPP_NAME(as a slug, e.g.document-campus) orNEXTHOP_APP_KEY. The file is needed only for licensed modules:php artisan nexthop:install --key=moge --name="MOGE Document Campus"In Nexthop Central, create an install for the organisation with its license. Central shows the license key.
In the app, an admin opens
/admin/licenseand pastes the key.
That is all. Make sure the scheduler runs (php artisan schedule:run every minute, e.g. an aaPanel cron
job): nexthop:device runs hourly (it collects the device details the License page sends), and
nexthop:report runs hourly and checks in when it is due.
Devices
A device is one copy of the app on one server: the machine's id plus the app's folder.
- Fingerprint. The kit picks the best machine id the server offers, once, and pins it in
nexthop/device.jsonon the license disk (next toinstall.json): on Linux/etc/machine-id,/var/lib/dbus/machine-idor the DMI product UUID (not inside a container: containers from one image share a machine-id); on macOS theIOPlatformUUIDor the MAC address ofen0; on Windows theMachineGuid; otherwise a random id kept indevice.json. Only HMAC hashes leave the kit (machine_hash,instance_hash,fingerprint); the machine's own id is never stored or sent. - The folder is the real path of
storage/, so deploys that switch release folders but sharestorage/stay one device. SetNEXTHOP_DEVICE_INSTANCEonly when that path changes on purpose (e.g. a container whose storage volume is mounted elsewhere). - One device per server, with a local license disk. The license disk (
nexthop.license_disk, by default Laravel's local disk understorage/app) must be local to the server: a disk shared by several servers is not supported. - Moved? The scheduled check-in (on the command line, never on a web request) reads the pinned source
again. When this copy now runs on another machine or in another folder, it stops using the license
(
device_changed: "Activate it again; that needs a free device seat") and still tells Central. When the source can't be read (e.g.open_basedir), nothing happens. - What activation sends to Nexthop: the fingerprint and hashes; the device details (
php artisan nexthop:device --jsonshows them): host name, OS and distribution, uptime, load, disk size, DMI vendor and model, virtualization and control panel (aaPanel...), CPU and memory, network interfaces and MAC addresses, the server's IPs, PHP version, SAPI, a few ini values, extensions,open_basediranddisable_functions, Laravel, app and kit versions, environment, debug flag, app URL, base path, database driver and version, cache/queue/session/mail drivers; and, from the License page, the activating admin's name and email and their browser's IP and user agent. Never:APP_KEYor anything else from.env, database host or credentials, environment dumps, processes or users, cloud metadata. At most 16 KB. nexthop:device(scheduled hourly, also run bynexthop:install) collects the details from the command line, which on aaPanel sees the whole machine where PHP-FPM is limited byopen_basedir. The License page sends what it collected, plus what only the web server knows (its address and software).
What people see
| License | Everyone | Admins |
|---|---|---|
| Active | The app works. | The app works. |
| Grace period (ended, within the grace days) | The app works. | Once per login, a reminder page: "the app stops on ", with Renew and Continue. |
| Not activated, expired, suspended or revoked | The error page: the reason, and "ask your administrator". Guests get Log in. | The error page, with Open the License page. |
Offline too long (offline_too_long: the lease ran out) | The error page. | The error page: "connect this server to the internet, then press Check now on the License page". |
Disconnected by Central (released, after a 401) | The error page, with Central's reason: Nexthop deactivated this device, removed it from the license, or the license was activated again on this device from another copy. | The same, with Open the License page to activate again. |
Moved (device_changed) | The error page: "This copy was moved to another machine or folder." | The same; activating again needs a free device seat. |
The login, logout, password-reset and two-factor pages stay open, so an admin can always log in and fix
the license. JSON requests get 403 {"reason": "..."}; Inertia visits are sent to the error page.
The License page (/admin/license, admins only) has everything in one place:
- Overview: state, days left, the key's last five characters (
•••••-•••••-•••••-•••••-35JVQ: the full key is never kept), dates, the device ID, the device limit and, with a lease, the time to check in by. - Users: users counted against the limit, as a bar.
- Connection: Central's address, the check-in interval and the next one due, health checks, Check now, and upload a newer license file (for installs that are usually offline).
- History: activation, renewals and other changes, connection problems, renewal requests.
- Renewal: a request to Central, or (when Central can't be reached) what to send to Nexthop.
- Danger zone: deactivate (password + typing
DEACTIVATE). Central is told, but the device seat is not freed: only Nexthop can. Activating again on this same server and folder reuses the seat.
The pages are React, built into the package (dist/) and served by the kit at /nexthop/assets/….
Apps need no npm, and nothing is copied into resources/views or public/.
Configuration (only when the defaults don't fit)
Nothing needs to be configured. To change something, publish the config file:
php artisan vendor:publish --tag=nexthop-config
| Key | Default | What it does |
|---|---|---|
users.counts | [] (every user) | Which users count against the user limit, e.g. ['status' => 'approved']. Enum casts are understood. |
users.model | the auth user model | The model to count. |
admin.method / admin.role | isAdmin / Admin | Who may open the License page: users whose isAdmin() is true, otherwise users with the Spatie role. Defining the nexthop-manage-license Gate ability yourself wins. |
ui.app_name, ui.logo | APP_NAME, none | Shown at the top of the kit's pages. The logo is a URL or a path under public/. |
ui.colors.light / .dark | Nexthop orange | Buttons and links, as hex colours. Anything else falls back to the default. |
ui.theme | auto | auto (follow the device), light or dark. |
ui.history | 20 | History events shown before "Show all". |
ui.sections.renewal / .deactivate | true | Turn off the Renewal section or the Danger zone. php artisan nexthop:deactivate keeps working. |
protect.except | auth routes, nexthop.*, /up | Route names (wildcards allowed) or paths (/…) that never need a license. |
protect.groups | ['web'] | Middleware groups the kit protects. |
central_url | NEXTHOP_CENTRAL_URL | Prefills Central's address on the activation form. |
device_instance | NEXTHOP_DEVICE_INSTANCE | This copy's folder for the device fingerprint. Default: the real path of storage/. |
support | NEXTHOP_SUPPORT | How installs without internet reach Nexthop for a renewal (email or phone). |
app.key, app.version | APP_NAME slug, 1.0.0 | Who the app is for Central when it has no nexthop-app.yaml. |
logs.path | storage/logs | Where the error-log summary is read from (laravel*.log). |
home | / | Where "Back to the app" goes. |
inertia_prop | null | Set to a name (e.g. license) to share {state, valid, days_left} with every Inertia page. |
The user limit needs no code. When a user is about to start counting (created as counted, approved,
reactivated) and the license is full, the kit stops the save and the app shows the error under
user_limit (and as a toast in Inertia apps).
No keys go in .env. Activation is stored in storage (the install token encrypted with APP_KEY).
Optional modules
Most apps are one license: valid means the whole app works. An app with separately licensed parts lists
them in nexthop-app.yaml with the routes they cover:
key: shop
name: { en: Shop, my: Shop }
version: 1.0.0
modules:
- { key: shop.reports, name: { en: Reports }, routes: ['reports.*'] }
- { key: shop.export, requires: [shop.reports], routes: ['export.*'] }
A page of a module that the license does not include shows the error page ("not included in the
license"). The core module (the app key) is implicit. For a route that no pattern covers, add the
license:shop.export middleware to it.
Tests in an app
The kit's checks (the page check and the user limit) are off while the app's tests run, so the app's test suite needs no license and no changes. To test with a license, turn it on and install one:
config(['nexthop.protect.in_tests' => true]);
$licenses = Nexthop\ServiceKit\Testing\LicenseFactory::configure();
$licenses->install(['user_limit' => 10]); // active
$licenses->install(['exp' => now()->subDays(30)->toIso8601String()]); // expired
$licenses->install(['lease_until' => now()->subHour()->toIso8601String()]); // offline too long
$licenses->uninstall(); // not activated
Licenses from the factory are for its device (LicenseFactory::DEVICE, or configure(deviceId: ...)).
Commands
| Command | |
|---|---|
nexthop:install | Write nexthop-app.yaml (optional; needed only for modules). |
nexthop:activate <key> --central=<url> | Activate online, like the License page (no user or browser is sent). |
nexthop:device | Collect the device details and pin the fingerprint (scheduled hourly); --json shows what is sent. |
nexthop:status | Show the activation, license and connection. |
nexthop:report | Check in with Central when due (scheduled hourly); --force checks in now. |
nexthop:register | Tell Central the app's version (run after each deploy). |
nexthop:deactivate | Deactivate this server. The device seat stays used: only Nexthop frees it. |
nexthop:manifest:validate | Check nexthop-app.yaml. |
Trust
Licenses are checked against Central's public keys built into this package
(resources/keys/trusted.php). To add or rotate Central's key: run php artisan central:keygen on the
Central server, add the public key to that file, and release a new kit version. NEXTHOP_PUBLIC_KEY, and
a local Central's key returned at activation, are trusted only in the local and testing environments.
Develop the kit
composer install && vendor/bin/pest # PHP
npm install && npm run build # the React pages (resources/ui → dist/)
dist/ is committed: rebuild it whenever resources/ui changes. CI fails when it is out of date.
Release a version
- Add the version to
CHANGELOG.md. - Set
ServiceKit::VERSION(src/ServiceKit.php): the kit sends it to Central (X-Nexthop-Kit). - Commit, then tag and push:
git tag v3.0.0 && git push origin main --tags. - Packagist picks up the tag from the public repository
(
https://gitlab.thenexthop.com.mm/root/service-kit).
Follow semantic versioning: a change that breaks something an app relies on gets a new major version.
License
Proprietary. See LICENSE.