Compare commits

...
10 Commits
Author SHA1 Message Date
Mark Gregory e44a59a91e move into sonardesk 2026-10-12 15:27:09 +01:00
Mark Gregory 0ca7a2cf1e move into sonardesk 2026-10-12 15:24:46 +01:00
Mark Gregory 729b78ddb9 move into sonardesk 2026-10-12 15:21:49 +01:00
Mark Gregory a88d336991 move into sonardesk 2026-10-12 15:18:07 +01:00
Mark Gregory ef9903b7b7 move into sonardesk 2026-10-12 15:08:42 +01:00
Mark Gregory 38b4a7406f move into sonardesk 2026-10-12 14:50:13 +01:00
Mark Gregory 5f0d2ceb72 move into sonardesk 2026-10-12 14:27:19 +01:00
Mark Gregory 23a07be83e move into sonardesk 2026-10-12 13:29:10 +01:00
Mark Gregory cd66923c43 move into sonardesk 2026-10-12 13:27:28 +01:00
Mark Gregory b4e040999d move into sonardesk 2026-10-12 13:25:16 +01:00
323 changed files with 10889 additions and 2 deletions
View File
+6
View File
@@ -0,0 +1,6 @@
projectKey=Content
serverUrl=http://192.168.1.2:9001
serverVersion=9.9.8.100196
dashboardUrl=http://192.168.1.2:9001/dashboard?id=Content
ceTaskId=AaCkx0myX_DhBSeKgkyS
ceTaskUrl=http://192.168.1.2:9001/api/ce/task?id=AaCkx0myX_DhBSeKgkyS
+16
View File
@@ -0,0 +1,16 @@
# ABSDev / SonarDesk integration — development scaffold
**NOT PRODUCTION READY. DO NOT DEPLOY OVER AN EXISTING LICENSING SERVER.**
Implemented:
- Preserved ABSDev application as base.
- Added SonarDesk-host-specific marketing routes and responsive ABSDev-inspired navy/gold views.
- Archived licensing source under `integration/sonardesk-legacy/` for a controlled migration. This directory is **not** wired into runtime.
Not implemented / not verified:
- Licensing API, existing download/appcast routes, payment checkout/webhooks, customer identity mapping, backend administration, data migrations and theme-manager integration.
- Laravel test suite, browser tests, PHP dependencies and deployment verification.
**Important:** Existing unscoped ABSDev routes and module routes may still collide with the SonarDesk host. The new domain routes are only an initial frontend scaffold. Do not point production SonarDesk DNS at this build.
Next steps: inventory and namespace every licensing route, reconcile User schema and Fortify authentication, port modules and database migrations without collisions, wire checkout and webhooks, add regression tests, and test on staging with anonymised production data.
View File
+8
View File
@@ -0,0 +1,8 @@
<?php
return [
'name' => 'Licensing',
'features' =>
[
],
];
View File
@@ -0,0 +1,20 @@
<?php
namespace Modules\Licensing\Console;
use Modules\Licensing\Services\LicenseDeliveryService;
use Illuminate\Console\Command;
final class SonarDeskLicenseDeliver extends Command
{
protected $signature = 'sonardesk:license-deliver {--limit=50 : Maximum pending licence emails to send}';
protected $description = 'Deliver pending SonarDesk licence-key emails';
public function handle(LicenseDeliveryService $delivery): int
{
$limit = max(1, min((int) $this->option('limit'), 100));
$count = $delivery->deliverPending($limit);
$this->info("Delivered {$count} licence email(s).");
return self::SUCCESS;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Modules\Licensing\Console;
use Modules\Licensing\Models\LicenseProduct;
use Modules\Licensing\Services\LicenseIssuer;
use Illuminate\Console\Command;
class SonarDeskLicenseIssue extends Command
{
protected $signature = 'sonardesk:license:issue {product} {--name=} {--email=}';
protected $description = 'Issue a manual SonarDesk licence key';
public function handle(LicenseIssuer $issuer): int
{
$p = LicenseProduct::where('code', $this->argument('product'))->firstOrFail();
[$l,$key] = $issuer->issue($p, $this->option('name') ?: null, $this->option('email') ?: null);
if (! $key) {
$this->warn('Licence already existed; plaintext key is intentionally not recoverable.');
return self::FAILURE;
} $this->line('Licence ID: '.$l->id);
$this->line('Licence key: '.$key);
$this->warn('This is the only time the plaintext key is shown.');
return self::SUCCESS;
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Modules\Licensing\Console;
use Modules\Licensing\Models\License;
use Illuminate\Console\Command;
class SonarDeskLicenseRevoke extends Command
{
protected $signature = 'sonardesk:license:revoke {id} {--reason=Administrative revocation}';
protected $description = 'Revoke a SonarDesk licence';
public function handle(): int
{
$l = License::findOrFail($this->argument('id'));
$l->update(['revoked_at' => now(), 'revoke_reason' => $this->option('reason')]);
$this->info('Licence revoked.');
return self::SUCCESS;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace Modules\Licensing\Console;
use Modules\Licensing\Models\LicenseProduct;
use Illuminate\Console\Command;
class SonarDeskProductCreate extends Command
{
protected $signature = 'sonardesk:product {code} {--name=SonarDesk} {--edition=Professional} {--activations=1} {--max-major=1} {--provider=} {--product-id=} {--price-id=}';
protected $description = 'Create or update a SonarDesk licensing product';
public function handle(): int
{
$p = LicenseProduct::updateOrCreate(['code' => $this->argument('code')], ['name' => $this->option('name'), 'edition' => $this->option('edition'), 'activation_limit' => (int) $this->option('activations'), 'max_major_version' => $this->option('max-major') !== '' ? (int) $this->option('max-major') : null, 'payment_provider' => $this->option('provider') ?: null, 'external_product_id' => $this->option('product-id') ?: null, 'external_price_id' => $this->option('price-id') ?: null, 'active' => true]);
$this->info("Product {$p->code} ready.");
return self::SUCCESS;
}
}
@@ -0,0 +1,92 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('license_products', function (Blueprint $table) {
$table->id();
$table->string('code')->unique();
$table->string('name');
$table->string('edition')->default('Professional');
$table->unsignedInteger('activation_limit')->default(1);
$table->unsignedInteger('max_major_version')->nullable();
$table->string('payment_provider')->nullable();
$table->string('external_product_id')->nullable();
$table->string('external_price_id')->nullable();
$table->boolean('active')->default(true);
$table->timestamps();
$table->unique(['payment_provider', 'external_price_id']);
});
Schema::create('licenses', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignId('license_product_id')->constrained('license_products');
$table->string('license_key_hash', 64)->unique();
$table->string('license_key_last4', 4);
$table->string('customer_name')->nullable();
$table->string('customer_email')->nullable()->index();
$table->string('edition');
$table->unsignedInteger('activation_limit');
$table->unsignedInteger('max_major_version')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamp('revoked_at')->nullable();
$table->string('revoke_reason')->nullable();
$table->string('payment_provider')->nullable();
$table->string('external_order_id')->nullable();
$table->string('external_customer_id')->nullable();
$table->timestamps();
$table->unique(['payment_provider', 'external_order_id']);
});
Schema::create('license_delivery_outbox', function (Blueprint $table) {
$table->id();
$table->uuid('license_id')->unique();
$table->text('encrypted_license_key');
$table->string('recipient_email')->nullable();
$table->timestamp('delivered_at')->nullable();
$table->timestamps();
$table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete();
});
Schema::create('license_activations', function (Blueprint $table) {
$table->id();
$table->uuid('license_id');
$table->uuid('installation_id');
$table->string('platform', 32)->nullable();
$table->string('app_version', 32)->nullable();
$table->timestamp('activated_at');
$table->timestamp('last_seen_at')->nullable();
$table->timestamp('deactivated_at')->nullable();
$table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete();
$table->unique(['license_id', 'installation_id']);
$table->index(['license_id', 'deactivated_at']);
});
Schema::create('license_webhook_events', function (Blueprint $table) {
$table->id();
$table->string('provider');
$table->string('external_event_id');
$table->string('event_type');
$table->string('payload_sha256', 64);
$table->json('payload');
$table->timestamp('processed_at')->nullable();
$table->text('processing_error')->nullable();
$table->timestamps();
$table->unique(['provider', 'external_event_id']);
});
}
public function down(): void
{
Schema::dropIfExists('license_webhook_events');
Schema::dropIfExists('license_activations');
Schema::dropIfExists('license_delivery_outbox');
Schema::dropIfExists('licenses');
Schema::dropIfExists('license_products');
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('users') && ! Schema::hasColumn('users', 'is_admin')) {
Schema::table('users', fn (Blueprint $table) => $table->boolean('is_admin')->default(false)->index());
}
if (Schema::hasTable('license_activations') && ! Schema::hasColumn('license_activations', 'installation_name')) {
Schema::table('license_activations', fn (Blueprint $table) => $table->string('installation_name')->nullable()->after('installation_id'));
}
}
public function down(): void
{
if (Schema::hasTable('license_activations') && Schema::hasColumn('license_activations', 'installation_name')) {
Schema::table('license_activations', fn (Blueprint $table) => $table->dropColumn('installation_name'));
}
if (Schema::hasTable('users') && Schema::hasColumn('users', 'is_admin')) {
Schema::table('users', fn (Blueprint $table) => $table->dropColumn('is_admin'));
}
}
};
@@ -0,0 +1,13 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void {
if (!Schema::hasTable('sonardesk_releases')) Schema::create('sonardesk_releases', function(Blueprint $t) {
$t->id(); $t->string('version',40); $t->unsignedInteger('build_number')->default(1); $t->string('channel',20)->default('stable'); $t->string('minimum_macos',30)->nullable(); $t->string('file_path')->nullable(); $t->string('original_filename')->nullable(); $t->unsignedBigInteger('file_size')->nullable(); $t->string('sha256',64)->nullable(); $t->text('sparkle_signature')->nullable(); $t->text('release_notes')->nullable(); $t->boolean('is_published')->default(false)->index(); $t->boolean('is_featured')->default(false)->index(); $t->timestamp('published_at')->nullable()->index(); $t->unsignedBigInteger('download_count')->default(0); $t->timestamps(); $t->unique(['version','build_number','channel']);
});
if (!Schema::hasTable('download_products')) Schema::create('download_products',function(Blueprint $t){$t->id();$t->string('name');$t->text('description')->nullable();$t->string('version')->nullable();$t->string('platform')->nullable();$t->string('file_path')->nullable();$t->string('original_filename')->nullable();$t->unsignedBigInteger('file_size')->default(0);$t->unsignedBigInteger('download_count')->default(0);$t->boolean('is_published')->default(false);$t->timestamps();});
}
public function down(): void { /* Preserve release and download data on rollback; use an explicit data migration to remove. */ }
};
@@ -0,0 +1,21 @@
<?php
namespace Modules\Licensing\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Database\Eloquent\Model;
class LicensingDatabaseSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
Model::unguard();
// $this->call("OthersTableSeeder");
}
}
View File
+12
View File
@@ -0,0 +1,12 @@
<?php
use Modules\Licensing\Support\ModuleInfo;
if (! function_exists('licensing_feature_enabled')) {
/**
* Check if a feature is enabled in the config.
*/
function licensing_feature_enabled(string $key): bool
{
return (bool) config(ModuleInfo::nameLower().".features.{$key}", false);
}
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Licensing\Http\Controllers\Admin;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Modules\Licensing\Models\{SonarDeskRelease,DownloadProduct};
final class DistributionAdminController extends Controller {
public function index(){return view('licensing::distribution.admin', ['releases'=>SonarDeskRelease::orderByDesc('created_at')->paginate(20),'products'=>DownloadProduct::orderBy('name')->get()]);}
public function release(Request $request){$data=$request->validate(['version'=>'required|string|max:40','build_number'=>'required|integer|min:1','channel'=>['required',Rule::in(['stable','beta'])],'minimum_macos'=>'nullable|string|max:30','release_notes'=>'nullable|string','sparkle_signature'=>'nullable|string|max:2048','is_published'=>'nullable|boolean','is_featured'=>'nullable|boolean','file'=>'required|file|max:512000|mimes:dmg,zip,pkg']);$file=$request->file('file');$path=$file->store('sonardesk-distribution/releases','local');$release=SonarDeskRelease::create(['version'=>$data['version'],'build_number'=>$data['build_number'],'channel'=>$data['channel'],'minimum_macos'=>$data['minimum_macos']??null,'release_notes'=>$data['release_notes']??null,'sparkle_signature'=>$data['sparkle_signature']??null,'file_path'=>$path,'original_filename'=>$file->getClientOriginalName(),'file_size'=>$file->getSize(),'sha256'=>hash_file('sha256',$file->getRealPath()),'is_published'=>(bool)($data['is_published']??false),'is_featured'=>(bool)($data['is_featured']??false),'published_at'=>!empty($data['is_published'])?now():null]);return redirect()->route('administration.sonardesk.distribution')->with('status','Release '.$release->version.' created');}
public function product(Request $request){$data=$request->validate(['name'=>'required|string|max:150','description'=>'nullable|string','version'=>'nullable|string|max:60','platform'=>'nullable|string|max:60','is_published'=>'nullable|boolean','file'=>'required|file|max:512000']);$file=$request->file('file');$path=$file->store('sonardesk-distribution/products','local');DownloadProduct::create(['name'=>$data['name'],'description'=>$data['description']??null,'version'=>$data['version']??null,'platform'=>$data['platform']??null,'file_path'=>$path,'original_filename'=>$file->getClientOriginalName(),'file_size'=>$file->getSize(),'is_published'=>(bool)($data['is_published']??false)]);return redirect()->route('administration.sonardesk.distribution')->with('status','Download created');}
public function publish(Request $request,SonarDeskRelease $release){$data=$request->validate(['is_published'=>'required|boolean']);$release->update(['is_published'=>(bool)$data['is_published'],'published_at'=>$data['is_published']?($release->published_at??now()):null]);return back()->with('status','Release status updated');}
}
@@ -0,0 +1,98 @@
<?php
namespace Modules\Licensing\Http\Controllers\Admin;
use Illuminate\Contracts\View\View;
use Illuminate\Routing\Controller;
use Modules\Licensing\Models\License;
use Modules\Licensing\Models\LicenseProduct;
use Modules\Licensing\Models\LicenseWebhookEvent;
final class LicensingDashboardController extends Controller
{
public function index(): View
{
return view('licensing::admin.index', [
'licenseCount' => License::query()->count(),
'activeCount' => License::query()->whereNull('revoked_at')->where(function ($q): void {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})->count(),
'productCount' => LicenseProduct::query()->count(),
'recentLicenses' => License::query()->with('product')->latest()->limit(10)->get(),
]);
}
public function licenses(): View
{
return view('licensing::admin.licenses', [
'licenses' => License::query()->with('product')->latest()->paginate(25),
]);
}
public function show(License $license): View
{
return view('licensing::admin.show', [
'license' => $license->load(['product', 'activations']),
]);
}
/** Read-only snapshot of existing licensing subscription metadata. */
public function subscriptions(): View
{
$subscriptions = License::query()
->whereNotNull('external_subscription_id')
->with('product')
->orderByDesc('created_at')
->paginate(25);
$total = License::query()->whereNotNull('external_subscription_id')->count();
$active = License::query()->whereNotNull('external_subscription_id')
->whereIn('subscription_status', ['active', 'trialing'])->count();
$cancelScheduled = License::query()->whereNotNull('external_subscription_id')
->whereNotNull('cancel_scheduled_at')->count();
return view('licensing::admin.subscriptions', compact('subscriptions', 'total', 'active', 'cancelScheduled'));
}
/** Local-only billing integrity audit. Does not contact Paddle or mutate records. */
public function billingAudit(): View
{
$linked = License::query()->whereNotNull('external_subscription_id');
$duplicateSubscriptions = License::query()
->select('payment_provider', 'external_subscription_id')
->selectRaw('COUNT(*) AS license_count')
->whereNotNull('external_subscription_id')
->groupBy('payment_provider', 'external_subscription_id')
->havingRaw('COUNT(*) > 1')
->orderByDesc('license_count')
->limit(25)
->get();
$duplicateEvents = LicenseWebhookEvent::query()
->select('provider', 'external_event_id')
->selectRaw('COUNT(*) AS event_count')
->whereNotNull('external_event_id')
->groupBy('provider', 'external_event_id')
->havingRaw('COUNT(*) > 1')
->orderByDesc('event_count')
->limit(25)
->get();
return view('licensing::admin.billing-audit', [
'linkedCount' => (clone $linked)->count(),
'missingProvider' => (clone $linked)->whereNull('payment_provider')->count(),
'missingCustomer' => (clone $linked)->whereNull('external_customer_id')->count(),
'missingStatus' => (clone $linked)->whereNull('subscription_status')->count(),
'eventCount' => LicenseWebhookEvent::query()->count(),
'unprocessedEvents' => LicenseWebhookEvent::query()->whereNull('processed_at')->count(),
'failedEvents' => LicenseWebhookEvent::query()->whereNotNull('processing_error')->count(),
'duplicateSubscriptions' => $duplicateSubscriptions,
'duplicateEvents' => $duplicateEvents,
'recentFailures' => LicenseWebhookEvent::query()
->whereNotNull('processing_error')->latest()->limit(15)
->get(['id', 'provider', 'external_event_id', 'event_type', 'processing_error', 'created_at']),
]);
}
}
@@ -0,0 +1,51 @@
<?php
namespace Modules\Licensing\Http\Controllers\Admin;
use Illuminate\Contracts\View\View;
use Illuminate\Routing\Controller;
use Modules\Licensing\Models\License;
use Modules\Licensing\Services\PaddleReadOnlyClient;
use Throwable;
final class PaddleReconciliationController extends Controller
{
public function index(PaddleReadOnlyClient $paddle): View
{
$configured = trim((string) config('sonardesk-paddle.api_key')) !== '';
$environment = config('sonardesk-paddle.environment', 'sandbox');
$rows = [];
// A bounded, explicit read-only comparison; no local changes or Paddle writes.
$licenses = License::query()
->where('payment_provider', 'paddle')
->whereNotNull('external_subscription_id')
->orderByDesc('created_at')
->limit(20)
->get();
foreach ($licenses as $license) {
$result = [
'license' => $license,
'remote_status' => null,
'result' => $configured ? 'unknown' : 'not configured',
'detail' => null,
];
if ($configured) {
try {
$subscription = $paddle->subscription((string) $license->external_subscription_id);
$result['remote_status'] = (string) ($subscription['status'] ?? 'unknown');
$result['result'] = $result['remote_status'] === (string) $license->subscription_status
? 'matched' : 'different';
} catch (Throwable $e) {
report($e);
$result['result'] = 'lookup failed';
$result['detail'] = $e->getMessage();
}
}
$rows[] = $result;
}
return view('licensing::admin.paddle-reconciliation', compact('rows', 'configured', 'environment'));
}
}
@@ -0,0 +1,164 @@
<?php
namespace Modules\Licensing\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Modules\Licensing\Models\License;
use Modules\Licensing\Models\LicenseActivation;
use Modules\Licensing\Services\LicenseKey;
use Modules\Licensing\Services\LicenseSigner;
use Modules\Licensing\Services\LicenseTokenVerifier;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class LicenseActivationController extends Controller
{
public function activate(Request $request, LicenseSigner $signer): JsonResponse
{
$data = $request->validate([
'licenseKey' => ['required', 'string', 'max:128'],
'installationID' => ['required', 'uuid'],
'appVersion' => ['required', 'string', 'max:32'],
'platform' => ['required', 'string', 'max:32'],
]);
return DB::transaction(function () use ($data, $signer) {
$license = License::where('license_key_hash', LicenseKey::hash($data['licenseKey']))
->lockForUpdate()
->first();
if (! $license) {
throw ValidationException::withMessages(['licenseKey' => 'The licence key is not valid.']);
}
if (! $license->is_usable) {
return response()->json(['message' => 'This licence is expired or has been revoked.'], 403);
}
$installationID = strtolower($data['installationID']);
$activation = LicenseActivation::where('license_id', $license->id)
->where('installation_id', $installationID)
->first();
if ($activation && $activation->deactivated_at === null) {
$activation->update([
'last_seen_at' => now(),
'platform' => $data['platform'],
'app_version' => $data['appVersion'],
]);
} else {
$activeCount = LicenseActivation::where('license_id', $license->id)
->whereNull('deactivated_at')
->count();
if ($activeCount >= $license->activation_limit) {
return response()->json(['message' => 'This licence has reached its activation limit. Deactivate another Mac first.'], 409);
}
if ($activation) {
$activation->update([
'deactivated_at' => null,
'activated_at' => now(),
'last_seen_at' => now(),
'platform' => $data['platform'],
'app_version' => $data['appVersion'],
]);
} else {
LicenseActivation::create([
'license_id' => $license->id,
'installation_id' => $installationID,
'platform' => $data['platform'],
'app_version' => $data['appVersion'],
'activated_at' => now(),
'last_seen_at' => now(),
]);
}
}
return response()->json([
'licenseToken' => $signer->sign($license, $data['installationID']),
]);
});
}
public function validateInstallation(
Request $request,
LicenseTokenVerifier $verifier,
LicenseSigner $signer,
): JsonResponse {
$data = $request->validate([
'licenseToken' => ['required', 'string', 'max:8192'],
'installationID' => ['required', 'uuid'],
'appVersion' => ['required', 'string', 'max:32'],
'platform' => ['required', 'string', 'max:32'],
]);
try {
$payload = $verifier->verify($data['licenseToken']);
} catch (\Throwable) {
return response()->json(['message' => 'The licence token is invalid.'], 400);
}
if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) {
return response()->json(['message' => 'The installation does not match this licence token.'], 400);
}
return DB::transaction(function () use ($data, $payload, $signer) {
$license = License::whereKey((string) ($payload['licenseID'] ?? ''))
->lockForUpdate()
->first();
if (! $license || ! $license->is_usable) {
return response()->json(['message' => 'This licence is expired or has been revoked.'], 403);
}
$activation = LicenseActivation::where('license_id', $license->id)
->where('installation_id', strtolower($data['installationID']))
->first();
if (! $activation || $activation->deactivated_at !== null) {
return response()->json(['message' => 'This Mac has been deactivated from the licence portal.'], 403);
}
$activation->update([
'last_seen_at' => now(),
'platform' => $data['platform'],
'app_version' => $data['appVersion'],
]);
return response()->json([
'active' => true,
// Refreshing the signed token also applies subscription edition/expiry changes
// without requiring the user to reactivate manually.
'licenseToken' => $signer->sign($license, $data['installationID']),
]);
});
}
public function deactivate(Request $request, LicenseTokenVerifier $verifier): JsonResponse
{
$data = $request->validate([
'licenseToken' => ['required', 'string', 'max:8192'],
'installationID' => ['required', 'uuid'],
]);
try {
$payload = $verifier->verify($data['licenseToken']);
} catch (\Throwable) {
return response()->json(['message' => 'The licence token is invalid.'], 400);
}
if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) {
return response()->json(['message' => 'The installation does not match this licence token.'], 400);
}
LicenseActivation::where('license_id', (string) ($payload['licenseID'] ?? ''))
->where('installation_id', strtolower($data['installationID']))
->whereNull('deactivated_at')
->update(['deactivated_at' => now(), 'last_seen_at' => now()]);
return response()->json((object) []);
}
}
@@ -0,0 +1,191 @@
<?php
namespace Modules\Licensing\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Modules\Licensing\Models\LicenseWebhookEvent;
use Modules\Licensing\Services\LicenseDeliveryService;
use App\Services\Payments\LemonSqueezyWebhookVerifier;
use Modules\Paddle\Services\PaddleWebhookVerifier;
use Modules\Paddle\Services\PurchaseProvisioner;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Throwable;
final class PaymentWebhookController extends Controller
{
public function __invoke(
string $provider,
Request $request,
PurchaseProvisioner $provisioner,
LicenseDeliveryService $delivery
): JsonResponse {
$raw = (string) $request->getContent();
$verifier = match ($provider) {
'paddle' => app(PaddleWebhookVerifier::class),
'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class),
default => null,
};
if (! $verifier) {
return response()->json([
'message' => 'Unknown webhook provider.',
], 404);
}
try {
$verifier->verify($request, $raw);
} catch (Throwable $e) {
report($e);
return response()->json([
'message' => 'Invalid webhook signature.',
], 400);
}
try {
$payload = json_decode(
$raw,
true,
flags: JSON_THROW_ON_ERROR
);
} catch (Throwable) {
return response()->json([
'message' => 'Invalid JSON.',
], 400);
}
$externalID = (string) (
$provider === 'paddle'
? ($payload['event_id'] ?? '')
: ($payload['meta']['event_name'] ?? '')
.':'
.($payload['data']['id'] ?? '')
);
$eventType = (string) (
$provider === 'paddle'
? ($payload['event_type'] ?? 'unknown')
: ($payload['meta']['event_name'] ?? 'unknown')
);
if ($externalID === '') {
return response()->json([
'message' => 'Webhook event has no stable identifier.',
], 400);
}
$payloadHash = hash('sha256', $raw);
try {
/*
* Paddle can deliver several related events concurrently.
*
* Laravel retries this complete transaction when MySQL reports
* a deadlock. Five attempts gives short-lived lock contention
* enough opportunity to resolve without acknowledging an event
* that was not actually processed.
*/
DB::transaction(
function () use (
$provider,
$externalID,
$eventType,
$payload,
$payloadHash,
$provisioner
): void {
/*
* Insert the event if this is its first delivery.
*
* Once present, lock the row so duplicate deliveries of
* the same event cannot provision concurrently.
*/
LicenseWebhookEvent::firstOrCreate(
[
'provider' => $provider,
'external_event_id' => $externalID,
],
[
'event_type' => $eventType,
'payload_sha256' => $payloadHash,
'payload' => $payload,
]
);
$event = LicenseWebhookEvent::query()
->where('provider', $provider)
->where('external_event_id', $externalID)
->lockForUpdate()
->firstOrFail();
if ($event->processed_at) {
return;
}
if (! hash_equals(
(string) $event->payload_sha256,
$payloadHash
)) {
throw new RuntimeException(
'Webhook ID was reused with a different payload.'
);
}
try {
$provisioner->provision(
$provider,
$payload
);
$event->processed_at = now();
$event->processing_error = null;
$event->save();
} catch (Throwable $e) {
/*
* This assignment is useful when the surrounding
* transaction eventually succeeds. If processing
* throws, the transaction itself is rolled back and
* Paddle will retry the event.
*/
$event->processing_error = mb_substr(
$e->getMessage(),
0,
4000
);
$event->save();
throw $e;
}
},
5
);
} catch (Throwable $e) {
report($e);
return response()->json([
'message' => 'Webhook could not be processed.',
], 500);
}
/*
* Delivery deliberately happens outside the webhook transaction.
*
* A temporary SMTP failure must not undo a successfully processed
* payment event. The outbox retains anything that still needs
* delivery.
*/
try {
$delivery->deliverPending(10);
} catch (Throwable $e) {
report($e);
}
return response()->json([
'ok' => true,
]);
}
}
@@ -0,0 +1,42 @@
<?php
namespace Modules\Licensing\Http\Controllers;
use Illuminate\Routing\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Contracts\View\View;
/** Checkout handoff only. Billing/webhook processing remains on the legacy service. */
final class CheckoutHandoffController extends Controller
{
public function index(): View
{
return view('licensing::checkout.index', ['enabled' => $this->destination() !== null]);
}
public function redirect(): RedirectResponse
{
$destination = $this->destination();
abort_unless($destination !== null, 503, 'Checkout is not configured.');
return redirect()->away($destination, 302);
}
private function destination(): ?string
{
if (! config('sonardesk-integration.checkout_enabled')) {
return null;
}
$url = trim((string) config('sonardesk-integration.checkout_url'));
$parts = parse_url($url);
if (! is_array($parts) || strtolower($parts['scheme'] ?? '') !== 'https'
|| ! isset($parts['host']) || isset($parts['user']) || isset($parts['pass'])
|| isset($parts['fragment']) || isset($parts['port'])) {
return null;
}
$host = strtolower(rtrim($parts['host'], '.'));
$allowed = array_map(static fn ($value) => strtolower(rtrim((string) $value, '.')), config('sonardesk-integration.allowed_checkout_hosts', []));
if (! in_array($host, $allowed, true)) {
return null;
}
return $url;
}
}
@@ -0,0 +1,40 @@
<?php
namespace Modules\Licensing\Http\Controllers\Customer;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Modules\Licensing\Models\License;
final class CustomerLicensingController extends Controller
{
public function index(Request $request): View
{
$email = mb_strtolower(trim((string) $request->user()->email));
$licenses = License::query()
->with('product')
->whereRaw('LOWER(customer_email) = ?', [$email])
->latest()
->paginate(15);
return view('licensing::customer.index', compact('licenses'));
}
public function show(Request $request, License $license): View
{
// Return 404, not 403, to avoid disclosing whether a licence exists.
abort_unless(
hash_equals(
mb_strtolower(trim((string) $license->customer_email)),
mb_strtolower(trim((string) $request->user()->email))
),
404
);
$license->load(['product', 'activations']);
return view('licensing::customer.show', compact('license'));
}
}
@@ -0,0 +1,17 @@
<?php
namespace Modules\Licensing\Http\Controllers;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Storage;
use Modules\Licensing\Models\{SonarDeskRelease,DownloadProduct};
use Symfony\Component\HttpFoundation\BinaryFileResponse;
final class DistributionController extends Controller {
public function index(){ $latest=SonarDeskRelease::published()->where('channel','stable')->orderByDesc('published_at')->first(); $releases=SonarDeskRelease::published()->orderByDesc('published_at')->paginate(12);$products=DownloadProduct::where('is_published',true)->whereNotNull('file_path')->orderBy('name')->get();return view('licensing::downloads.index',compact('latest','releases','products')); }
public function release(SonarDeskRelease $release){abort_unless($release->is_published && $release->published_at && $release->file_path,404);return view('licensing::downloads.release',compact('release'));}
public function product(DownloadProduct $product){abort_unless($product->is_published && $product->file_path,404);return view('licensing::downloads.product',compact('product'));}
public function latest():BinaryFileResponse{$r=SonarDeskRelease::published()->where('channel','stable')->orderByDesc('published_at')->firstOrFail();return $this->send($r);}
public function download(SonarDeskRelease $release):BinaryFileResponse{abort_unless($release->is_published && $release->published_at,404);return $this->send($release);}
public function productDownload(DownloadProduct $product):BinaryFileResponse{abort_unless($product->is_published,404);$path=$this->safePath($product->file_path);$product->increment('download_count');return response()->download($path,$product->original_filename ?: basename($path));}
private function safePath(?string $relative):string {abort_unless($relative && preg_match('~^sonardesk-distribution/[a-zA-Z0-9_./-]+$~D',$relative) && !str_contains($relative,'..'),404);$disk=Storage::disk('local');abort_unless($disk->exists($relative),404);$base=realpath($disk->path('sonardesk-distribution'));$file=realpath($disk->path($relative));abort_unless($base && $file && str_starts_with($file,$base.DIRECTORY_SEPARATOR),404);return $file;}
private function send(SonarDeskRelease $r):BinaryFileResponse{$path=$this->safePath($r->file_path);$r->increment('download_count');return response()->download($path,$r->original_filename ?: 'SonarDesk-'.$r->version.'.dmg');}
public function appcast(){ $releases=SonarDeskRelease::published()->where('channel','stable')->whereNotNull('sparkle_signature')->orderByDesc('published_at')->limit(20)->get();return response()->view('licensing::downloads.appcast',compact('releases'),200,['Content-Type'=>'application/rss+xml; charset=UTF-8','Cache-Control'=>'public, max-age=300']); }
}
@@ -0,0 +1,79 @@
<?php
namespace Modules\Licensing\Http\Controllers;
use Illuminate\Contracts\Support\Renderable;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class LicensingController extends Controller
{
/**
* Display a listing of the resource.
* @return Renderable
*/
public function index()
{
return view('licensing::index');
}
/**
* Show the form for creating a new resource.
* @return Renderable
*/
public function create()
{
return view('licensing::create');
}
/**
* Store a newly created resource in storage.
* @param Request $request
* @return Renderable
*/
public function store(Request $request)
{
//
}
/**
* Show the specified resource.
* @param int $id
* @return Renderable
*/
public function show($id)
{
return view('licensing::show');
}
/**
* Show the form for editing the specified resource.
* @param int $id
* @return Renderable
*/
public function edit($id)
{
return view('licensing::edit');
}
/**
* Update the specified resource in storage.
* @param Request $request
* @param int $id
* @return Renderable
*/
public function update(Request $request, $id)
{
//
}
/**
* Remove the specified resource from storage.
* @param int $id
* @return Renderable
*/
public function destroy($id)
{
//
}
}
@@ -0,0 +1,143 @@
<?php
namespace Modules\Licensing\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use Modules\Licensing\Models\License;
use Modules\Licensing\Models\LicenseDeliveryOutbox;
use Modules\Licensing\Models\LicenseProduct;
use Modules\Paddle\Services\PaddleClient;
use Modules\Licensing\Services\LicenseDeliveryService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use RuntimeException;
final class PortalController extends Controller
{
public function __construct(private PaddleClient $paddle, private LicenseDeliveryService $delivery) {}
private function owned(Request $request)
{
return License::with(['product', 'activations'])
->whereRaw('lower(customer_email) = ?', [strtolower($request->user()->email)]);
}
public function index(Request $request)
{
$licenses = $this->owned($request)->latest()->get();
$hasProfessional = $licenses->contains(fn (License $license) => $license->is_usable && $license->edition === 'Professional');
return view('portal.index', compact('licenses', 'hasProfessional'));
}
public function show(Request $request, License $license)
{
$this->authorise($request, $license);
$license->load(['product', 'activations']);
return view('portal.show', compact('license'));
}
public function upgrade(Request $request, License $license): RedirectResponse
{
$this->authorise($request, $license);
abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422);
abort_unless($license->edition === 'Personal' && $license->is_usable, 422);
$targetPriceID = $this->professionalPriceFor($license);
$targetProduct = LicenseProduct::query()
->where('payment_provider', 'paddle')
->where('external_price_id', $targetPriceID)
->where('active', true)
->firstOrFail();
try {
$subscription = $this->paddle->updateSubscriptionPrice($license->external_subscription_id, $targetPriceID);
} catch (RuntimeException $exception) {
return back()->withErrors(['licence' => $exception->getMessage()]);
}
// Apply the entitlement immediately. Paddle webhooks remain authoritative and
// will reconcile subscription status/current-period details afterwards.
$license->update([
'license_product_id' => $targetProduct->id,
'edition' => $targetProduct->edition,
'activation_limit' => $targetProduct->activation_limit,
'max_major_version' => $targetProduct->max_major_version,
'external_price_id' => $targetPriceID,
'subscription_status' => (string) ($subscription['status'] ?? $license->subscription_status),
'revoked_at' => null,
'revoke_reason' => null,
]);
return redirect()->route('portal.show', $license)->with('status', 'Upgraded to Professional. Your existing Paddle subscription was changed; no second subscription was created.');
}
public function cancel(Request $request, License $license): RedirectResponse
{
$this->authorise($request, $license);
abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422);
abort_unless(! in_array($license->subscription_status, ['canceled'], true), 422);
try {
$this->paddle->cancelSubscriptionImmediately($license->external_subscription_id);
} catch (RuntimeException $exception) {
return back()->withErrors(['licence' => $exception->getMessage()]);
}
$license->update([
'subscription_status' => 'canceled',
'expires_at' => now(),
'revoked_at' => now(),
'revoke_reason' => 'Subscription canceled from licence portal',
'cancel_scheduled_at' => null,
]);
return redirect()->route('portal.index')->with('status', 'Subscription canceled in Paddle and the licence is no longer active.');
}
public function deactivate(Request $request, License $license, int $activation)
{
$this->authorise($request, $license);
$item = $license->activations()->whereKey($activation)->firstOrFail();
if (! $item->deactivated_at) {
$item->update(['deactivated_at' => now()]);
}
return back()->with('status', 'Mac deactivated. SonarDesk will detect this through the licensing API.');
}
public function resend(Request $request, License $license)
{
$this->authorise($request, $license);
$out = LicenseDeliveryOutbox::where('license_id', $license->id)->first();
if (! $out) {
return back()->withErrors(['licence' => 'This licence was issued manually and its full key is not recoverable. Contact support.']);
}
$out->update([
'recipient_email' => strtolower($request->user()->email),
'delivered_at' => null,
]);
$this->delivery->deliverPending(1);
return back()->with('status', 'Licence key sent to your verified email address.');
}
private function authorise(Request $request, License $license): void
{
abort_unless(strtolower((string) $license->customer_email) === strtolower($request->user()->email), 404);
}
private function professionalPriceFor(License $license): string
{
$paddle = config('paddle');
$current = (string) $license->external_price_id;
if ($current === (string) ($paddle['personal_annual_price_id'] ?? '')) {
return (string) ($paddle['professional_annual_price_id'] ?? '');
}
return (string) ($paddle['professional_monthly_price_id'] ?? '');
}
}
View File
@@ -0,0 +1,8 @@
<?php
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Model;
final class DownloadProduct extends Model {
protected $table='download_products';
protected $fillable=['name','description','version','platform','file_path','original_filename','file_size','is_published'];
protected function casts():array{return ['is_published'=>'boolean','file_size'=>'integer'];}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class License extends Model
{
use HasUuids;
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = ['license_product_id', 'license_key_hash', 'license_key_last4', 'customer_name', 'customer_email', 'edition', 'activation_limit', 'max_major_version', 'expires_at', 'revoked_at', 'revoke_reason', 'payment_provider', 'external_order_id', 'external_customer_id', 'external_subscription_id', 'external_price_id', 'subscription_status', 'current_period_ends_at', 'cancel_scheduled_at'];
protected $casts = ['expires_at' => 'datetime', 'revoked_at' => 'datetime', 'activation_limit' => 'integer', 'max_major_version' => 'integer', 'current_period_ends_at' => 'datetime', 'cancel_scheduled_at' => 'datetime'];
public function product(): BelongsTo
{
return $this->belongsTo(LicenseProduct::class, 'license_product_id');
}
public function activations(): HasMany
{
return $this->hasMany(LicenseActivation::class);
}
public function getIsUsableAttribute(): bool
{
return $this->revoked_at === null && ($this->expires_at === null || $this->expires_at->isFuture());
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Model;
class LicenseActivation extends Model
{
public $timestamps = false;
protected $fillable = ['license_id', 'installation_id', 'installation_name', 'platform', 'app_version', 'activated_at', 'last_seen_at', 'deactivated_at'];
protected $casts = ['activated_at' => 'datetime', 'last_seen_at' => 'datetime', 'deactivated_at' => 'datetime'];
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LicenseDeliveryOutbox extends Model
{
protected $table = 'license_delivery_outbox';
protected $fillable = ['license_id', 'encrypted_license_key', 'recipient_email', 'delivered_at'];
protected $casts = ['delivered_at' => 'datetime'];
public function license(): BelongsTo
{
return $this->belongsTo(License::class);
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Model;
class LicenseProduct extends Model
{
protected $fillable = ['code', 'name', 'edition', 'activation_limit', 'max_major_version', 'payment_provider', 'external_product_id', 'external_price_id', 'active'];
protected $casts = ['active' => 'boolean', 'activation_limit' => 'integer', 'max_major_version' => 'integer'];
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Model;
class LicenseWebhookEvent extends Model
{
protected $fillable = ['provider', 'external_event_id', 'event_type', 'payload_sha256', 'payload', 'processed_at', 'processing_error'];
protected $casts = ['payload' => 'array', 'processed_at' => 'datetime'];
}
@@ -0,0 +1,9 @@
<?php
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Model;
final class SonarDeskRelease extends Model {
protected $table='sonardesk_releases';
protected $fillable=['version','build_number','channel','minimum_macos','file_path','original_filename','file_size','sha256','sparkle_signature','release_notes','is_published','is_featured','published_at'];
protected function casts():array{return ['is_published'=>'boolean','is_featured'=>'boolean','published_at'=>'datetime','file_size'=>'integer','build_number'=>'integer'];}
public function scopePublished($query){return $query->where('is_published',true)->whereNotNull('file_path')->whereNotNull('published_at');}
}
@@ -0,0 +1,40 @@
<?php
namespace Modules\Licensing\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use App\Models\User;
/** Explicitly mounted licensing API. Portal integration remains gated pending auth review. */
final class IntegratedLicensingServiceProvider extends ServiceProvider
{
public function boot(): void
{
RateLimiter::for('license-activation', static fn (Request $request): array => [
Limit::perMinute(12)->by($request->ip()),
Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))),
]);
$this->mergeConfigFrom(base_path('config/sonardesk-paddle.php'), 'sonardesk-paddle');
$this->mergeConfigFrom(base_path('config/sonardesk-integration.php'), 'sonardesk-integration');
$this->loadMigrationsFrom(__DIR__.'/../Database/Migrations');
$this->loadViewsFrom(__DIR__.'/../Resources/views', 'licensing');
$this->mergeConfigFrom(__DIR__.'/../Config/config.php', 'licensing');
// Explicit role-based authorization; deny access by default.
Gate::define('manage-sonardesk-licensing', static fn (User $user): bool =>
($user->hasPermissionTo('manage-sonardesk-licensing', 'web') || $user->hasAnyRole(['Super Admin', 'super-admin', 'Administrator']))
);
Route::prefix('api')->middleware('api')->group(__DIR__.'/../Routes/api.php');
Route::group([], __DIR__.'/../Routes/admin.php');
Route::group([], __DIR__.'/../Routes/customer.php');
Route::group([], __DIR__.'/../Routes/distribution.php');
Route::group([], __DIR__.'/../Routes/checkout.php');
}
}
@@ -0,0 +1,171 @@
<?php
namespace Modules\Licensing\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Database\Eloquent\Factory;
use Illuminate\Support\Facades\File;
use Modules\Licensing\Support\ModuleInfo;
use Config;
class LicensingServiceProvider extends ServiceProvider
{
/**
* @var string $moduleName
*/
protected string $moduleName = 'Licensing';
/**
* @var string $moduleNameLower
*/
protected string $moduleNameLower = 'licensing';
/**
* Get the services provided by the provider.
*
* @return array
*/
public function provides():array
{
return [];
}
/**
* Boot the application events.
*
* @return void
*/
public function boot() : void
{
RateLimiter::for('login', fn (Request $request) => Limit::perMinute(5)
->by(strtolower((string) $request->input('email')).'|'.$request->ip()));
RateLimiter::for('registration', fn (Request $request) =>
Limit::perHour(5)->by($request->ip())
);
RateLimiter::for('license-activation', function (Request $request): array {
return [
Limit::perMinute(12)->by($request->ip()),
Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))),
];
});
$this->registerTranslations();
$this->registerConfig();
$this->registerViews();
$this->registerHelpers();
$this->registerCommands();
$this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
}
/**
* Register the service provider.
*
* @return void
*/
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
}
/**
* Register the helpers.
*
* @return void
*/
protected function registerHelpers(): void
{
foreach (glob(__DIR__.'/../Helpers/*.php') as $filename) {
require_once $filename;
}
}
/**
* Dynamically Register Commands.
*
* @return void
*/
public function registerCommands(): void
{
if (!$this->app->runningInConsole()) {
return;
}
$commandPath = __DIR__ . '/../Console';
$commands = collect(File::allFiles($commandPath))
->map(function ($file) {
$class = 'Modules\\' . $this->moduleName . '\\Console\\' . $file->getFilenameWithoutExtension();
return $class;
})
->filter(function ($class) {
return class_exists($class);
})
->values()
->all();
$this->commands($commands);
}
/**
* Register config.
*
* @return void
*/
protected function registerConfig(): void
{
$this->publishes([
module_path(ModuleInfo::name(), 'Config/config.php') => config_path( ModuleInfo::nameLower(). '.php'),
], 'config');
$this->mergeConfigFrom(
module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower()
);
}
/**
* Register views.
*
* @return void
*/
public function registerViews(): void
{
$viewPath = resource_path('views/modules/' . ModuleInfo::nameLower());
$sourcePath = module_path(ModuleInfo::name(), 'Resources/views');
$this->publishes([
$sourcePath => $viewPath
], ['views', ModuleInfo::nameLower() . '-module-views']);
$this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), ModuleInfo::nameLower());
}
/**
* Register translations.
*
* @return void
*/
public function registerTranslations(): void
{
$langPath = resource_path('lang/modules/' . ModuleInfo::nameLower());
if (is_dir($langPath)) {
$this->loadTranslationsFrom($langPath, ModuleInfo::nameLower());
} else {
$this->loadTranslationsFrom(module_path(ModuleInfo::name(), 'Resources/lang'), ModuleInfo::nameLower());
}
}
/**
* Regsiter View Paths
* @return array
*/
private function getPublishableViewPaths(): array
{
$paths = [];
foreach (Config::get('view.paths') as $path) {
if (is_dir($path . '/modules/' . ModuleInfo::nameLower())) {
$paths[] = $path . '/modules/' . ModuleInfo::nameLower();
}
}
return $paths;
}
}
@@ -0,0 +1,69 @@
<?php
namespace Modules\Licensing\Providers;
use Illuminate\Support\Facades\Route;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
class RouteServiceProvider extends ServiceProvider
{
/**
* The module namespace to assume when generating URLs to actions.
*
* @var string
*/
protected $moduleNamespace = 'Modules\Licensing\Http\Controllers';
/**
* Called before routes are registered.
*
* Register any model bindings or pattern based filters.
*
* @return void
*/
public function boot()
{
parent::boot();
}
/**
* Define the routes for the application.
*
* @return void
*/
public function map()
{
$this->mapApiRoutes();
$this->mapWebRoutes();
}
/**
* Define the "web" routes for the application.
*
* These routes all receive session state, CSRF protection, etc.
*
* @return void
*/
protected function mapWebRoutes()
{
Route::middleware('web')
->namespace($this->moduleNamespace)
->group(module_path('Licensing', '/Routes/web.php'));
}
/**
* Define the "api" routes for the application.
*
* These routes are typically stateless.
*
* @return void
*/
protected function mapApiRoutes()
{
Route::prefix('api')
->middleware('api')
->namespace($this->moduleNamespace)
->group(module_path('Licensing', '/Routes/api.php'));
}
}
@@ -0,0 +1,34 @@
@extends('licensing::admin.layout')
@section('title', 'SonarDesk Billing Audit')
@section('content')
<h1>Billing Audit</h1>
<p class="muted">Local database checks only. No Paddle API requests, billing changes, webhook replays or licence mutations are performed.</p>
<div class="cards">
<div class="card"><div class="muted">Subscription-linked licences</div><div class="metric">{{ number_format($linkedCount) }}</div></div>
<div class="card"><div class="muted">Missing payment provider</div><div class="metric">{{ number_format($missingProvider) }}</div></div>
<div class="card"><div class="muted">Missing customer ID</div><div class="metric">{{ number_format($missingCustomer) }}</div></div>
<div class="card"><div class="muted">Missing subscription status</div><div class="metric">{{ number_format($missingStatus) }}</div></div>
<div class="card"><div class="muted">Webhook events stored</div><div class="metric">{{ number_format($eventCount) }}</div></div>
<div class="card"><div class="muted">Unprocessed events</div><div class="metric">{{ number_format($unprocessedEvents) }}</div></div>
<div class="card"><div class="muted">Events with errors</div><div class="metric">{{ number_format($failedEvents) }}</div></div>
</div>
<h2>Duplicate subscription identifiers (first 25)</h2>
<p class="muted">Multiple licences can legitimately share a subscription. Review these before deciding whether a record is incorrect.</p>
<div class="table-wrap"><table><thead><tr><th>Provider</th><th>Subscription ID</th><th>Licences</th></tr></thead><tbody>
@forelse ($duplicateSubscriptions as $row)
<tr><td>{{ $row->payment_provider ?? '—' }}</td><td>{{ $row->external_subscription_id }}</td><td>{{ $row->license_count }}</td></tr>
@empty<tr><td colspan="3">No repeated subscription identifiers.</td></tr>@endforelse
</tbody></table></div>
<h2>Duplicate webhook event identifiers (first 25)</h2>
<div class="table-wrap"><table><thead><tr><th>Provider</th><th>Event ID</th><th>Rows</th></tr></thead><tbody>
@forelse ($duplicateEvents as $row)
<tr><td>{{ $row->provider }}</td><td>{{ $row->external_event_id }}</td><td>{{ $row->event_count }}</td></tr>
@empty<tr><td colspan="3">No duplicate event identifiers.</td></tr>@endforelse
</tbody></table></div>
<h2>Recent processing errors</h2>
<div class="table-wrap"><table><thead><tr><th>When</th><th>Provider</th><th>Event type</th><th>Event ID</th><th>Error</th></tr></thead><tbody>
@forelse ($recentFailures as $event)
<tr><td>{{ $event->created_at?->format('d M Y H:i') ?? '—' }}</td><td>{{ $event->provider }}</td><td>{{ $event->event_type }}</td><td>{{ $event->external_event_id }}</td><td>{{ \Illuminate\Support\Str::limit($event->processing_error, 240) }}</td></tr>
@empty<tr><td colspan="5">No stored processing errors.</td></tr>@endforelse
</tbody></table></div>
@endsection
@@ -0,0 +1,3 @@
@extends('licensing::admin.layout')
@section('title', 'Licensing Overview')
@section('content')<h1>SonarDesk licensing</h1><div class="cards"><div class="card"><div class="muted">Total licences</div><div class="metric">{{ number_format($licenseCount) }}</div></div><div class="card"><div class="muted">Usable licences</div><div class="metric">{{ number_format($activeCount) }}</div></div><div class="card"><div class="muted">Products</div><div class="metric">{{ number_format($productCount) }}</div></div></div><h2>Recent licences</h2><div class="table-wrap"><table><thead><tr><th>Customer</th><th>Product</th><th>Edition</th><th>Status</th><th>Created</th></tr></thead><tbody>@forelse($recentLicenses as $license)<tr><td><a href="{{ route('administration.sonardesk.licenses.show', $license) }}">{{ $license->customer_email ?: 'Unknown customer' }}</a></td><td>{{ $license->product?->name ?? '—' }}</td><td>{{ $license->edition }}</td><td>{{ $license->is_usable ? 'Usable' : 'Inactive' }}</td><td>{{ $license->created_at?->format('d M Y') }}</td></tr>@empty<tr><td colspan="5">No licences found.</td></tr>@endforelse</tbody></table></div><p><a href="{{ route('administration.sonardesk.licenses') }}">View all licences →</a></p>@endsection
@@ -0,0 +1,37 @@
{{-- Shared ABSDev administration shell; no separate SonarDesk layout or navigation. --}}
<x-layouts.app :title="trim($__env->yieldContent('title', 'SonarDesk')) . ' · SonarDesk'">
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-zinc-200 pb-5 dark:border-zinc-700">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-amber-600 dark:text-amber-400">ABSDev / Products</p>
<h1 class="mt-1 text-3xl font-bold tracking-tight text-zinc-900 dark:text-white">SonarDesk</h1>
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">Licensing administration · Read only</p>
</div>
<div class="flex flex-wrap gap-2 text-sm">
<a class="rounded-lg border border-zinc-300 px-4 py-2 hover:bg-zinc-100 dark:border-zinc-600 dark:hover:bg-zinc-800" href="{{ route('administration.sonardesk.index') }}">Overview</a>
<a class="rounded-lg border border-zinc-300 px-4 py-2 hover:bg-zinc-100 dark:border-zinc-600 dark:hover:bg-zinc-800" href="{{ route('administration.sonardesk.licenses') }}">Licences</a>
</div>
</div>
<div class="sonardesk-admin space-y-6">@yield('content')</div>
</div>
<style>
.sonardesk-admin h1{font-size:1.65rem;font-weight:700;margin-bottom:1.25rem}
.sonardesk-admin h2{font-size:1.125rem;font-weight:650;margin:1.5rem 0 .85rem}
.sonardesk-admin a{color:#b7791f;text-decoration:underline;text-underline-offset:3px}
.sonardesk-admin .cards,.sonardesk-admin .detail{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr));gap:1rem;margin-bottom:1.5rem}
.sonardesk-admin .card{border:1px solid #d4d4d8;border-radius:.85rem;padding:1.4rem;background:rgba(127,127,127,.035)}
.dark .sonardesk-admin .card{border-color:#52525b}
.sonardesk-admin .metric{font-size:2rem;font-weight:750;color:#b7791f}
.sonardesk-admin .muted,.sonardesk-admin dt{color:#71717a}
.sonardesk-admin dd{margin:.25rem 0 .8rem;overflow-wrap:anywhere}
.sonardesk-admin .table-wrap{width:100%;overflow-x:auto;border:1px solid #d4d4d8;border-radius:.85rem}
.dark .sonardesk-admin .table-wrap{border-color:#52525b}
.sonardesk-admin table{width:100%;border-collapse:collapse;text-align:left;font-size:.875rem}
.sonardesk-admin th,.sonardesk-admin td{padding:.85rem 1rem;border-bottom:1px solid #d4d4d8;vertical-align:top}
.dark .sonardesk-admin th,.dark .sonardesk-admin td{border-color:#52525b}
.sonardesk-admin th{font-size:.75rem;letter-spacing:.04em;text-transform:uppercase}
.sonardesk-admin .pill{border-radius:999px;padding:.25rem .65rem;background:#e4e4e7}
.dark .sonardesk-admin .pill{background:#3f3f46}
.sonardesk-admin .pager{margin-top:1.25rem}
</style>
</x-layouts.app>
@@ -0,0 +1,3 @@
@extends('licensing::admin.layout')
@section('title', 'Licences')
@section('content')<h1>Licences</h1><div class="table-wrap"><table><thead><tr><th>Customer</th><th>Product</th><th>Edition</th><th>Provider</th><th>Status</th><th>Created</th></tr></thead><tbody>@forelse($licenses as $license)<tr><td><a href="{{ route('administration.sonardesk.licenses.show', $license) }}">{{ $license->customer_email ?: 'Unknown customer' }}</a></td><td>{{ $license->product?->name ?? '—' }}</td><td>{{ $license->edition }}</td><td>{{ $license->payment_provider ?? '—' }}</td><td>{{ $license->is_usable ? 'Usable' : 'Inactive' }}</td><td>{{ $license->created_at?->format('d M Y') }}</td></tr>@empty<tr><td colspan="6">No licences found.</td></tr>@endforelse</tbody></table></div><div class="pager">{{ $licenses->links() }}</div>@endsection
@@ -0,0 +1,22 @@
@extends('licensing::admin.layout')
@section('title', 'Paddle Reconciliation')
@section('content')
<h1>Paddle Reconciliation</h1>
<p class="muted">Read-only comparison of the 20 most recent Paddle-linked licences against Paddle {{ $environment }}. This page never changes subscriptions, licences or webhooks.</p>
@if (! $configured)
<div class="card"><strong>Paddle API not configured.</strong><p>Set <code>SONARDESK_PADDLE_API_KEY</code> and <code>SONARDESK_PADDLE_ENVIRONMENT</code> in your local environment, then clear configuration cache. No API calls were made.</p></div>
@endif
<div class="table-wrap"><table><thead><tr><th>Licence</th><th>Subscription</th><th>Local status</th><th>Paddle status</th><th>Comparison</th></tr></thead><tbody>
@forelse ($rows as $row)
<tr>
<td><a href="{{ route('administration.sonardesk.licenses.show', $row['license']) }}">{{ $row['license']->license_key_last4 ?? $row['license']->id }}</a></td>
<td>{{ $row['license']->external_subscription_id }}</td>
<td>{{ $row['license']->subscription_status ?? '—' }}</td>
<td>{{ $row['remote_status'] ?? '—' }}</td>
<td><span class="pill">{{ $row['result'] }}</span>@if ($row['detail']) <span class="muted">{{ $row['detail'] }}</span> @endif</td>
</tr>
@empty
<tr><td colspan="5">No Paddle-linked licences found in the local database.</td></tr>
@endforelse
</tbody></table></div>
@endsection
@@ -0,0 +1,3 @@
@extends('licensing::admin.layout')
@section('title', 'Licence details')
@section('content')<h1>Licence details</h1><div class="detail"><section class="card"><h2>Customer and licence</h2><dl><dt>Customer</dt><dd>{{ $license->customer_name ?? '—' }}</dd><dt>Email</dt><dd>{{ $license->customer_email ?? '—' }}</dd><dt>Product</dt><dd>{{ $license->product?->name ?? '—' }}</dd><dt>Edition</dt><dd>{{ $license->edition }}</dd><dt>Key (last four only)</dt><dd>•••• {{ $license->license_key_last4 }}</dd><dt>Status</dt><dd>{{ $license->is_usable ? 'Usable' : 'Inactive' }}</dd></dl></section><section class="card"><h2>Subscription</h2><dl><dt>Provider</dt><dd>{{ $license->payment_provider ?? '—' }}</dd><dt>Subscription status</dt><dd>{{ $license->subscription_status ?? '—' }}</dd><dt>Period ends</dt><dd>{{ $license->current_period_ends_at?->format('d M Y') ?? '—' }}</dd><dt>Expires</dt><dd>{{ $license->expires_at?->format('d M Y') ?? '—' }}</dd><dt>Activation limit</dt><dd>{{ $license->activation_limit }}</dd></dl></section></div><h2>Device activations</h2><div class="table-wrap"><table><thead><tr><th>Platform</th><th>Version</th><th>Activated</th><th>Last seen</th><th>Status</th></tr></thead><tbody>@forelse($license->activations as $activation)<tr><td>{{ $activation->platform ?? '—' }}</td><td>{{ $activation->app_version ?? '—' }}</td><td>{{ $activation->activated_at?->format('d M Y H:i') ?? '—' }}</td><td>{{ $activation->last_seen_at?->format('d M Y H:i') ?? '—' }}</td><td>{{ $activation->deactivated_at ? 'Deactivated' : 'Active' }}</td></tr>@empty<tr><td colspan="5">No device activations.</td></tr>@endforelse</tbody></table></div>@endsection
@@ -0,0 +1,25 @@
@extends('licensing::admin.layout')
@section('title', 'SonarDesk Subscriptions')
@section('content')
<h1>Subscriptions</h1>
<p class="muted">Read-only subscription records stored in ABSDev. This is not a live Paddle reconciliation.</p>
<div class="cards">
<div class="card"><div class="muted">Subscription-linked licences</div><div class="metric">{{ number_format($total) }}</div></div>
<div class="card"><div class="muted">Active / trialing</div><div class="metric">{{ number_format($active) }}</div></div>
<div class="card"><div class="muted">Cancellation scheduled</div><div class="metric">{{ number_format($cancelScheduled) }}</div></div>
</div>
<div class="table-wrap"><table><thead><tr><th>Customer</th><th>Product</th><th>Provider</th><th>Status</th><th>Period ends</th><th>Cancellation</th><th>Licence</th></tr></thead><tbody>
@forelse($subscriptions as $subscription)
<tr>
<td>{{ $subscription->customer_email ?: '—' }}</td>
<td>{{ $subscription->product?->name ?? '—' }}</td>
<td>{{ $subscription->payment_provider ?: '—' }}</td>
<td><span class="pill">{{ $subscription->subscription_status ?: 'Unknown' }}</span></td>
<td>{{ $subscription->current_period_ends_at?->format('d M Y') ?? '—' }}</td>
<td>{{ $subscription->cancel_scheduled_at?->format('d M Y') ?? '—' }}</td>
<td><a href="{{ route('administration.sonardesk.licenses.show', $subscription) }}">View licence</a></td>
</tr>
@empty<tr><td colspan="7">No subscription-linked licences in the local database.</td></tr>@endforelse
</tbody></table></div>
<div class="pager">{{ $subscriptions->links() }}</div>
@endsection
@@ -0,0 +1,15 @@
@extends('licensing::customer.layout')
@section('title', 'SonarDesk · Purchase')
@section('content')
<section class="card">
<h1>SonarDesk</h1>
<p>Native SonarQube project monitoring for macOS.</p>
<p>Choose your licence on our secure checkout service. Your subscription and licence are processed by the existing SonarDesk licensing platform.</p>
@if($enabled)
<p><a href="{{ route('sonardesk.checkout') }}" style="display:inline-block;background:#163b70;color:#fff;padding:12px 20px;border-radius:8px;text-decoration:none">View plans &amp; buy</a></p>
@else
<p role="status">Online checkout is not yet enabled on this site. No payment will be taken here.</p>
@endif
<p><a href="{{ route('sonardesk.downloads') }}">Downloads &amp; release notes</a> · <a href="{{ route('account.sonardesk.index') }}">My licences</a></p>
</section>
@endsection
@@ -0,0 +1,14 @@
@extends('licensing::customer.layout')
@section('title', 'My SonarDesk Licences')
@section('content')
<h1>My SonarDesk licences</h1>
<p class="muted">Licences associated with your verified ABSDev account email. This portal is read-only; existing billing and licence activation services remain unchanged.</p>
<div class="card scroll"><table><thead><tr><th>Edition</th><th>Licence</th><th>Subscription</th><th>Expiry</th><th></th></tr></thead><tbody>
@forelse($licenses as $license)
<tr><td><strong>{{ $license->edition }}</strong></td><td>•••• {{ $license->license_key_last4 ?? '—' }}</td><td><span class="pill">{{ $license->subscription_status ?? 'Not linked' }}</span></td><td>{{ $license->expires_at?->format('d M Y') ?? '—' }}</td><td><a href="{{ route('account.sonardesk.licenses.show', $license) }}">View details</a></td></tr>
@empty
<tr><td colspan="5">No SonarDesk licences match your account email. If you purchased with a different email, contact support to verify ownership; licences cannot be claimed automatically.</td></tr>
@endforelse
</tbody></table></div>
{{ $licenses->links() }}
@endsection
@@ -0,0 +1,7 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('title', 'SonarDesk Account') · ABSDev</title>
<style>
:root{color-scheme:light dark;font-family:system-ui,-apple-system,sans-serif}body{margin:0;background:#f5f7fb;color:#19253a}header{background:#112440;color:#fff;padding:18px max(20px,calc((100vw - 1100px)/2))}header a{color:#e8c36b;text-decoration:none}main{max-width:1100px;margin:35px auto;padding:0 20px}.card{background:#fff;border:1px solid #dbe2ee;border-radius:12px;padding:22px;margin:16px 0;box-shadow:0 4px 16px #10233d0a}a{color:#19579b}table{border-collapse:collapse;width:100%}th,td{padding:12px;text-align:left;border-bottom:1px solid #e5eaf2}th{color:#53627b}small,.muted{color:#59677e}.scroll{overflow-x:auto}.pill{border-radius:99px;background:#eef3fa;padding:5px 10px;display:inline-block}h1{font-size:1.8rem}@media(prefers-color-scheme:dark){body{background:#0e1725;color:#e9eef8}.card{background:#18263b;border-color:#33435a}th,td{border-color:#33435a}th,.muted,small{color:#bdc7d8}.pill{background:#33435a}a{color:#a8cfff}}
</style></head><body><header><strong>ABSDev · SonarDesk</strong> &nbsp; <a href="{{ route('account.sonardesk.index') }}">My licences</a></header>
<main>@yield('content')</main></body></html>
@@ -0,0 +1,12 @@
@extends('licensing::customer.layout')
@section('title', 'Licence Details')
@section('content')
<p><a href="{{ route('account.sonardesk.index') }}">← My licences</a></p>
<h1>SonarDesk {{ $license->edition }}</h1>
<div class="card"><h2>Licence</h2><p><strong>Key ending:</strong> {{ $license->license_key_last4 ?? '—' }}</p><p><strong>Status:</strong> {{ $license->revoked_at ? 'Revoked' : ($license->is_usable ? 'Usable' : 'Expired') }}</p><p><strong>Subscription:</strong> {{ $license->subscription_status ?? 'Not linked' }}</p><p><strong>Current period ends:</strong> {{ $license->current_period_ends_at?->format('d M Y') ?? '—' }}</p><p><strong>Activation limit:</strong> {{ $license->activation_limit }}</p><p class="muted">Full licence keys are not displayed or recoverable from their stored hashes.</p></div>
<div class="card scroll"><h2>Devices</h2><table><thead><tr><th>Installation</th><th>Status</th></tr></thead><tbody>
@forelse($license->activations as $activation)
<tr><td>{{ $activation->installation_id ?? $activation->installationID ?? 'Device' }}</td><td>{{ $activation->deactivated_at ? 'Deactivated' : 'Active' }}</td></tr>
@empty<tr><td colspan="2">No activations recorded.</td></tr>@endforelse
</tbody></table></div>
@endsection
@@ -0,0 +1,9 @@
@extends('licensing::admin.layout')
@section('content')
<h1>Downloads &amp; releases</h1>@if(session('status'))<p>{{ session('status') }}</p>@endif
<h2>Publish SonarDesk release</h2><form method="POST" enctype="multipart/form-data" action="{{ route('administration.sonardesk.distribution.releases.store') }}">@csrf
<p><input name="version" placeholder="Version (e.g. 1.2.0)" required> <input name="build_number" type="number" min="1" placeholder="Build" required> <select name="channel"><option value="stable">Stable</option><option value="beta">Beta</option></select></p>
<p><input name="minimum_macos" placeholder="Minimum macOS version"><input type="file" name="file" accept=".dmg,.zip,.pkg" required></p><p><textarea name="release_notes" placeholder="Full release notes" rows="5" cols="70"></textarea></p><p><input name="sparkle_signature" placeholder="Sparkle EdDSA signature (optional)" size="65"></p><label><input type="checkbox" name="is_published" value="1"> Publish</label> <button type="submit">Create release</button></form>
<h2>Releases</h2><table><thead><tr><th>Version</th><th>Channel</th><th>Status</th><th>Downloads</th><th>Action</th></tr></thead><tbody>@foreach($releases as $r)<tr><td>{{ $r->version }} ({{ $r->build_number }})</td><td>{{ $r->channel }}</td><td>{{ $r->is_published ? 'Published' : 'Draft' }}</td><td>{{ $r->download_count }}</td><td><form method="POST" action="{{ route('administration.sonardesk.distribution.releases.publish',$r) }}">@csrf @method('PATCH')<input type="hidden" name="is_published" value="{{ $r->is_published ? 0 : 1 }}"><button>{{ $r->is_published ? 'Unpublish' : 'Publish' }}</button></form></td></tr>@endforeach</tbody></table>{{ $releases->links() }}
<h2>Add other download (e.g. SonarDisk)</h2><form method="POST" enctype="multipart/form-data" action="{{ route('administration.sonardesk.distribution.products.store') }}">@csrf<p><input name="name" placeholder="Product name" required><input name="version" placeholder="Version"><input name="platform" placeholder="Platform"></p><p><textarea name="description" placeholder="Description"></textarea></p><p><input name="file" type="file" required></p><label><input type="checkbox" name="is_published" value="1"> Publish</label><button>Add download</button></form><h2>Other downloads</h2><ul>@foreach($products as $p)<li>{{ $p->name }} {{ $p->version }} — {{ $p->download_count }} downloads ({{ $p->is_published ? 'published' : 'draft' }})</li>@endforeach</ul>
@endsection
@@ -0,0 +1,2 @@
@php echo '<?xml version="1.0" encoding="utf-8"?>'; @endphp
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"><channel><title>SonarDesk Updates</title><link>{{ route('sonardesk.updates.appcast') }}</link><description>SonarDesk stable release feed</description>@foreach($releases as $release)<item><title>SonarDesk {{ $release->version }}</title><pubDate>{{ $release->published_at?->toRfc2822String() }}</pubDate><sparkle:version>{{ $release->build_number }}</sparkle:version><sparkle:shortVersionString>{{ $release->version }}</sparkle:shortVersionString>@if($release->minimum_macos)<sparkle:minimumSystemVersion>{{ $release->minimum_macos }}</sparkle:minimumSystemVersion>@endif<description><![CDATA[{!! nl2br(e($release->release_notes ?: 'SonarDesk update')) !!}]]></description><enclosure url="{{ route('sonardesk.releases.download',$release) }}" length="{{ $release->file_size }}" type="application/octet-stream" sparkle:edSignature="{{ $release->sparkle_signature }}" /></item>@endforeach</channel></rss>
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SonarDesk Downloads</title><style>body{font:16px system-ui;background:#f6f8fc;color:#17243c;margin:0}.wrap{max-width:1120px;margin:48px auto;padding:0 24px}h1{font-size:42px;margin-bottom:6px}.card{background:white;border:1px solid #dce3ee;border-radius:14px;padding:22px;margin:18px 0}a.button{display:inline-block;background:#183b73;color:white;padding:10px 18px;border-radius:8px;text-decoration:none}small{color:#58657a}ul{line-height:2}</style></head><body><main class="wrap"><h1>SonarDesk Downloads</h1><p>Current applications and release history.</p>@if($latest)<section class="card"><h2>SonarDesk <small>{{ $latest->version }}</small></h2><p>{{ \Illuminate\Support\Str::limit(strip_tags($latest->release_notes ?? 'Native SonarQube dashboard for macOS.'),190) }}</p><a class="button" href="{{ route('sonardesk.releases.download',$latest) }}">Download for macOS</a> &nbsp;<a href="{{ route('sonardesk.releases.show',$latest) }}">Release notes &amp; requirements</a></section>@endif @if($products->isNotEmpty())<section class="card"><h2>Other downloads</h2><ul>@foreach($products as $product)<li><strong>{{ $product->name }}</strong> {{ $product->version }} ({{ $product->platform }}) — <a href="{{ route('sonardesk.products.download',$product) }}">Download</a> · <a href="{{ route('sonardesk.products.show',$product) }}">Details</a></li>@endforeach</ul></section>@endif<section class="card"><h2>Previous releases</h2><ul>@forelse($releases as $release)<li><strong>{{ $release->version }}</strong> — {{ $release->channel }} · <a href="{{ route('sonardesk.releases.show',$release) }}">Notes</a> · <a href="{{ route('sonardesk.releases.download',$release) }}">Download</a></li>@empty<li>No releases published.</li>@endforelse</ul>{{ $releases->links() }}</section></main></body></html>
@@ -0,0 +1 @@
<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{ $product->name }}</title></head><body style="font:16px system-ui;max-width:900px;margin:50px auto;padding:20px"><a href="{{ route('sonardesk.downloads') }}">← All downloads</a><h1>{{ $product->name }}</h1><p>Version {{ $product->version }} · {{ $product->platform }}</p><div style="white-space:pre-wrap">{{ $product->description }}</div><p><a href="{{ route('sonardesk.products.download',$product) }}">Download {{ $product->original_filename }}</a></p></body></html>
@@ -0,0 +1 @@
<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>SonarDesk {{ $release->version }}</title></head><body style="font:16px system-ui;max-width:900px;margin:50px auto;padding:20px"><a href="{{ route('sonardesk.downloads') }}">← All downloads</a><h1>SonarDesk {{ $release->version }}</h1><p>Build {{ $release->build_number }} · {{ $release->channel }}</p><h2>Release notes</h2><div style="white-space:pre-wrap">{{ $release->release_notes ?: 'No release notes provided.' }}</div><h2>System requirements</h2><p>macOS {{ $release->minimum_macos ?: 'See application documentation' }} or later.</p><p>SHA-256: <code>{{ $release->sha256 }}</code></p><a href="{{ route('sonardesk.releases.download',$release) }}">Download {{ $release->original_filename }}</a></body></html>
@@ -0,0 +1,9 @@
@extends('licensing::layouts.master')
@section('content')
<h1>Hello World</h1>
<p>
This view is loaded from module: {!! config('licensing.name') !!}
</p>
@endsection
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Module Licensing</title>
{{-- Laravel Mix - CSS File --}}
{{-- <link rel="stylesheet" href="{{ mix('css/licensing.css') }}"> --}}
</head>
<body>
@yield('content')
{{-- Laravel Mix - JS File --}}
{{-- <script src="{{ mix('js/licensing.js') }}"></script> --}}
</body>
</html>
View File
+17
View File
@@ -0,0 +1,17 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Licensing\Http\Controllers\Admin\LicensingDashboardController;
// Deliberately read-only until the identity and payment migration is verified.
Route::middleware(['web', 'auth', 'verified', 'can:manage-sonardesk-licensing'])
->prefix('administration/sonardesk')
->name('administration.sonardesk.')
->group(function (): void {
Route::get('/', [LicensingDashboardController::class, 'index'])->name('index');
Route::get('/licenses', [LicensingDashboardController::class, 'licenses'])->name('licenses');
Route::get('/billing-audit', [LicensingDashboardController::class, 'billingAudit'])->name('billing-audit');
Route::get('/paddle-reconciliation', [\Modules\Licensing\Http\Controllers\Admin\PaddleReconciliationController::class, 'index'])->name('paddle-reconciliation');
Route::get('/subscriptions', [LicensingDashboardController::class, 'subscriptions'])->name('subscriptions');
Route::get('/licenses/{license}', [LicensingDashboardController::class, 'show'])->name('licenses.show');
});
+12
View File
@@ -0,0 +1,12 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Licensing\Http\Controllers\Api\LicenseActivationController;
use Modules\Licensing\Http\Controllers\Api\PaymentWebhookController;
Route::prefix('v1')->group(function (): void {
Route::post('/activate', [LicenseActivationController::class, 'activate'])->middleware('throttle:license-activation');
Route::post('/validate', [LicenseActivationController::class, 'validateInstallation'])->middleware('throttle:license-activation');
Route::post('/deactivate', [LicenseActivationController::class, 'deactivate'])->middleware('throttle:license-activation');
Route::post('/webhooks/{provider}', PaymentWebhookController::class)->whereIn('provider', ['paddle', 'lemon-squeezy']);
});
+8
View File
@@ -0,0 +1,8 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Licensing\Http\Controllers\CheckoutHandoffController;
Route::middleware(['web', 'throttle:30,1'])->prefix('sonardesk')->name('sonardesk.')->group(function (): void {
Route::get('/buy', [CheckoutHandoffController::class, 'index'])->name('buy');
Route::get('/checkout', [CheckoutHandoffController::class, 'redirect'])->name('checkout');
});
+13
View File
@@ -0,0 +1,13 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Licensing\Http\Controllers\Customer\CustomerLicensingController;
// Read-only customer portal. No licence claiming by ID, no billing mutations.
Route::middleware(['web', 'auth', 'verified', 'throttle:30,1'])
->prefix('account/sonardesk')
->name('account.sonardesk.')
->group(function (): void {
Route::get('/', [CustomerLicensingController::class, 'index'])->name('index');
Route::get('/licenses/{license}', [CustomerLicensingController::class, 'show'])->name('licenses.show');
});
+19
View File
@@ -0,0 +1,19 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Licensing\Http\Controllers\DistributionController as PublicDownloads;
use Modules\Licensing\Http\Controllers\Admin\DistributionAdminController as AdminDownloads;
Route::middleware(['web','throttle:60,1'])->prefix('sonardesk')->name('sonardesk.')->group(function(){
Route::get('/downloads',[PublicDownloads::class,'index'])->name('downloads');
Route::get('/downloads/releases/{release}',[PublicDownloads::class,'release'])->name('releases.show');
Route::get('/downloads/products/{product}',[PublicDownloads::class,'product'])->name('products.show');
Route::get('/downloads/latest',[PublicDownloads::class,'latest'])->name('latest');
Route::get('/downloads/releases/{release}/file',[PublicDownloads::class,'download'])->name('releases.download');
Route::get('/downloads/products/{product}/file',[PublicDownloads::class,'productDownload'])->name('products.download');
Route::get('/updates/appcast.xml',[PublicDownloads::class,'appcast'])->name('updates.appcast');
});
Route::middleware(['web','auth','verified','can:manage-sonardesk-licensing'])->prefix('administration/sonardesk')->name('administration.sonardesk.')->group(function(){
Route::get('/distribution',[AdminDownloads::class,'index'])->name('distribution');
Route::post('/distribution/releases',[AdminDownloads::class,'release'])->name('distribution.releases.store');
Route::post('/distribution/products',[AdminDownloads::class,'product'])->name('distribution.products.store');
Route::patch('/distribution/releases/{release}/publish',[AdminDownloads::class,'publish'])->name('distribution.releases.publish');
});
+13
View File
@@ -0,0 +1,13 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Licensing\Http\Controllers\Portal\PortalController;
Route::middleware(['auth', 'verified'])->prefix('portal')->name('portal.')->group(function (): void {
Route::get('/', [PortalController::class, 'index'])->name('index');
Route::get('/licences/{license}', [PortalController::class, 'show'])->name('show');
Route::post('/licences/{license}/upgrade', [PortalController::class, 'upgrade'])->middleware('throttle:5,1')->name('upgrade');
Route::delete('/licences/{license}/subscription', [PortalController::class, 'cancel'])->middleware('throttle:5,1')->name('cancel');
Route::post('/licences/{license}/resend', [PortalController::class, 'resend'])->middleware('throttle:3,10')->name('resend');
Route::delete('/licences/{license}/activations/{activation}', [PortalController::class, 'deactivate'])->name('deactivate');
});
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace Modules\Licensing\Services;
use Modules\Licensing\Models\LicenseDeliveryOutbox;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Mail;
final class LicenseDeliveryService
{
public function deliverPending(int $limit = 20): int
{
$delivered = 0;
$pending = LicenseDeliveryOutbox::query()
->with('license')
->whereNull('delivered_at')
->whereNotNull('recipient_email')
->where('recipient_email', '!=', '')
->oldest()
->limit(max(1, min($limit, 100)))
->get();
foreach ($pending as $outbox) {
$license = $outbox->license;
if (! $license) {
continue;
}
$plain = Crypt::decryptString($outbox->encrypted_license_key);
$recipient = strtolower(trim((string) $outbox->recipient_email));
if ($recipient === '') {
continue;
}
$data = [
'customerName' => $license->customer_name,
'edition' => $license->edition,
'licenseKey' => $plain,
'expiresAt' => $license->expires_at,
'portalUrl' => config('sonardesk.portal_url') ?: url('/portal'),
'downloadUrl' => route('release'),
];
Mail::send(['html' => 'emails.license-key', 'text' => 'emails.license-key-text'], $data, function ($message) use ($recipient): void {
$message->to($recipient)->subject('Your SonarDesk licence key');
});
$outbox->update(['delivered_at' => now()]);
$delivered++;
}
return $delivered;
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace Modules\Licensing\Services;
use Modules\Licensing\Models\License;
use Modules\Licensing\Models\LicenseDeliveryOutbox;
use Modules\Licensing\Models\LicenseProduct;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
final class LicenseIssuer
{
public function issue(LicenseProduct $product, ?string $name, ?string $email, ?string $provider = null, ?string $orderID = null, ?string $customerID = null, bool $queueDelivery = false): array
{
return DB::transaction(function () use ($product, $name, $email, $provider, $orderID, $customerID, $queueDelivery) {
if ($provider && $orderID) {
$existing = License::where('payment_provider', $provider)->where('external_order_id', $orderID)->first();
if ($existing) {
return [$existing, null];
}
}
$plain = LicenseKey::generate();
$license = License::create([
'license_product_id' => $product->id,
'license_key_hash' => LicenseKey::hash($plain),
'license_key_last4' => substr(LicenseKey::normalize($plain), -4),
'customer_name' => $name,
'customer_email' => $email ? strtolower(trim($email)) : null,
'edition' => $product->edition,
'activation_limit' => $product->activation_limit,
'max_major_version' => $product->max_major_version,
'payment_provider' => $provider,
'external_order_id' => $orderID,
'external_customer_id' => $customerID,
]);
if ($queueDelivery) {
LicenseDeliveryOutbox::updateOrCreate(
['license_id' => $license->id],
['encrypted_license_key' => Crypt::encryptString($plain), 'recipient_email' => $license->customer_email, 'delivered_at' => null]
);
}
return [$license, $plain];
});
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Modules\Licensing\Services;
final class LicenseKey
{
public static function generate(): string
{
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$raw = '';
for ($i = 0; $i < 20; $i++) {
$raw .= $alphabet[random_int(0, strlen($alphabet) - 1)];
}
return implode('-', str_split($raw, 5));
}
public static function normalize(string $value): string
{
return strtoupper(preg_replace('/[^A-Z0-9]/i', '', trim($value)) ?? '');
}
public static function hash(string $value): string
{
return hash('sha256', self::normalize($value));
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace Modules\Licensing\Services;
use Modules\Licensing\Models\License;
use Carbon\CarbonInterface;
use RuntimeException;
final class LicenseSigner
{
public function sign(License $license, string $installationID, ?CarbonInterface $issuedAt = null): string
{
$seed = $this->privateSeed();
$pair = sodium_crypto_sign_seed_keypair($seed);
$secret = sodium_crypto_sign_secretkey($pair);
$payload = [
'customerEmail' => $license->customer_email,
'customerName' => $license->customer_name,
'edition' => $license->edition,
'expiresAt' => $license->expires_at?->utc()->format('Y-m-d\\TH:i:s\\Z'),
'installationID' => strtolower($installationID),
'issuedAt' => ($issuedAt ?? now())->utc()->format('Y-m-d\\TH:i:s\\Z'),
'licenseID' => strtolower((string) $license->id),
'maxMajorVersion' => $license->max_major_version,
];
ksort($payload, SORT_STRING);
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$sig = sodium_crypto_sign_detached($json, $secret);
return 'RS3.'.self::b64url($json).'.'.self::b64url($sig);
}
public function publicKeyBase64(): string
{
$pair = sodium_crypto_sign_seed_keypair($this->privateSeed());
return base64_encode(sodium_crypto_sign_publickey($pair));
}
private function privateSeed(): string
{
$path = config('sonardesk.private_key_path');
if (! $path || ! is_file($path)) {
throw new RuntimeException('SONARDESK_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.');
}
$decoded = base64_decode(trim((string) file_get_contents($path)), true);
if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) {
throw new RuntimeException('The SonarDesk private key must be a Base64-encoded 32-byte Ed25519 seed.');
}
return $decoded;
}
private static function b64url(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace Modules\Licensing\Services;
use RuntimeException;
final class LicenseTokenVerifier
{
public function verify(string $token): array
{
$parts = explode('.', trim($token));
if (count($parts) !== 3 || $parts[0] !== 'RS3') {
throw new RuntimeException('Invalid licence token.');
}
$payload = $this->decode($parts[1]);
$signature = $this->decode($parts[2]);
// Derive the verification key from the same private seed used by LicenseSigner.
// Keeping a separate SONARDESK_LICENSE_PUBLIC_KEY allowed the two keys to drift:
// activation would sign a perfectly valid token with the new private key, then
// /validate would reject that same token using a stale public key.
$public = $this->publicKeyFromPrivateSeed();
if (! sodium_crypto_sign_verify_detached($signature, $payload, $public)) {
throw new RuntimeException('Licence token signature is invalid.');
}
$decoded = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.');
}
private function publicKeyFromPrivateSeed(): string
{
$path = config('sonardesk.private_key_path');
if (! $path || ! is_file($path)) {
throw new RuntimeException('SONARDESK_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.');
}
$seed = base64_decode(trim((string) file_get_contents($path)), true);
if ($seed === false || strlen($seed) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) {
throw new RuntimeException('The SonarDesk private key must be a Base64-encoded 32-byte Ed25519 seed.');
}
$pair = sodium_crypto_sign_seed_keypair($seed);
return sodium_crypto_sign_publickey($pair);
}
private function decode(string $value): string
{
$value = strtr($value, '-_', '+/');
$value .= str_repeat('=', (4 - strlen($value) % 4) % 4);
$out = base64_decode($value, true);
if ($out === false) {
throw new RuntimeException('Invalid Base64URL value.');
}
return $out;
}
}
@@ -0,0 +1,49 @@
<?php
namespace Modules\Licensing\Services;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/** Read-only Paddle Billing API access; no writes or webhook registration. */
final class PaddleReadOnlyClient
{
public function subscription(string $id): array
{
if (! preg_match('/^sub_[a-zA-Z0-9]+$/', $id)) {
throw new RuntimeException('Invalid Paddle subscription identifier.');
}
$token = trim((string) config('sonardesk-paddle.api_key'));
if ($token === '') {
throw new RuntimeException('SONARDESK_PADDLE_API_KEY is not configured.');
}
$environment = config('sonardesk-paddle.environment', 'sandbox');
if (! in_array($environment, ['sandbox', 'production'], true)) {
throw new RuntimeException('Invalid Paddle environment configuration.');
}
$url = $environment === 'production'
? 'https://api.paddle.com'
: 'https://sandbox-api.paddle.com';
$response = Http::baseUrl($url)
->withToken($token)
->acceptJson()
->timeout(8)
->retry(2, 200, throw: false)
->get('/subscriptions/'.$id);
if (! $response->successful()) {
throw new RuntimeException('Paddle lookup failed (HTTP '.$response->status().').');
}
$data = $response->json('data');
if (! is_array($data)) {
throw new RuntimeException('Unexpected Paddle response.');
}
return $data;
}
}
View File
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Modules\Licensing\Support;
class ModuleInfo
{
protected static string $moduleName = 'Licensing';
/**
* defines the Module Name
* @return string
*/
public static function name(): string
{
return static::$moduleName;
}
/**
* define the lowercase version
* @return string
*/
public static function nameLower(): string
{
return strtolower(static::$moduleName);
}
}
View File
View File
+15
View File
@@ -0,0 +1,15 @@
{
"name": "mpba/licensing",
"description": "SonarDesk Licensing module",
"type": "library",
"autoload": {
"psr-4": {
"Modules\\Licensing\\": ""
}
},
"autoload-dev": {
"psr-4": {
"Modules\\Licensing\\Tests\\": "Tests/"
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "Licensing",
"alias": "licensing",
"version": "1.0.0",
"description": "SonarDesk Licensing module",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\Licensing\\Providers\\LicensingServiceProvider"
],
"aliases": {},
"files": [],
"requires": []
}
+17
View File
@@ -0,0 +1,17 @@
{
"private": true,
"scripts": {
"dev": "npm run development",
"development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"watch": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --watch --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"watch-poll": "npm run watch -- --watch-poll",
"hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js",
"prod": "npm run production",
"production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js"
},
"devDependencies": {
"cross-env": "^7.0",
"laravel-mix": "^5.0.1",
"laravel-mix-merge-manifest": "^0.1.2"
}
}
+11
View File
@@ -0,0 +1,11 @@
const dotenvExpand = require('dotenv-expand');
dotenvExpand(require('dotenv').config({ path: '../../.env'/*, debug: true*/}));
const mix = require('laravel-mix');
mix.js(__dirname + '/Resources/assets/js/app.js', 'js/licensing.js')
.sass( __dirname + '/Resources/assets/sass/app.scss', 'css/licensing.css');
if (mix.inProduction()) {
mix.version();
}
+1
View File
@@ -6,4 +6,5 @@ use App\Providers\FortifyServiceProvider;
return [
AppServiceProvider::class,
FortifyServiceProvider::class,
\Modules\Licensing\Providers\IntegratedLicensingServiceProvider::class,
];
+7
View File
@@ -0,0 +1,7 @@
<?php
return [
// Off by default. The existing SonarDesk service remains the billing authority.
'checkout_enabled' => (bool) env('SONARDESK_CHECKOUT_ENABLED', false),
'checkout_url' => env('SONARDESK_CHECKOUT_URL', ''),
'allowed_checkout_hosts' => array_values(array_filter(array_map('trim', explode(',', (string) env('SONARDESK_CHECKOUT_ALLOWED_HOSTS', ''))))),
];
+6
View File
@@ -0,0 +1,6 @@
<?php
return [
'api_key' => env('SONARDESK_PADDLE_API_KEY', ''),
'environment' => env('SONARDESK_PADDLE_ENVIRONMENT', 'sandbox'),
];
+16
View File
@@ -0,0 +1,16 @@
# ABSDev / SonarDesk integration — development scaffold
**NOT PRODUCTION READY. DO NOT DEPLOY OVER AN EXISTING LICENSING SERVER.**
Implemented:
- Preserved ABSDev application as base.
- Added SonarDesk-host-specific marketing routes and responsive ABSDev-inspired navy/gold views.
- Archived licensing source under `sonardesk-legacy` for a controlled migration. This directory is **not** wired into runtime.
Not implemented / not verified:
- Licensing API, existing download/appcast routes, payment checkout/webhooks, customer identity mapping, backend administration, data migrations and theme-manager integration.
- Laravel test suite, browser tests, PHP dependencies and deployment verification.
**Important:** Existing unscoped ABSDev routes and module routes may still collide with the SonarDesk host. The new domain routes are only an initial frontend scaffold. Do not point production SonarDesk DNS at this build.
Next steps: inventory and namespace every licensing route, reconcile User schema and Fortify authentication, port modules and database migrations without collisions, wire checkout and webhooks, add regression tests, and test on staging with anonymised production data.
+14
View File
@@ -0,0 +1,14 @@
# Phase 2: route-contract baseline (not a runtime migration)
This iteration adds a repeatable **static** route inventory (`sonardesk-route-inventory.json`), its regeneration script (`php scripts/integration/audit_routes.php`) and host-level marketing route tests.
## Production blockers
1. Extract legacy API routes from module route providers and compare the **runtime** `php artisan route:list --json` outputs of both original applications. Static regex is not a substitute for runtime route enumeration.
2. Port licensing models, migrations, services, controllers and commands with namespace isolation. Do not run legacy migrations against the ABSDev database until table/schema conflicts have been reviewed.
3. Reconcile Fortify/Spatie auth with legacy users, permissions, sessions and licence ownership.
4. Integrate Paddle/Lemon Squeezy webhook signature verification and idempotency, preserve existing endpoints and secrets outside source control.
5. Port downloads, appcast, support and release-management views/controllers into Stylist's SonarDesk theme.
6. Test with a staging database copy and synthetic payment events; only then switch DNS.
No legacy licensing endpoints have been activated by this change. The archived legacy source remains isolated.
+23
View File
@@ -0,0 +1,23 @@
# Phase 10 — Read-only customer licence portal
## Install
Extract this archive at the ABSDev project root, preserving paths. Merge `IntegratedLicensingServiceProvider.php` if locally customised. Do not replace the production licensing application.
```sh
composer dump-autoload
php artisan optimize:clear
php artisan route:list --path=account/sonardesk -v
```
Visit `/account/sonardesk` while signed in with a **verified** ABSDev account. Licences are matched to `customer_email` case-insensitively; no cross-application SSO or automatic account linking is claimed.
## Security and scope
- Authenticated and verified users only, rate-limited.
- Ownership checked on each licence detail request, returning 404 for non-owners.
- No subscription cancellation, upgrade, activation changes, key retrieval or checkout.
- No new migrations, secrets or third-party API calls.
- Existing Paddle billing, licensing APIs, admin routes and webhooks are untouched.
- Test with one owner, one unrelated user and an unverified user before release.
## Phase 11
Downloads, releases and update feeds.
+21
View File
@@ -0,0 +1,21 @@
# Phase 11 — SonarDesk distribution
Install patch at ABSDEV root; all paths are relative to the project root. No root-level module duplicates are included.
```sh
php artisan migrate
composer dump-autoload
php artisan optimize:clear
php artisan route:list --path=sonardesk
php artisan route:list --path=administration/sonardesk/distribution
```
Admin: `/administration/sonardesk/distribution`; public: `/sonardesk/downloads`; feed: `/sonardesk/updates/appcast.xml`.
Release and download binaries are stored on the private `local` disk in `sonardesk-distribution/`. Ensure the storage directory is persistent across deployments, backed up, and writable by PHP. Do not put it in public webroot. Public download responses use the Laravel controller.
The database schema preserves the existing legacy `sonardesk_releases` and `download_products` column names. If those tables already exist, the migration does not recreate them. Legacy files and records are **not** automatically imported; migrate their storage paths into `sonardesk-distribution/` before enabling public downloads.
Sparkle appcast includes only stable published releases with a non-empty Sparkle signature. Generate signatures with the official Sparkle signing tool; SHA-256 is not a substitute. Configure the macOS client to point to this feed only after validating compatibility. Existing live appcast and checkout remain unchanged.
The patch includes release creation, publication toggle, separate product upload and read-only public details. Editing existing releases, deletion, detailed per-platform requirements, and automatic client-side updates are not implemented here. Uploaded files are limited to 500 MiB by Laravel validation and additionally by PHP/nginx upload limits. Review your server upload limits.
Do not apply to production before testing staging, storage persistence, URLs, signatures and permissions. No database data is deleted on rollback.
+37
View File
@@ -0,0 +1,37 @@
# Phase 12 — Controlled checkout handoff and production-readiness gate
This is a **safe first cutover increment**, not a completed production migration. It introduces a unified SonarDesk purchase landing page in ABSDev and an explicit, allowlisted HTTPS handoff to the *existing* licensing checkout. It deliberately does NOT duplicate payment processing, webhooks, licence issuance, subscription mutations or customer ownership rules.
## Install
Extract this archive **into the existing ABSDEV project root**, preserving `Modules/Licensing`. No root-level module copies are included.
```sh
composer dump-autoload
php artisan optimize:clear
php artisan route:list --path=sonardesk/buy
php artisan route:list --path=sonardesk/checkout
php artisan test --filter=SonarDeskCheckoutHandoffTest
```
## Staging-only opt-in
Add to ABSDev `.env` (use the real existing hosted checkout URL):
```dotenv
SONARDESK_CHECKOUT_ENABLED=false
SONARDESK_CHECKOUT_URL=https://sonardesk.stage.absdev.net/buy
SONARDESK_CHECKOUT_ALLOWED_HOSTS=sonardesk.stage.absdev.net
```
After verifying the hosted path exists and the correct Paddle **sandbox** account is used, set `SONARDESK_CHECKOUT_ENABLED=true` in staging and clear configuration cache. Hostnames are exact-match allowlisted. Do not use production until separately approved.
## Acceptance and cutover gates — NOT automatically satisfied
1. Verify the checkout path and Paddle sandbox checkout end to end.
2. Verify webhook signatures, idempotency, retry handling, licence issuance and delivery in the **existing** service.
3. Verify customer portal email ownership, subscription visibility and activation limits.
4. Verify signed Sparkle appcast and actual updater in a signed test build; merely serving RSS is insufficient.
5. Test product downloads, checksums, platform restrictions, admin permissions and backup/restore.
6. Address `npm audit` findings; perform Laravel tests, integration tests and manual UI checks.
7. Plan DB and file migration, dual-write/rollback, DNS and webhook destination cutover **before** switching billing authority.
8. Keep legacy production licensing service and webhook endpoint unchanged until a separate validated cutover.
No production deployment or billing authority transfer is performed by this patch.
+25
View File
@@ -0,0 +1,25 @@
# Phase 5 — Administration authorization correction
This is a **delta package** against Phase 4, not a full project.
## What changed
- Updated the SonarDesk admin Gate to accept an explicitly granted Spatie `manage-sonardesk-licensing` permission (web guard) as well as the existing approved administrator roles.
- Added Pest authorization smoke tests for anonymous users, unprivileged verified users, and the explicit permission grant.
- Did not change any licensing API, checkout, webhook, or customer records.
## Install
1. Back up `Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php`.
2. Copy the included `Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php` over the existing file.
3. Copy the included test into `tests/Feature/` if desired.
4. Run `composer dump-autoload && php artisan optimize:clear && php artisan permission:cache-reset`.
5. Run `php artisan route:list --path=administration/sonardesk -v`.
6. Run `php artisan test --filter=SonarDeskAdminAuthorizationTest` against a **disposable test database**. Do not run destructive test migrations against live customer data.
## Next implementation milestones
- Phase 6: audited commerce checkout, webhook idempotency, customer identity mapping and data import.
- Phase 7: downloads, releases, appcast, system requirements, and ABSDev Stylist frontend.
- Phase 8: complete route parity, automated regression tests, staging deployment and cutover.
## Limitations
- The tests have not been run against your local ABSDev database.
- No production-readiness claim is made; commerce/customer migration is still pending.

Some files were not shown because too many files have changed in this diff Show More