# ONOXSOFT Panel Hardening — L2 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Mevcut PANELTR projesinin lisans client'ını sertleştir — License Server'a (onox.com.tr) gerçek bağlantı, Ed25519 JWT doğrulama, KillSwitch (Postfix/Dovecot down), "Lisans Satın Al" sayfası ve Onoxsoft → "Onoxsoft Panel" branding fix.

**Architecture:** L1'de built License Server ile HTTP üzerinden konuşur. JWT cache `/etc/onoxsoft/license.jwt`, embedded public key ile verify. KillSwitch lisans geçersizken Postfix+Dovecot durdurur, panel login → satın al sayfasına yönlendirir. PHP-FPM/Apache/MariaDB ayakta kalır.

**Tech Stack:** Laravel 11.51 + Inertia 2.0 + Vue 3.4 + Tailwind 3.2 + Pest 3.5 (PANELTR mevcut stack). libsodium (Ed25519). Http facade (License Server HTTP).

**Estimated:** 6 hafta solo part-time (haftada 10-15 saat).

**Prerequisites:**
- L1 MVP tag `v0.1.0-L1-MVP` deploy edilmiş veya local'de çalışır
- L1 server'dan Ed25519 PUBLIC key elimizde (`config/onoxsoft/license-pubkey.pem`)
- onox.com.tr (veya test için `onox-license.test`) erişilebilir
- PANELTR'de aşağıdaki dosyalar var (Phase 4 skeleton):
  - `app/Domain/License/Services/LicenseManager.php`
  - `app/Http/Middleware/EnforceLicense.php`
  - `app/Console/Commands/LicenseHeartbeatCommand.php`
  - `database/migrations/2026_05_13_094650_create_license_table.php`

---

## File Structure

```
PANELTR/
├── app/Domain/License/
│   ├── Services/
│   │   ├── LicenseManager.php              [REWRITE — gerçek implementation]
│   │   ├── OnoxsoftLicenseClient.php       [YENİ — HTTP istemci]
│   │   ├── JwtVerifier.php                 [YENİ — Ed25519 verify]
│   │   ├── MachineFingerprint.php          [YENİ — /etc/machine-id okur]
│   │   ├── PurchaseIntentBuilder.php       [YENİ — HMAC intent token]
│   │   └── KillSwitch.php                  [YENİ — Postfix/Dovecot down]
│   ├── Actions/
│   │   ├── ActivateLicenseAction.php       [YENİ — local activate]
│   │   ├── TransferIpAction.php            [YENİ — panel-side transfer]
│   │   └── RefreshLicenseAction.php        [YENİ — heartbeat wrapper]
│   ├── Exceptions/
│   │   ├── LicenseClientException.php      [YENİ]
│   │   └── KillSwitchException.php         [YENİ]
│   └── Console/Commands/
│       ├── HeartbeatCommand.php            [REWRITE — mevcut güncellenir]
│       └── BuyIntentCommand.php            [YENİ — intent token üret CLI]
├── app/Http/
│   ├── Middleware/EnforceLicense.php       [REWRITE — KillSwitch integrate]
│   └── Controllers/
│       ├── License/BuyController.php       [YENİ — /lisans/satin-al]
│       └── License/ActivateController.php  [YENİ — manuel doğrula]
├── config/onoxsoft/
│   ├── license-pubkey.pem                  [YENİ — server public key]
│   └── license-server.php                  [YENİ — endpoint config]
├── resources/js/
│   ├── Pages/License/Buy.vue               [YENİ — satın al sayfası]
│   ├── Pages/Admin/License/Index.vue       [UPDATE — branding + new actions]
│   └── Components/LicenseStatusBanner.vue  [YENİ — header banner]
├── routes/web.php                          [MODIFY — license routes]
├── package.json                            [MODIFY — name field]
├── config/app.php                          [MODIFY — name field]
└── tests/
    ├── Unit/License/
    │   ├── JwtVerifierTest.php             [YENİ]
    │   ├── MachineFingerprintTest.php      [YENİ]
    │   └── PurchaseIntentBuilderTest.php   [YENİ]
    ├── Feature/License/
    │   ├── LicenseClientTest.php           [YENİ — Http::fake]
    │   ├── KillSwitchTest.php              [YENİ]
    │   ├── EnforceLicenseTest.php          [YENİ]
    │   ├── BuyPageTest.php                 [YENİ]
    │   └── HeartbeatTest.php               [YENİ]
    └── Feature/Integration/
        └── LicenseE2eTest.php              [YENİ — Http::fake ile L1 simulation]
```

---

## Phase 1 — Branding Fix (Task 1)

### Task 1: Onoxsoft → "Onoxsoft Panel" Branding Sweep

**Files:**
- Modify: `package.json` (name field)
- Modify: `config/app.php` (APP_NAME default)
- Modify: 30+ Vue/PHP files where "ONOX" appears as product name (NOT prefix like onx-, onoxsoft, ONOXSOFT)

**Critical distinction:**
- `ONOX` (product name) → `Onoxsoft Panel` ✓ change
- `ONOXSOFT` (company name) → KEEP (uppercase trademark)
- `onx-` (command prefix) → KEEP
- `/etc/onoxsoft/` → KEEP
- `app/Domain/ONOX/` → not a path, no change

- [ ] **Step 1: Inventory Onoxsoft product-name occurrences**

Run:
```bash
cd /c/xampp/htdocs/PANELTR
# Onoxsoft kelimesini bul ama ONOXSOFT'tan ayır — sadece "ONOX" sonrası non-S karakteri
grep -rn "ONOX[^S]" --include="*.vue" --include="*.php" --include="*.md" --include="*.json" \
  --exclude-dir=vendor --exclude-dir=node_modules --exclude-dir=public/build \
  | grep -v "onoxsoft" | tee /tmp/onox-occurrences.txt | wc -l
```

Expected: ~80-120 occurrences. Review the file to know what to change.

- [ ] **Step 2: package.json name field**

Edit `package.json`:
```diff
- "name": "onox-panel",
+ "name": "onoxsoft-panel",
```

(Note: package.json'da `"name"` field eksik olabilir Laravel default'unda; var ise değiştir, yoksa atla.)

- [ ] **Step 3: config/app.php — name**

Edit `config/app.php`:
```diff
- 'name' => env('APP_NAME', 'Onoxsoft Panel'),
+ 'name' => env('APP_NAME', 'Onoxsoft Panel'),
```

- [ ] **Step 4: `.env.example` — APP_NAME**

Edit `.env.example`:
```diff
- APP_NAME="Onoxsoft Panel"
+ APP_NAME="Onoxsoft Panel"
```

- [ ] **Step 5: Vue/PHP files — bulk replace "Onoxsoft " (with trailing space) → "Onoxsoft "**

Bu safe replacement çünkü:
- `Onoxsoft Panel` → `Onoxsoft Panel` ✓
- `Onoxsoft hosting` → `Onoxsoft hosting` ✓
- `ONOXSOFT` → `ONOXSOFT` (no trailing space match) ✓ unchanged
- `onx-bin` → unchanged ✓

```bash
cd /c/xampp/htdocs/PANELTR
# Linux/Windows uyumlu: bash + sed
find . -path ./vendor -prune -o -path ./node_modules -prune -o -path ./public/build -prune \
  -o \( -name "*.vue" -o -name "*.php" -o -name "*.md" \) -print \
  | xargs sed -i 's/Onoxsoft Panel/Onoxsoft Panel/g'
```

- [ ] **Step 6: Cleanup — kalan "Onoxsoft " (uppercase, product context)**

```bash
# Bir kez daha tara, manuel olarak kontrol et — bazı yerlerde " Onoxsoft " (yalın) olabilir
grep -rn "Onoxsoft " --include="*.vue" --include="*.php" --include="*.md" \
  --exclude-dir=vendor --exclude-dir=node_modules --exclude-dir=public/build \
  | grep -v "onoxsoft" | grep -v "ONOXSOFT" | head -30
```

Manual review the results. Genellikle:
- `Onoxsoft paneli` → `Onoxsoft paneli`
- `bu ONOX` → `bu Onoxsoft`

- [ ] **Step 7: Build + run tests**

```bash
cd /c/xampp/htdocs/PANELTR
npm run build
C:/xampp/php/php-win.exe artisan test 2>&1 | tail -5
```

Expected: tüm test'ler hala pass (265+, branding değişikliği logic'i etkilemez).

- [ ] **Step 8: Commit**

```bash
cd /c/xampp/htdocs/PANELTR
git add -A
git commit -m "refactor(branding): 'Onoxsoft Panel' → 'Onoxsoft Panel' (TPMK marka)

- Onoxsoft (product name) → Onoxsoft Panel
- ONOXSOFT (company, uppercase) korundu
- onx-* command prefix korundu
- /etc/onoxsoft path korundu
- package.json + config/app.php + .env.example + ~80 UI/doc dosyası
- Test sayısı sabit (265+), branding logic etkilemez"
```

---

## Phase 2 — License Client Core (Tasks 2-5)

### Task 2: MachineFingerprint Service

**Files:**
- Create: `app/Domain/License/Services/MachineFingerprint.php`
- Create: `tests/Unit/License/MachineFingerprintTest.php`

- [ ] **Step 1: Failing test**

Create `tests/Unit/License/MachineFingerprintTest.php`:
```php
<?php
use App\Domain\License\Services\MachineFingerprint;

test('machine_id okur (Linux /etc/machine-id veya Windows fallback)', function () {
    $fp = new MachineFingerprint();
    $mid = $fp->machineId();
    expect($mid)->toBeString()->not->toBeEmpty();
    expect(strlen($mid))->toBeGreaterThanOrEqual(8);
});

test('hostname döner', function () {
    $fp = new MachineFingerprint();
    $hostname = $fp->hostname();
    expect($hostname)->toBeString()->not->toBeEmpty();
});

test('aynı çağrı aynı değer döner (deterministic)', function () {
    $fp = new MachineFingerprint();
    expect($fp->machineId())->toBe($fp->machineId());
    expect($fp->hostname())->toBe($fp->hostname());
});

test('fingerprint() composite key döner', function () {
    $fp = new MachineFingerprint();
    $combined = $fp->fingerprint();
    expect($combined)->toBeString();
    expect($combined)->toContain($fp->machineId());
});
```

Run: `C:/xampp/php/php-win.exe vendor/bin/pest tests/Unit/License/MachineFingerprintTest.php`
Expected: FAIL — class doesn't exist.

- [ ] **Step 2: Implementation**

Create `app/Domain/License/Services/MachineFingerprint.php`:
```php
<?php
declare(strict_types=1);
namespace App\Domain\License\Services;

class MachineFingerprint {
    private ?string $cachedMachineId = null;
    private ?string $cachedHostname  = null;

    public function machineId(): string {
        if ($this->cachedMachineId !== null) return $this->cachedMachineId;

        // Linux: /etc/machine-id (systemd standart)
        if (PHP_OS_FAMILY === 'Linux' && is_readable('/etc/machine-id')) {
            $id = trim(@file_get_contents('/etc/machine-id') ?: '');
            if ($id) return $this->cachedMachineId = $id;
        }

        // macOS: ioreg
        if (PHP_OS_FAMILY === 'Darwin') {
            $output = @shell_exec('ioreg -d2 -c IOPlatformExpertDevice | awk -F\\" \'/IOPlatformUUID/{print $(NF-1)}\'');
            if ($output) return $this->cachedMachineId = strtolower(trim($output));
        }

        // Windows: WMIC
        if (PHP_OS_FAMILY === 'Windows') {
            $output = @shell_exec('wmic csproduct get UUID /value 2>nul');
            if ($output && preg_match('/UUID=([A-F0-9-]+)/i', $output, $m)) {
                return $this->cachedMachineId = strtolower($m[1]);
            }
        }

        // Fallback: hash of hostname + MAC (best effort) + storage path
        $hash = hash('sha256', $this->hostname() . '|' . php_uname('n') . '|' . storage_path());
        return $this->cachedMachineId = substr($hash, 0, 32);
    }

    public function hostname(): string {
        if ($this->cachedHostname !== null) return $this->cachedHostname;
        return $this->cachedHostname = gethostname() ?: php_uname('n') ?: 'unknown-host';
    }

    public function fingerprint(): string {
        return $this->machineId() . '|' . $this->hostname();
    }
}
```

Run: `C:/xampp/php/php-win.exe vendor/bin/pest tests/Unit/License/MachineFingerprintTest.php`
Expected: 4 passed.

- [ ] **Step 3: Commit**

```bash
git add app/Domain/License/Services/MachineFingerprint.php tests/Unit/License/MachineFingerprintTest.php
git commit -m "feat(license): MachineFingerprint — /etc/machine-id okur (Linux/macOS/Windows)"
```

---

### Task 3: PurchaseIntentBuilder Service

**Files:**
- Create: `app/Domain/License/Services/PurchaseIntentBuilder.php`
- Create: `tests/Unit/License/PurchaseIntentBuilderTest.php`

- [ ] **Step 1: Failing test**

Create `tests/Unit/License/PurchaseIntentBuilderTest.php`:
```php
<?php
use App\Domain\License\Services\PurchaseIntentBuilder;
use App\Domain\License\Services\MachineFingerprint;

beforeEach(function () {
    $this->fp = new MachineFingerprint();
    $this->builder = new PurchaseIntentBuilder($this->fp, 'test-intent-secret-32-byte-fixd');
});

test('build() valid HMAC-signed base64url token döner', function () {
    $token = $this->builder->build('1.2.3.4', 'host.example.com', '1.0.0', 3600);
    expect($token)->toBeString()->not->toBeEmpty();

    // Decode + verify signature
    $json = base64_decode(strtr($token, '-_', '+/'), true);
    $data = json_decode($json, true);
    expect($data)->toBeArray();
    expect($data)->toHaveKeys(['v','ip','mid','host','pv','ts','exp','nonce','sig']);
    expect($data['ip'])->toBe('1.2.3.4');
    expect($data['host'])->toBe('host.example.com');
    expect($data['pv'])->toBe('1.0.0');
});

test('imza HMAC-SHA256 ile aynı secret\'la verify edilir', function () {
    $token = $this->builder->build('5.6.7.8', 'panel.host', '2.0.0');
    $data  = json_decode(base64_decode(strtr($token, '-_', '+/'), true), true);

    $sig = $data['sig'];
    unset($data['sig']);

    $expected = hash_hmac('sha256', json_encode($data, JSON_UNESCAPED_SLASHES), 'test-intent-secret-32-byte-fixd');
    expect($sig)->toBe($expected);
});

test('exp parametresi gelecekte', function () {
    $before = time();
    $token  = $this->builder->build('1.1.1.1', 'h', '1', 7200);
    $data   = json_decode(base64_decode(strtr($token, '-_', '+/'), true), true);

    expect($data['exp'])->toBeGreaterThan($before);
    expect($data['exp'])->toBeLessThanOrEqual($before + 7300);  // 7200 + slack
});

test('her çağrı farklı nonce üretir', function () {
    $t1 = $this->builder->build('1.1.1.1', 'h', '1');
    $t2 = $this->builder->build('1.1.1.1', 'h', '1');
    expect($t1)->not->toBe($t2);
});
```

- [ ] **Step 2: Implementation**

Create `app/Domain/License/Services/PurchaseIntentBuilder.php`:
```php
<?php
declare(strict_types=1);
namespace App\Domain\License\Services;

class PurchaseIntentBuilder {
    public function __construct(
        private MachineFingerprint $fp,
        private string $secret,
    ) {}

    public function build(string $ip, string $hostname, string $panelVersion, int $ttlSeconds = 3600): string {
        $data = [
            'v'     => 1,
            'ip'    => $ip,
            'mid'   => $this->fp->machineId(),
            'host'  => $hostname,
            'pv'    => $panelVersion,
            'ts'    => time(),
            'exp'   => time() + $ttlSeconds,
            'nonce' => bin2hex(random_bytes(8)),
        ];
        $data['sig'] = hash_hmac('sha256', json_encode($data, JSON_UNESCAPED_SLASHES), $this->secret);

        return rtrim(strtr(base64_encode(json_encode($data)), '+/', '-_'), '=');
    }
}
```

- [ ] **Step 3: Service binding**

Modify `app/Providers/AppServiceProvider.php` `register()`:
```php
$this->app->singleton(\App\Domain\License\Services\PurchaseIntentBuilder::class, fn($app) =>
    new \App\Domain\License\Services\PurchaseIntentBuilder(
        $app->make(\App\Domain\License\Services\MachineFingerprint::class),
        config('onoxsoft.intent.secret', 'unset-dev-secret-rotate-in-prod'),
    )
);
```

Add to `config/onoxsoft.php` (PANELTR'de henüz yok — create):
```php
<?php
return [
    'intent' => [
        'secret' => env('ONOXSOFT_INTENT_SECRET', ''),
    ],
    'server' => [
        'base_url' => env('ONOXSOFT_LICENSE_SERVER', 'https://onox.com.tr'),
        'timeout'  => 10,
    ],
];
```

Add to `.env.example`:
```
ONOXSOFT_INTENT_SECRET=
ONOXSOFT_LICENSE_SERVER=https://onox.com.tr
```

- [ ] **Step 4: Run + commit**

```bash
C:/xampp/php/php-win.exe vendor/bin/pest tests/Unit/License/PurchaseIntentBuilderTest.php
git add app/Domain/License/Services/PurchaseIntentBuilder.php app/Providers/AppServiceProvider.php config/onoxsoft.php .env.example tests/Unit/License/
git commit -m "feat(license): PurchaseIntentBuilder — HMAC-SHA256 imzalı intent token üretir"
```

---

### Task 4: JwtVerifier Service (Ed25519)

**Files:**
- Create: `app/Domain/License/Services/JwtVerifier.php`
- Create: `config/onoxsoft/license-pubkey.pem` (L1 keygen çıktısından)
- Create: `tests/Unit/License/JwtVerifierTest.php`

- [ ] **Step 1: License Server'dan public key al**

L1'de üretilen Ed25519 keypair'in PUBLIC key'ini PANELTR'ye embed et:

```bash
cd /c/xampp/htdocs/ONOXSOFT-LICENSE
# .env'den public key'i oku
grep ONOXSOFT_LICENSE_PUBLIC_KEY .env
```

Copy değerini PANELTR `.env`'sine ekle:
```
ONOXSOFT_LICENSE_PUBLIC_KEY=<base64-from-license-server>
```

Bu env değişkeni `config/onoxsoft.php`'de okunacak.

- [ ] **Step 2: Failing test**

Create `tests/Unit/License/JwtVerifierTest.php`:
```php
<?php
use App\Domain\License\Services\JwtVerifier;

beforeEach(function () {
    $kp = sodium_crypto_sign_keypair();
    $this->privateKey = sodium_crypto_sign_secretkey($kp);
    $this->publicKey  = sodium_crypto_sign_publickey($kp);
    $this->verifier   = new JwtVerifier($this->publicKey);
});

function signJwt(string $privateKey, array $claims): string {
    $header  = ['alg' => 'EdDSA', 'typ' => 'JWT'];
    $h64     = rtrim(strtr(base64_encode(json_encode($header,  JSON_UNESCAPED_SLASHES)), '+/', '-_'), '=');
    $p64     = rtrim(strtr(base64_encode(json_encode($claims, JSON_UNESCAPED_SLASHES)), '+/', '-_'), '=');
    $message = $h64 . '.' . $p64;
    $sig     = sodium_crypto_sign_detached($message, $privateKey);
    return $message . '.' . rtrim(strtr(base64_encode($sig), '+/', '-_'), '=');
}

test('geçerli JWT verify edilir', function () {
    $jwt = signJwt($this->privateKey, ['sub' => 'license-uuid', 'exp' => time()+3600]);
    $claims = $this->verifier->verify($jwt);
    expect($claims)->not->toBeNull();
    expect($claims['sub'])->toBe('license-uuid');
});

test('expired JWT reddedilir', function () {
    $jwt = signJwt($this->privateKey, ['sub' => 'x', 'exp' => time()-1]);
    expect($this->verifier->verify($jwt))->toBeNull();
});

test('farklı key ile imzalanan JWT reddedilir', function () {
    $kp2 = sodium_crypto_sign_keypair();
    $jwt = signJwt(sodium_crypto_sign_secretkey($kp2), ['sub' => 'x', 'exp' => time()+3600]);
    expect($this->verifier->verify($jwt))->toBeNull();
});

test('tampered payload reddedilir', function () {
    $jwt = signJwt($this->privateKey, ['sub' => 'x', 'exp' => time()+3600]);
    [$h, $p, $s] = explode('.', $jwt);
    $tampered = rtrim(strtr(base64_encode(json_encode(['sub' => 'evil', 'exp' => time()+3600])), '+/', '-_'), '=');
    expect($this->verifier->verify($h . '.' . $tampered . '.' . $s))->toBeNull();
});

test('bozuk JWT format reddedilir', function () {
    expect($this->verifier->verify('not-a-jwt'))->toBeNull();
    expect($this->verifier->verify('a.b'))->toBeNull();
    expect($this->verifier->verify(''))->toBeNull();
});
```

- [ ] **Step 3: Implementation**

Create `app/Domain/License/Services/JwtVerifier.php`:
```php
<?php
declare(strict_types=1);
namespace App\Domain\License\Services;

class JwtVerifier {
    public function __construct(private string $publicKey) {}

    public function verify(string $jwt): ?array {
        $parts = explode('.', $jwt);
        if (count($parts) !== 3) return null;
        [$h, $p, $sig] = $parts;

        $message   = $h . '.' . $p;
        $signature = $this->base64urlDecode($sig);
        if (!$signature || strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES) return null;

        try {
            if (!sodium_crypto_sign_verify_detached($signature, $message, $this->publicKey)) {
                return null;
            }
        } catch (\SodiumException) {
            return null;
        }

        $claims = json_decode($this->base64urlDecode($p), true);
        if (!is_array($claims)) return null;
        if (isset($claims['exp']) && $claims['exp'] < time()) return null;

        return $claims;
    }

    private function base64urlDecode(string $data): string {
        return base64_decode(strtr($data, '-_', '+/'), true) ?: '';
    }
}
```

- [ ] **Step 4: Service binding + config**

Modify `config/onoxsoft.php`:
```php
return [
    'license' => [
        'public_key' => env('ONOXSOFT_LICENSE_PUBLIC_KEY'),
    ],
    'intent' => [
        'secret' => env('ONOXSOFT_INTENT_SECRET', ''),
    ],
    'server' => [
        'base_url' => env('ONOXSOFT_LICENSE_SERVER', 'https://onox.com.tr'),
        'timeout'  => 10,
    ],
];
```

Service binding in `AppServiceProvider`:
```php
$this->app->singleton(\App\Domain\License\Services\JwtVerifier::class, function () {
    $pub = config('onoxsoft.license.public_key');
    if (empty($pub)) {
        throw new \RuntimeException('ONOXSOFT_LICENSE_PUBLIC_KEY .env\'de tanımlı değil');
    }
    return new \App\Domain\License\Services\JwtVerifier(base64_decode($pub));
});
```

- [ ] **Step 5: Run + commit**

```bash
C:/xampp/php/php-win.exe vendor/bin/pest tests/Unit/License/JwtVerifierTest.php
git add app/Domain/License/Services/JwtVerifier.php config/onoxsoft.php .env.example app/Providers/AppServiceProvider.php tests/Unit/License/JwtVerifierTest.php
git commit -m "feat(license): JwtVerifier — Ed25519 EdDSA verify, expired/tampered/malformed reddet"
```

---

### Task 5: OnoxsoftLicenseClient (HTTP Client)

**Files:**
- Create: `app/Domain/License/Services/OnoxsoftLicenseClient.php`
- Create: `app/Domain/License/Exceptions/LicenseClientException.php`
- Create: `tests/Feature/License/LicenseClientTest.php`

- [ ] **Step 1: Exception class**

Create `app/Domain/License/Exceptions/LicenseClientException.php`:
```php
<?php
declare(strict_types=1);
namespace App\Domain\License\Exceptions;

use RuntimeException;

class LicenseClientException extends RuntimeException {
    public static function networkError(string $reason): self {
        return new self("License Server bağlantısı: {$reason}");
    }
    public static function invalidResponse(string $reason): self {
        return new self("License Server geçersiz yanıt: {$reason}");
    }
    public static function jwtVerifyFailed(): self {
        return new self('License Server JWT imzası doğrulanamadı');
    }
}
```

- [ ] **Step 2: Failing test**

Create `tests/Feature/License/LicenseClientTest.php`:
```php
<?php
use App\Domain\License\Services\OnoxsoftLicenseClient;
use App\Domain\License\Services\JwtVerifier;
use App\Domain\License\Exceptions\LicenseClientException;
use Illuminate\Support\Facades\Http;

beforeEach(function () {
    // Test public key (matches the private key we'll sign with)
    $kp = sodium_crypto_sign_keypair();
    $this->testPrivate = sodium_crypto_sign_secretkey($kp);
    $this->testPublic  = sodium_crypto_sign_publickey($kp);

    $verifier = new JwtVerifier($this->testPublic);
    $this->client = new OnoxsoftLicenseClient(
        baseUrl: 'https://onox.com.tr',
        verifier: $verifier,
        timeoutSeconds: 10,
    );
});

function makeJwt(string $privateKey, array $claims): string {
    $header = ['alg' => 'EdDSA', 'typ' => 'JWT'];
    $h64 = rtrim(strtr(base64_encode(json_encode($header, JSON_UNESCAPED_SLASHES)), '+/', '-_'), '=');
    $p64 = rtrim(strtr(base64_encode(json_encode($claims, JSON_UNESCAPED_SLASHES)), '+/', '-_'), '=');
    $msg = $h64 . '.' . $p64;
    $sig = sodium_crypto_sign_detached($msg, $privateKey);
    return $msg . '.' . rtrim(strtr(base64_encode($sig), '+/', '-_'), '=');
}

test('activate() success → verified JWT döner', function () {
    $jwt = makeJwt($this->testPrivate, ['sub' => 'uuid-1', 'exp' => time()+86400]);

    Http::fake([
        'onox.com.tr/api/v1/activate' => Http::response(['license_jwt' => $jwt, 'expires_at' => now()->addDay()->toIso8601String()], 200),
    ]);

    $result = $this->client->activate([
        'intent_token' => 'TOKEN', 'ip' => '1.2.3.4', 'machine_id' => 'mid', 'hostname' => 'h', 'panel_version' => '1.0',
    ]);

    expect($result['jwt'])->toBe($jwt);
    expect($result['claims']['sub'])->toBe('uuid-1');
});

test('activate() 422 error → exception fırlatır', function () {
    Http::fake([
        'onox.com.tr/api/v1/activate' => Http::response(['error' => 'intent_not_found'], 422),
    ]);

    expect(fn() => $this->client->activate([
        'intent_token' => 'X', 'ip' => '1.1.1.1', 'machine_id' => 'm',
    ]))->toThrow(LicenseClientException::class, 'intent_not_found');
});

test('activate() invalid JWT signature → exception', function () {
    // Imzayı farklı bir key'le yap → verify fail
    $kp2 = sodium_crypto_sign_keypair();
    $badJwt = makeJwt(sodium_crypto_sign_secretkey($kp2), ['sub' => 'evil', 'exp' => time()+3600]);

    Http::fake([
        'onox.com.tr/api/v1/activate' => Http::response(['license_jwt' => $badJwt, 'expires_at' => now()->addDay()->toIso8601String()], 200),
    ]);

    expect(fn() => $this->client->activate([
        'intent_token' => 'X', 'ip' => '1.1.1.1', 'machine_id' => 'm',
    ]))->toThrow(LicenseClientException::class, 'JWT imzası');
});

test('heartbeat() success', function () {
    $jwt = makeJwt($this->testPrivate, ['sub' => 'uuid-2', 'exp' => time()+86400]);
    Http::fake([
        'onox.com.tr/api/v1/heartbeat' => Http::response(['license_jwt' => $jwt], 200),
    ]);

    $result = $this->client->heartbeat('license-uuid-2', ['ip' => '1.1.1.1', 'machine_id' => 'm', 'panel_version' => '1.0']);
    expect($result['jwt'])->toBe($jwt);
});

test('heartbeat() 410 binding_not_found → exception', function () {
    Http::fake([
        'onox.com.tr/api/v1/heartbeat' => Http::response(['error' => 'binding_not_found'], 410),
    ]);

    expect(fn() => $this->client->heartbeat('uuid', ['ip' => '1', 'machine_id' => 'm']))
        ->toThrow(LicenseClientException::class, 'binding_not_found');
});

test('network timeout → exception', function () {
    Http::fake(function () {
        throw new \Illuminate\Http\Client\ConnectionException('Connection refused');
    });

    expect(fn() => $this->client->activate(['intent_token' => 'x', 'ip' => '1', 'machine_id' => 'm']))
        ->toThrow(LicenseClientException::class, 'bağlantı');
});

test('checkIntent() polling status döner', function () {
    Http::fake([
        'onox.com.tr/api/v1/license/check*' => Http::response(['status' => 'ready_to_activate'], 200),
    ]);
    expect($this->client->checkIntent('test-token'))->toBe(['status' => 'ready_to_activate']);
});

test('signRequest() HMAC header üretir', function () {
    // Public method check: signRequest('POST', 'api/v1/heartbeat', ['ip'=>'1'], 'license-uuid')
    $headers = $this->client->signRequest('POST', 'api/v1/heartbeat', ['ip' => '1.1.1.1'], 'license-uuid-test');
    expect($headers)->toHaveKey('X-License-UUID');
    expect($headers)->toHaveKey('X-License-Signature');
    expect($headers['X-License-UUID'])->toBe('license-uuid-test');

    $expected = hash_hmac('sha256', 'POST|api/v1/heartbeat|' . json_encode(['ip' => '1.1.1.1']), 'license-uuid-test');
    expect($headers['X-License-Signature'])->toBe($expected);
});
```

- [ ] **Step 3: Implementation**

Create `app/Domain/License/Services/OnoxsoftLicenseClient.php`:
```php
<?php
declare(strict_types=1);
namespace App\Domain\License\Services;

use App\Domain\License\Exceptions\LicenseClientException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;

class OnoxsoftLicenseClient {
    public function __construct(
        private string $baseUrl,
        private JwtVerifier $verifier,
        private int $timeoutSeconds = 10,
    ) {}

    public function activate(array $payload): array {
        $url = rtrim($this->baseUrl, '/') . '/api/v1/activate';
        $response = $this->postJson($url, $payload);
        return $this->verifyResponse($response);
    }

    public function heartbeat(string $licenseUuid, array $payload): array {
        $url = rtrim($this->baseUrl, '/') . '/api/v1/heartbeat';
        $headers = $this->signRequest('POST', 'api/v1/heartbeat', $payload, $licenseUuid);
        $response = $this->postJson($url, $payload, $headers);
        return $this->verifyResponse($response);
    }

    public function transferIp(string $licenseUuid, array $payload): array {
        $url = rtrim($this->baseUrl, '/') . '/api/v1/transfer-ip';
        $headers = $this->signRequest('POST', 'api/v1/transfer-ip', $payload, $licenseUuid);
        $response = $this->postJson($url, $payload, $headers);
        return $this->verifyResponse($response);
    }

    public function checkIntent(string $token): array {
        $url = rtrim($this->baseUrl, '/') . '/api/v1/license/check?intent=' . urlencode($token);
        try {
            $response = Http::timeout($this->timeoutSeconds)->acceptJson()->get($url);
        } catch (ConnectionException $e) {
            throw LicenseClientException::networkError($e->getMessage());
        }
        return $response->json() ?? ['status' => 'unknown'];
    }

    public function crackReport(string $licenseUuid, string $signal, array $payload = []): void {
        $url = rtrim($this->baseUrl, '/') . '/api/v1/crack-report';
        $body = ['signal' => $signal, 'payload' => $payload];
        $headers = $this->signRequest('POST', 'api/v1/crack-report', $body, $licenseUuid);
        try {
            Http::timeout($this->timeoutSeconds)->withHeaders($headers)->acceptJson()->post($url, $body);
            // Fire-and-forget — failures shouldn't break panel
        } catch (\Throwable) {}
    }

    public function signRequest(string $method, string $path, array $body, string $licenseUuid): array {
        $bodyJson = empty($body) ? '' : json_encode($body);
        $payload = strtoupper($method) . '|' . ltrim($path, '/') . '|' . $bodyJson;
        return [
            'X-License-UUID'      => $licenseUuid,
            'X-License-Signature' => hash_hmac('sha256', $payload, $licenseUuid),
            'Accept'              => 'application/json',
            'Content-Type'        => 'application/json',
        ];
    }

    private function postJson(string $url, array $body, array $headers = []): \Illuminate\Http\Client\Response {
        try {
            return Http::timeout($this->timeoutSeconds)
                ->withHeaders($headers)
                ->acceptJson()
                ->asJson()
                ->post($url, $body);
        } catch (ConnectionException $e) {
            throw LicenseClientException::networkError($e->getMessage());
        }
    }

    private function verifyResponse(\Illuminate\Http\Client\Response $response): array {
        $data = $response->json();
        if (!is_array($data)) {
            throw LicenseClientException::invalidResponse('JSON parse fail');
        }

        if ($response->failed()) {
            $error = $data['error'] ?? "HTTP {$response->status()}";
            throw new LicenseClientException($error);
        }

        $jwt = $data['license_jwt'] ?? null;
        if (!$jwt) {
            throw LicenseClientException::invalidResponse('license_jwt eksik');
        }

        $claims = $this->verifier->verify($jwt);
        if ($claims === null) {
            throw LicenseClientException::jwtVerifyFailed();
        }

        return ['jwt' => $jwt, 'claims' => $claims, 'expires_at' => $data['expires_at'] ?? null];
    }
}
```

- [ ] **Step 4: Service binding**

In `AppServiceProvider::register()`:
```php
$this->app->singleton(\App\Domain\License\Services\OnoxsoftLicenseClient::class, fn($app) =>
    new \App\Domain\License\Services\OnoxsoftLicenseClient(
        baseUrl: config('onoxsoft.server.base_url'),
        verifier: $app->make(\App\Domain\License\Services\JwtVerifier::class),
        timeoutSeconds: (int) config('onoxsoft.server.timeout', 10),
    )
);
```

- [ ] **Step 5: Run + commit**

```bash
C:/xampp/php/php-win.exe vendor/bin/pest tests/Feature/License/LicenseClientTest.php
git add app/Domain/License/ tests/Feature/License/
git commit -m "feat(license): OnoxsoftLicenseClient — License Server HTTP istemcisi

- 5 endpoint sarmalayıcısı: activate/heartbeat/transferIp/checkIntent/crackReport
- HMAC-SHA256 imzalama (X-License-UUID + X-License-Signature header)
- Response Ed25519 JWT verify zorunlu — sahte server'a karşı
- LicenseClientException (network/invalidResponse/jwtVerifyFailed)
- Http::fake ile 8 test (success/422/410/network/tamper)
- Crack-report fire-and-forget (panel'i bozmaz)"
```

---

## Phase 3 — KillSwitch & Enforcement (Tasks 6-8)

### Task 6: KillSwitch Service

**Files:**
- Create: `app/Domain/License/Services/KillSwitch.php`
- Create: `tests/Feature/License/KillSwitchTest.php`
- Create: `database/migrations/2026_05_14_create_license_killswitch_table.php`

- [ ] **Step 1: Migration**

```bash
cd /c/xampp/htdocs/PANELTR
C:/xampp/php/php-win.exe artisan make:migration create_license_killswitch_table
```

Edit `database/migrations/2026_05_14_*_create_license_killswitch_table.php`:
```php
public function up(): void {
    Schema::create('license_killswitch', function (Blueprint $t) {
        $t->id();
        $t->string('reason', 100);
        $t->text('detail')->nullable();
        $t->timestamp('activated_at');
        $t->timestamp('deactivated_at')->nullable();
        $t->timestamps();
    });
}
public function down(): void { Schema::dropIfExists('license_killswitch'); }
```

Run: `C:/xampp/php/php-win.exe artisan migrate`

- [ ] **Step 2: Failing test**

Create `tests/Feature/License/KillSwitchTest.php`:
```php
<?php
use App\Domain\License\Services\KillSwitch;
use Illuminate\Support\Facades\DB;

beforeEach(function () {
    $this->killSwitch = app(KillSwitch::class);
});

test('isActive() default false', function () {
    expect($this->killSwitch->isActive())->toBeFalse();
});

test('activate(reason) DB\'ye yazar + isActive=true', function () {
    $this->killSwitch->activate('test_reason', 'test detail');

    expect($this->killSwitch->isActive())->toBeTrue();
    expect(DB::table('license_killswitch')->whereNull('deactivated_at')->count())->toBe(1);

    $row = DB::table('license_killswitch')->whereNull('deactivated_at')->first();
    expect($row->reason)->toBe('test_reason');
    expect($row->detail)->toBe('test detail');
});

test('deactivate() açık olan row\'a deactivated_at yazar', function () {
    $this->killSwitch->activate('test', null);
    $this->killSwitch->deactivate();

    expect($this->killSwitch->isActive())->toBeFalse();
    $row = DB::table('license_killswitch')->latest('id')->first();
    expect($row->deactivated_at)->not->toBeNull();
});

test('activate() çoklu çağrı yeni row eklemez (idempotent)', function () {
    $this->killSwitch->activate('reason_1', null);
    $this->killSwitch->activate('reason_2', null);

    // Sadece 1 aktif row olmalı, reason güncellenir
    expect(DB::table('license_killswitch')->whereNull('deactivated_at')->count())->toBe(1);
});

test('lastReason() döner', function () {
    expect($this->killSwitch->lastReason())->toBeNull();

    $this->killSwitch->activate('expired', 'License süresi doldu');
    expect($this->killSwitch->lastReason())->toBe('expired');
});
```

- [ ] **Step 3: Implementation**

Create `app/Domain/License/Services/KillSwitch.php`:
```php
<?php
declare(strict_types=1);
namespace App\Domain\License\Services;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;

class KillSwitch {
    private const SERVICES_TO_STOP = ['postfix', 'dovecot'];

    public function isActive(): bool {
        return DB::table('license_killswitch')->whereNull('deactivated_at')->exists();
    }

    public function lastReason(): ?string {
        $row = DB::table('license_killswitch')->whereNull('deactivated_at')->latest('id')->first();
        return $row?->reason;
    }

    public function activate(string $reason, ?string $detail = null): void {
        // Idempotent: zaten aktifse sadece reason güncelle
        $existing = DB::table('license_killswitch')->whereNull('deactivated_at')->first();
        if ($existing) {
            DB::table('license_killswitch')->where('id', $existing->id)->update([
                'reason' => $reason, 'detail' => $detail, 'updated_at' => now(),
            ]);
        } else {
            DB::table('license_killswitch')->insert([
                'reason'       => $reason,
                'detail'       => $detail,
                'activated_at' => now(),
                'created_at'   => now(),
                'updated_at'   => now(),
            ]);
        }

        $this->stopServices();
        Log::warning('License KillSwitch activated', ['reason' => $reason, 'detail' => $detail]);
    }

    public function deactivate(): void {
        DB::table('license_killswitch')->whereNull('deactivated_at')->update([
            'deactivated_at' => now(),
            'updated_at'     => now(),
        ]);

        $this->startServices();
        Log::info('License KillSwitch deactivated');
    }

    private function stopServices(): void {
        if (!$this->isLinuxProduction()) return;
        foreach (self::SERVICES_TO_STOP as $svc) {
            try {
                Process::run("sudo systemctl stop {$svc}");
            } catch (\Throwable $e) {
                Log::warning("Failed to stop {$svc}: " . $e->getMessage());
            }
        }
    }

    private function startServices(): void {
        if (!$this->isLinuxProduction()) return;
        foreach (self::SERVICES_TO_STOP as $svc) {
            try {
                Process::run("sudo systemctl start {$svc}");
            } catch (\Throwable $e) {
                Log::warning("Failed to start {$svc}: " . $e->getMessage());
            }
        }
    }

    private function isLinuxProduction(): bool {
        return PHP_OS_FAMILY === 'Linux' && app()->environment('production');
    }
}
```

- [ ] **Step 4: Run + commit**

```bash
C:/xampp/php/php-win.exe vendor/bin/pest tests/Feature/License/KillSwitchTest.php
git add app/Domain/License/Services/KillSwitch.php database/migrations/ tests/Feature/License/KillSwitchTest.php
git commit -m "feat(license): KillSwitch — lisans geçersizken Postfix+Dovecot durdurur

- DB-backed state (license_killswitch table)
- Idempotent activate/deactivate
- systemctl stop/start (Linux production only; dev/test = no-op)
- isActive(), lastReason() helpers
- 5 test pass"
```

---

### Task 7: EnforceLicense Middleware Rewrite

**Files:**
- Modify: `app/Http/Middleware/EnforceLicense.php`
- Modify: `app/Domain/License/Services/LicenseManager.php` (gerçek implementation)
- Create: `tests/Feature/License/EnforceLicenseTest.php`

- [ ] **Step 1: LicenseManager rewrite — JWT cache + KillSwitch integration**

Replace `app/Domain/License/Services/LicenseManager.php` entirely:
```php
<?php
declare(strict_types=1);
namespace App\Domain\License\Services;

use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;

class LicenseManager {
    public const FEATURE_MAIL_SERVER = 'mail_server';
    public const FEATURE_PLUGINS     = 'plugins';
    public const FEATURE_API         = 'api';
    public const FEATURE_AI_ASSISTANT = 'ai_assistant';
    public const FEATURE_WHMCS_PLUGIN = 'whmcs_plugin';
    public const FEATURE_AUTOSSL     = 'autossl';

    private const CACHE_KEY = 'onoxsoft.license.jwt';
    private const CACHE_TTL_SECONDS = 60;  // RAM cache 1 dakika; primary store = file

    public function __construct(
        private JwtVerifier $verifier,
        private KillSwitch $killSwitch,
    ) {}

    public function isValid(): bool {
        if ($this->isDevMode()) return true;
        return $this->getValidClaims() !== null;
    }

    public function getValidClaims(): ?array {
        $jwt = $this->loadJwt();
        if (!$jwt) return null;
        return $this->verifier->verify($jwt);
    }

    public function tier(): ?string {
        $c = $this->getValidClaims();
        return $c['license']['tier'] ?? null;
    }

    public function features(): array {
        $c = $this->getValidClaims();
        return $c['license']['features'] ?? [];
    }

    public function accountLimit(): int {
        $c = $this->getValidClaims();
        $limit = $c['license']['account_limit'] ?? 0;
        return $limit > 0 ? (int) $limit : PHP_INT_MAX;  // 0 = sınırsız
    }

    public function hasFeature(string $feature): bool {
        if ($this->isDevMode()) return true;
        $f = $this->features();
        return in_array('*', $f, true) || in_array($feature, $f, true);
    }

    public function expiresAt(): ?Carbon {
        $c = $this->getValidClaims();
        if (!$c || empty($c['license']['expires_at'])) return null;
        return Carbon::parse($c['license']['expires_at']);
    }

    public function storeJwt(string $jwt): void {
        $path = $this->jwtPath();
        File::ensureDirectoryExists(dirname($path), 0700);
        File::put($path, $jwt);
        @chmod($path, 0600);
        Cache::put(self::CACHE_KEY, $jwt, self::CACHE_TTL_SECONDS);

        // Aktifse KillSwitch'i kaldır
        if ($this->killSwitch->isActive() && $this->verifier->verify($jwt) !== null) {
            $this->killSwitch->deactivate();
        }
    }

    public function clearJwt(): void {
        Cache::forget(self::CACHE_KEY);
        @unlink($this->jwtPath());
    }

    public function isDevMode(): bool {
        $host = parse_url(config('app.url'), PHP_URL_HOST);
        return in_array($host, ['localhost', '127.0.0.1', '[::1]'], true)
            || app()->environment('local', 'testing');
    }

    private function loadJwt(): ?string {
        return Cache::remember(self::CACHE_KEY, self::CACHE_TTL_SECONDS, function () {
            $path = $this->jwtPath();
            if (!is_readable($path)) return null;
            return trim(@file_get_contents($path) ?: '') ?: null;
        });
    }

    private function jwtPath(): string {
        // Production: /etc/onoxsoft/license.jwt (root:root 0600)
        // Dev: storage/app/onoxsoft/license.jwt
        if (PHP_OS_FAMILY === 'Linux' && app()->environment('production')) {
            return '/etc/onoxsoft/license.jwt';
        }
        return storage_path('app/onoxsoft/license.jwt');
    }
}
```

- [ ] **Step 2: Service binding update**

In `AppServiceProvider::register()` (LicenseManager already auto-resolves but explicit binding for KillSwitch + JwtVerifier dependencies):
```php
$this->app->singleton(\App\Domain\License\Services\LicenseManager::class);
$this->app->singleton(\App\Domain\License\Services\KillSwitch::class);
```

- [ ] **Step 3: EnforceLicense middleware rewrite**

Replace `app/Http/Middleware/EnforceLicense.php`:
```php
<?php
declare(strict_types=1);
namespace App\Http\Middleware;

use App\Domain\License\Services\LicenseManager;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class EnforceLicense {
    public function __construct(private LicenseManager $manager) {}

    public function handle(Request $request, Closure $next, ?string $feature = null): Response {
        // Dev mode bypass
        if ($this->manager->isDevMode()) return $next($request);

        // Setup + license routes + assets — her zaman erişilebilir
        if ($this->isAllowedWhenInvalid($request)) {
            return $next($request);
        }

        // Lisans geçersiz → satın al sayfasına yönlendir
        if (!$this->manager->isValid()) {
            if ($request->expectsJson()) {
                return response()->json([
                    'error'   => 'license_required',
                    'buy_url' => route('license.buy'),
                    'message' => 'Onoxsoft Panel lisansı gerekli',
                ], 402);
            }
            return redirect()->route('license.buy');
        }

        // Feature gate
        if ($feature && !$this->manager->hasFeature($feature)) {
            if ($request->expectsJson()) {
                return response()->json(['error' => 'feature_locked', 'feature' => $feature], 402);
            }
            abort(402, "Bu özellik için lisans yükseltmesi gerekir: {$feature}");
        }

        return $next($request);
    }

    private function isAllowedWhenInvalid(Request $request): bool {
        return $request->routeIs('license.*')
            || $request->routeIs('login')
            || $request->routeIs('logout')
            || str_starts_with($request->path(), 'build/')
            || str_starts_with($request->path(), 'images/')
            || str_starts_with($request->path(), 'storage/')
            || $request->path() === 'favicon.ico'
            || $request->path() === 'up';  // Laravel health
    }
}
```

- [ ] **Step 4: Test**

Create `tests/Feature/License/EnforceLicenseTest.php`:
```php
<?php
use App\Domain\License\Services\LicenseManager;
use App\Models\User;
use Illuminate\Support\Facades\App;

test('dev mode → middleware bypass', function () {
    // Test env'de isDevMode() true (testing environment)
    $manager = app(LicenseManager::class);
    expect($manager->isDevMode())->toBeTrue();

    $user = User::factory()->create();
    $this->actingAs($user)->get('/admin')->assertStatus(200, 'Dev mode bypass başarısız');
});

test('production mode + invalid license → /lisans/satin-al redirect', function () {
    App::partialMock()->shouldReceive('environment')->andReturn('production');
    config(['app.url' => 'https://panel.example.com']);
    // (Not: real test gerektirdiği için skip variant'ı; smoke koruması yeterli)
    expect(true)->toBeTrue();  // Placeholder — gerçek prod simulation L3'te
})->skip('Production env simulation L3 anti-crack task\'ında');

test('license routes invalid license\'da bile açık', function () {
    expect(true)->toBeTrue();  // Route::license.buy var, test L2-task-9'da
})->skip('Buy route Task 9\'da eklenir');
```

- [ ] **Step 5: Run + commit**

```bash
C:/xampp/php/php-win.exe vendor/bin/pest tests/Feature/License/EnforceLicenseTest.php
C:/xampp/php/php-win.exe vendor/bin/pest 2>&1 | tail -3  # full suite
git add app/Domain/License/Services/LicenseManager.php app/Http/Middleware/EnforceLicense.php app/Providers/AppServiceProvider.php tests/Feature/License/EnforceLicenseTest.php
git commit -m "refactor(license): LicenseManager + EnforceLicense gerçek implementation

- LicenseManager: JWT cache (file + RAM), getValidClaims, tier/features/expiresAt accessor'ları
- storeJwt() KillSwitch otomatik deactivate ediyor
- JWT path: prod /etc/onoxsoft/license.jwt, dev storage/app/onoxsoft/license.jwt
- EnforceLicense: dev-mode bypass, expectsJson() için 402, redirect license.buy
- License routes + login + assets her zaman erişilebilir
- 1 test pass + 2 skip (Task 9 + L3'te aktive olur)"
```

---

### Task 8: HeartbeatCommand Rewrite

**Files:**
- Modify: `app/Console/Commands/LicenseHeartbeatCommand.php`
- Create: `tests/Feature/License/HeartbeatTest.php`

- [ ] **Step 1: Command rewrite**

Replace `app/Console/Commands/LicenseHeartbeatCommand.php`:
```php
<?php
declare(strict_types=1);
namespace App\Console\Commands;

use App\Domain\License\Services\KillSwitch;
use App\Domain\License\Services\LicenseManager;
use App\Domain\License\Services\MachineFingerprint;
use App\Domain\License\Services\OnoxsoftLicenseClient;
use App\Domain\License\Exceptions\LicenseClientException;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;

class LicenseHeartbeatCommand extends Command {
    protected $signature = 'onoxsoft:license-heartbeat {--force}';
    protected $description = 'License Server\'a heartbeat gönder, JWT yenile';

    public function handle(
        LicenseManager $manager,
        OnoxsoftLicenseClient $client,
        MachineFingerprint $fp,
        KillSwitch $killSwitch,
    ): int {
        if ($manager->isDevMode()) {
            $this->info('Dev mode — heartbeat atlanıyor');
            return self::SUCCESS;
        }

        $claims = $manager->getValidClaims();
        if (!$claims) {
            $this->warn('Geçerli lisans yok — heartbeat gönderilmedi');
            // 72 saat grace dolduysa KillSwitch
            $this->maybeActivateKillSwitch($killSwitch);
            return self::FAILURE;
        }

        $licenseUuid = $claims['sub'] ?? '';
        if (!$licenseUuid) {
            $this->error('JWT sub claim yok — heartbeat iptal');
            return self::FAILURE;
        }

        try {
            $serverIp = $claims['license']['bound_ip'] ?? '0.0.0.0';
            $result = $client->heartbeat($licenseUuid, [
                'ip'            => $serverIp,
                'machine_id'    => $fp->machineId(),
                'panel_version' => config('app.version', '1.0.0'),
            ]);

            $manager->storeJwt($result['jwt']);
            $this->info("✓ Heartbeat başarılı — JWT yenilendi (exp: {$result['expires_at']})");

            return self::SUCCESS;

        } catch (LicenseClientException $e) {
            $this->error("✗ Heartbeat başarısız: {$e->getMessage()}");
            Log::warning('License heartbeat failed', ['error' => $e->getMessage()]);
            $this->maybeActivateKillSwitch($killSwitch, $e->getMessage());
            return self::FAILURE;
        }
    }

    private function maybeActivateKillSwitch(KillSwitch $ks, ?string $detail = null): void {
        // Grace period: 72 saat heartbeat yoksa KillSwitch (Task 10'da scheduler'a bağlanır)
        // Bu task'ta sadece logic placeholder
        if (!$ks->isActive()) {
            // Heartbeat fail history'sini ölçüp 72 saatten fazla ise activate et
            // L1 MVP scope: anında activate (sertleştirme L3'te)
            $ks->activate('heartbeat_failed', $detail);
        }
    }
}
```

- [ ] **Step 2: Test**

Create `tests/Feature/License/HeartbeatTest.php`:
```php
<?php
use App\Domain\License\Services\LicenseManager;
use App\Domain\License\Services\OnoxsoftLicenseClient;
use App\Domain\License\Services\JwtVerifier;
use App\Domain\License\Exceptions\LicenseClientException;
use Illuminate\Support\Facades\Http;

test('dev mode heartbeat atlanır', function () {
    $exitCode = \Illuminate\Support\Facades\Artisan::call('onoxsoft:license-heartbeat');
    expect($exitCode)->toBe(0);
});

test('production mode + valid JWT + server response → JWT güncellenir', function () {
    // Setup: testing env dev mode bypass eder; force production yapacağız
    config(['app.env' => 'production', 'app.url' => 'https://panel.test']);

    // JWT cache'e geçerli JWT koy
    $kp = sodium_crypto_sign_keypair();
    $priv = sodium_crypto_sign_secretkey($kp);
    $pub  = sodium_crypto_sign_publickey($kp);

    $existingJwt = makeJwtForTest($priv, [
        'sub' => 'test-uuid',
        'exp' => time() + 3600,
        'license' => ['bound_ip' => '1.1.1.1', 'tier' => 'pro', 'features' => ['api'], 'account_limit' => 100, 'expires_at' => now()->addYear()->toIso8601String()],
    ]);

    $newJwt = makeJwtForTest($priv, [
        'sub' => 'test-uuid',
        'exp' => time() + 86400,
        'license' => ['bound_ip' => '1.1.1.1', 'tier' => 'pro', 'features' => ['api'], 'account_limit' => 100, 'expires_at' => now()->addYear()->toIso8601String()],
    ]);

    // Bind verifier with our test key
    app()->instance(JwtVerifier::class, new JwtVerifier($pub));
    app()->instance(OnoxsoftLicenseClient::class, new OnoxsoftLicenseClient(
        'https://test.example.com',
        app(JwtVerifier::class),
    ));

    $manager = app(LicenseManager::class);
    $manager->storeJwt($existingJwt);

    Http::fake([
        'test.example.com/api/v1/heartbeat' => Http::response(['license_jwt' => $newJwt], 200),
    ]);

    \Illuminate\Support\Facades\Artisan::call('onoxsoft:license-heartbeat');

    expect($manager->getValidClaims()['exp'])->toBeGreaterThan(time() + 80000);
})->skip('Production env mocking flaky — L3 anti-crack task\'ında daha sağlam test');

function makeJwtForTest(string $priv, array $claims): string {
    $h = ['alg' => 'EdDSA', 'typ' => 'JWT'];
    $h64 = rtrim(strtr(base64_encode(json_encode($h, JSON_UNESCAPED_SLASHES)), '+/', '-_'), '=');
    $p64 = rtrim(strtr(base64_encode(json_encode($claims, JSON_UNESCAPED_SLASHES)), '+/', '-_'), '=');
    $msg = $h64 . '.' . $p64;
    $sig = sodium_crypto_sign_detached($msg, $priv);
    return $msg . '.' . rtrim(strtr(base64_encode($sig), '+/', '-_'), '=');
}
```

- [ ] **Step 3: Scheduler — 6 saatlik cron**

Edit `routes/console.php` (Laravel 11):
```php
use Illuminate\Support\Facades\Schedule;

Schedule::command('onoxsoft:license-heartbeat')
    ->everySixHours()
    ->withoutOverlapping(10)
    ->onFailure(fn() => \Illuminate\Support\Facades\Log::warning('License heartbeat cron failed'));
```

- [ ] **Step 4: Run + commit**

```bash
C:/xampp/php/php-win.exe vendor/bin/pest tests/Feature/License/HeartbeatTest.php
git add app/Console/Commands/LicenseHeartbeatCommand.php routes/console.php tests/Feature/License/HeartbeatTest.php
git commit -m "refactor(heartbeat): HeartbeatCommand gerçek LicenseClient çağırır

- onoxsoft:license-heartbeat → /api/v1/heartbeat'e POST
- Başarılı yanıt → JWT cache güncellenir + KillSwitch deactivate olur
- Başarısız (network/410) → KillSwitch activate (72h grace L3'te)
- Schedule: everySixHours, withoutOverlapping(10dk)
- Test: 1 pass + 1 skip (production simulation karmaşık)"
```

---

## Phase 4 — Customer UX (Tasks 9-10)

### Task 9: "Lisans Satın Al" Sayfası (Panel-Side)

**Files:**
- Create: `app/Http/Controllers/License/BuyController.php`
- Create: `resources/js/Pages/License/Buy.vue`
- Modify: `routes/web.php`
- Create: `tests/Feature/License/BuyPageTest.php`

- [ ] **Step 1: BuyController**

Create `app/Http/Controllers/License/BuyController.php`:
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\License;

use App\Domain\License\Services\KillSwitch;
use App\Domain\License\Services\LicenseManager;
use App\Domain\License\Services\MachineFingerprint;
use App\Domain\License\Services\OnoxsoftLicenseClient;
use App\Domain\License\Services\PurchaseIntentBuilder;
use App\Domain\License\Exceptions\LicenseClientException;
use App\Http\Controllers\Controller;
use Illuminate\Http\{Request, RedirectResponse};
use Inertia\{Inertia, Response};

class BuyController extends Controller {
    public function __construct(
        private LicenseManager $manager,
        private MachineFingerprint $fp,
        private PurchaseIntentBuilder $builder,
        private OnoxsoftLicenseClient $client,
        private KillSwitch $killSwitch,
    ) {}

    public function show(Request $request): Response {
        $licenseInfo = $this->manager->isValid() ? [
            'tier'        => $this->manager->tier(),
            'expires_at'  => $this->manager->expiresAt()?->toIso8601String(),
            'account_limit'=> $this->manager->accountLimit() === PHP_INT_MAX ? null : $this->manager->accountLimit(),
        ] : null;

        return Inertia::render('License/Buy', [
            'current_license'   => $licenseInfo,
            'kill_switch_active'=> $this->killSwitch->isActive(),
            'kill_switch_reason'=> $this->killSwitch->lastReason(),
            'fingerprint' => [
                'ip'         => $request->server('SERVER_ADDR') ?: $request->ip(),
                'hostname'   => $this->fp->hostname(),
                'machine_id' => $this->fp->machineId(),
            ],
            'server_url' => config('onoxsoft.server.base_url'),
        ]);
    }

    public function generateIntent(Request $request): RedirectResponse {
        $ip = $request->server('SERVER_ADDR') ?: $request->ip();
        $token = $this->builder->build(
            ip: $ip,
            hostname: $this->fp->hostname(),
            panelVersion: config('app.version', '1.0.0'),
            ttlSeconds: 3600,
        );

        $buyUrl = rtrim(config('onoxsoft.server.base_url'), '/') . '/buy?intent=' . $token;

        // Polling için intent token'ı session'a kaydet
        session(['onoxsoft.pending_intent' => $token]);

        return redirect()->away($buyUrl);
    }

    public function poll(Request $request): \Illuminate\Http\JsonResponse {
        $token = session('onoxsoft.pending_intent');
        if (!$token) {
            return response()->json(['status' => 'no_pending_intent'], 404);
        }

        try {
            $result = $this->client->checkIntent($token);
            $status = $result['status'] ?? 'unknown';

            if ($status === 'ready_to_activate') {
                $ip = $request->server('SERVER_ADDR') ?: $request->ip();
                $activated = $this->client->activate([
                    'intent_token'  => $token,
                    'ip'            => $ip,
                    'machine_id'    => $this->fp->machineId(),
                    'hostname'      => $this->fp->hostname(),
                    'panel_version' => config('app.version', '1.0.0'),
                ]);
                $this->manager->storeJwt($activated['jwt']);
                session()->forget('onoxsoft.pending_intent');
                return response()->json(['status' => 'activated', 'tier' => $activated['claims']['license']['tier'] ?? null]);
            }

            return response()->json(['status' => $status]);
        } catch (LicenseClientException $e) {
            return response()->json(['status' => 'error', 'message' => $e->getMessage()], 502);
        }
    }
}
```

- [ ] **Step 2: Routes**

In `routes/web.php`, add (OUTSIDE auth middleware — license.buy her zaman erişilebilir):
```php
// License — KillSwitch'te bile erişilebilir
Route::get('/lisans/satin-al',        [\App\Http\Controllers\License\BuyController::class, 'show'])->name('license.buy');
Route::post('/lisans/intent-baslat',  [\App\Http\Controllers\License\BuyController::class, 'generateIntent'])->name('license.intent');
Route::get('/lisans/poll',            [\App\Http\Controllers\License\BuyController::class, 'poll'])->name('license.poll');
```

- [ ] **Step 3: Buy.vue page**

Create `resources/js/Pages/License/Buy.vue`:
```vue
<script setup>
import { Head } from '@inertiajs/vue3'
import { ref, onMounted, onUnmounted } from 'vue'
import axios from 'axios'

const props = defineProps({
    current_license:    { type: Object, default: null },
    kill_switch_active: { type: Boolean, default: false },
    kill_switch_reason: { type: String,  default: null },
    fingerprint:        { type: Object, required: true },
    server_url:         { type: String, required: true },
})

const tiers = [
    { name: 'Trial',     slots: 5,   price: 'Ücretsiz', features: ['Test', 'Topluluk destek'] },
    { name: 'Lite',      slots: 30,  price: '₺315/ay',  features: ['API', 'AutoSSL', 'Plugins'] },
    { name: 'Pro',       slots: 100, price: '₺675/ay',  features: ['+ Mail Server', '+ WHMCS', '+ AI Asistan'] },
    { name: 'Unlimited', slots: '∞', price: '₺1.125/ay',features: ['Sınırsız hesap', 'Tüm özellikler'] },
    { name: 'Enterprise',slots: '∞', price: '₺3.555/ay',features: ['Multi-server', 'SLA', 'Önceliklendirilmiş destek'] },
]

const polling = ref(false)
const pollStatus = ref(null)
let pollTimer = null

function copyMid() {
    navigator.clipboard.writeText(props.fingerprint.machine_id)
}

async function pollOnce() {
    try {
        const { data } = await axios.get('/lisans/poll')
        pollStatus.value = data.status
        if (data.status === 'activated') {
            stopPolling()
            window.location.href = '/'
        }
    } catch (e) {
        pollStatus.value = 'error'
    }
}

function startPolling() {
    polling.value = true
    pollTimer = setInterval(pollOnce, 30000)
    pollOnce()
}

function stopPolling() {
    polling.value = false
    if (pollTimer) clearInterval(pollTimer)
}

onMounted(() => {
    // Pending intent varsa otomatik polling başlat
    pollOnce().then(() => {
        if (pollStatus.value && pollStatus.value !== 'no_pending_intent' && pollStatus.value !== 'activated') {
            startPolling()
        }
    })
})

onUnmounted(stopPolling)
</script>

<template>
    <Head title="Lisans Satın Al" />
    <div class="min-h-screen bg-gradient-to-br from-slate-900 via-indigo-900 to-slate-900 text-white">
        <div class="mx-auto max-w-5xl px-6 py-12">
            <header class="text-center mb-12">
                <img src="/images/onoxsoft-mark.svg" class="mx-auto h-16 mb-4" alt="Onoxsoft" />
                <h1 class="text-4xl font-bold">Onoxsoft Panel — Lisans Gerekli</h1>
                <p class="mt-3 text-slate-300">Hosting kontrol paneliniz aktif olabilmesi için geçerli bir lisans gerektirir.</p>
            </header>

            <!-- KillSwitch banner -->
            <div v-if="kill_switch_active" class="mb-6 rounded-lg bg-red-900/50 border border-red-700 p-4">
                <p class="font-semibold">⚠ Mail Servisleri Durduruldu</p>
                <p class="text-sm text-red-200 mt-1">Sebep: {{ kill_switch_reason }} — Lisans aktive edilince Postfix/Dovecot otomatik başlar.</p>
            </div>

            <!-- Mevcut sunucu bilgileri -->
            <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
                <div class="rounded-xl bg-white/5 backdrop-blur border border-white/10 p-5">
                    <h3 class="font-semibold mb-3 text-slate-200">Bu Sunucu</h3>
                    <dl class="text-sm space-y-1 font-mono">
                        <div><dt class="text-slate-400 inline">IP:</dt> <dd class="inline">{{ fingerprint.ip }}</dd></div>
                        <div><dt class="text-slate-400 inline">Hostname:</dt> <dd class="inline">{{ fingerprint.hostname }}</dd></div>
                        <div class="flex items-center gap-2">
                            <span class="text-slate-400">Machine ID:</span>
                            <span class="truncate flex-1">{{ fingerprint.machine_id.substring(0,20) }}…</span>
                            <button @click="copyMid" class="text-xs text-indigo-300 hover:text-indigo-100">[Kopyala]</button>
                        </div>
                    </dl>
                </div>
                <div v-if="current_license" class="rounded-xl bg-emerald-900/30 backdrop-blur border border-emerald-700 p-5">
                    <h3 class="font-semibold mb-3 text-emerald-200">Aktif Lisans</h3>
                    <dl class="text-sm space-y-1">
                        <div><dt class="text-emerald-400 inline">Tier:</dt> <dd class="inline capitalize">{{ current_license.tier }}</dd></div>
                        <div><dt class="text-emerald-400 inline">Bitiş:</dt> <dd class="inline">{{ new Date(current_license.expires_at).toLocaleDateString('tr-TR') }}</dd></div>
                        <div v-if="current_license.account_limit"><dt class="text-emerald-400 inline">Limit:</dt> <dd class="inline">{{ current_license.account_limit }} hesap</dd></div>
                    </dl>
                </div>
            </div>

            <!-- Plan kartları -->
            <h2 class="text-2xl font-bold text-center mb-6">Plan Seçin</h2>
            <div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
                <div v-for="t in tiers" :key="t.name"
                     class="rounded-xl bg-white/10 backdrop-blur border border-white/20 p-5 flex flex-col">
                    <h3 class="font-bold text-lg">{{ t.name }}</h3>
                    <p class="mt-2 text-2xl font-bold text-indigo-300">{{ t.price }}</p>
                    <p class="text-xs text-slate-400 mb-3">{{ t.slots }} hesap</p>
                    <ul class="text-xs space-y-1 text-slate-300 flex-1">
                        <li v-for="f in t.features" :key="f">✓ {{ f }}</li>
                    </ul>
                </div>
            </div>

            <!-- Satın al butonu (intent gen + redirect) -->
            <form method="POST" :action="route('license.intent')" class="text-center">
                <input type="hidden" name="_token" :value="$page.props.csrf_token" />
                <button type="submit"
                    class="rounded-lg bg-indigo-600 px-8 py-3 text-lg font-semibold text-white shadow hover:bg-indigo-700">
                    {{ server_url.replace(/^https?:\/\//, '') }}'de Satın Al →
                </button>
            </form>

            <!-- Polling status -->
            <div v-if="polling || pollStatus" class="mt-8 text-center text-sm">
                <p v-if="pollStatus === 'awaiting_payment'" class="text-amber-300">⏳ Ödeme bekleniyor…</p>
                <p v-else-if="pollStatus === 'ready_to_activate'" class="text-emerald-300">✓ Aktive ediliyor…</p>
                <p v-else-if="pollStatus === 'activated'" class="text-emerald-300">✓ Lisans aktif! Yönlendiriliyor…</p>
                <p v-else-if="pollStatus === 'error'" class="text-red-300">Bağlantı hatası — tekrar dene</p>
                <p v-else-if="polling" class="text-slate-400">Sipariş kontrol ediliyor… (30 sn arayla)</p>
            </div>

            <!-- Destek -->
            <p class="mt-12 text-center text-xs text-slate-400">
                Destek: <a href="mailto:destek@onoxsoft.com" class="text-indigo-300 hover:text-indigo-100">destek@onoxsoft.com</a> ·
                Telefon: +90 (xxx) xxx xx xx
            </p>
        </div>
    </div>
</template>
```

- [ ] **Step 4: Test**

Create `tests/Feature/License/BuyPageTest.php`:
```php
<?php
test('buy sayfası auth gerektirmez (KillSwitch\'te erişilebilir)', function () {
    $this->get('/lisans/satin-al')
        ->assertOk()
        ->assertInertia(fn($p) => $p->component('License/Buy')->has('fingerprint.machine_id'));
});

test('intent generate redirect license server\'a', function () {
    $this->post('/lisans/intent-baslat')
        ->assertRedirect()
        ->assertSessionHas('onoxsoft.pending_intent');
});

test('poll endpoint pending intent yokken 404', function () {
    $this->getJson('/lisans/poll')
        ->assertStatus(404)
        ->assertJson(['status' => 'no_pending_intent']);
});
```

- [ ] **Step 5: Build + run + commit**

```bash
cd /c/xampp/htdocs/PANELTR
npm run build
C:/xampp/php/php-win.exe vendor/bin/pest tests/Feature/License/BuyPageTest.php
git add app/Http/Controllers/License/ resources/js/Pages/License/ routes/web.php tests/Feature/License/BuyPageTest.php
git commit -m "feat(license): /lisans/satin-al sayfası — KillSwitch'te bile erişilebilir

- BuyController: show (fingerprint+plan listesi), generateIntent (intent token üret + LS redirect), poll (30s status check + auto-activate)
- Buy.vue: dark hero, 5 tier card, KillSwitch banner, machine_id kopyala, polling
- 3 route: license.buy / license.intent / license.poll
- 3 test pass"
```

---

### Task 10: Customer-Initiated IP Transfer (Admin Panel)

**Files:**
- Create: `app/Http/Controllers/License/ActivateController.php`
- Modify: `resources/js/Pages/Admin/License/Index.vue` (var olan dosya — update)
- Create: `tests/Feature/License/IpTransferTest.php`

- [ ] **Step 1: ActivateController — transfer endpoint**

Create `app/Http/Controllers/License/ActivateController.php`:
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\License;

use App\Domain\License\Services\LicenseManager;
use App\Domain\License\Services\MachineFingerprint;
use App\Domain\License\Services\OnoxsoftLicenseClient;
use App\Domain\License\Exceptions\LicenseClientException;
use App\Http\Controllers\Controller;
use Illuminate\Http\{JsonResponse, Request};

class ActivateController extends Controller {
    public function transferIp(
        Request $request,
        LicenseManager $manager,
        OnoxsoftLicenseClient $client,
        MachineFingerprint $fp,
    ): JsonResponse {
        $claims = $manager->getValidClaims();
        if (!$claims) {
            return response()->json(['error' => 'no_active_license'], 422);
        }

        $licenseUuid = $claims['sub'] ?? '';
        if (!$licenseUuid) return response()->json(['error' => 'invalid_jwt'], 422);

        try {
            $result = $client->transferIp($licenseUuid, [
                'new_ip'         => $request->server('SERVER_ADDR') ?: $request->ip(),
                'new_machine_id' => $fp->machineId(),
                'new_hostname'   => $fp->hostname(),
            ]);
            $manager->storeJwt($result['jwt']);
            return response()->json([
                'message' => 'IP başarıyla aktarıldı',
                'tier'    => $result['claims']['license']['tier'] ?? null,
                'bound_ip'=> $result['claims']['license']['bound_ip'] ?? null,
            ]);
        } catch (LicenseClientException $e) {
            return response()->json(['error' => $e->getMessage()], 502);
        }
    }
}
```

Add route:
```php
Route::middleware('auth')->post('/lisans/ip-transfer', [\App\Http\Controllers\License\ActivateController::class, 'transferIp'])
    ->name('license.ip-transfer');
```

- [ ] **Step 2: Test**

Create `tests/Feature/License/IpTransferTest.php`:
```php
<?php
use App\Models\User;

test('IP transfer endpoint auth gerektirir', function () {
    $this->postJson('/lisans/ip-transfer')->assertRedirect('/login');
});

test('lisans yokken transfer 422', function () {
    $user = User::factory()->create();
    $user->assignRole('admin');

    $this->actingAs($user)
        ->postJson('/lisans/ip-transfer')
        ->assertStatus(422)
        ->assertJson(['error' => 'no_active_license']);
})->skip('Dev mode bypass — production gerçek lisans gerekli');
```

(Test minimal — gerçek transfer L3 anti-crack'te full integration alır.)

- [ ] **Step 3: Commit**

```bash
git add app/Http/Controllers/License/ActivateController.php routes/web.php tests/Feature/License/IpTransferTest.php
git commit -m "feat(license): /lisans/ip-transfer — admin panel'den IP aktarma

- ActivateController.transferIp: mevcut JWT'den uuid alır, /api/v1/transfer-ip çağırır
- Yeni JWT cache'lenir, eski binding LS tarafında stale olur
- Auth gerekir (admin/customer ortak)
- Skip test: real transfer L3'te"
```

---

## Phase 5 — Verification (Task 11)

### Task 11: Full Suite + Integration Test

**Files:**
- Create: `tests/Feature/Integration/LicenseE2eTest.php`

- [ ] **Step 1: E2E integration test**

Create `tests/Feature/Integration/LicenseE2eTest.php`:
```php
<?php
use App\Domain\License\Services\JwtVerifier;
use App\Domain\License\Services\LicenseManager;
use App\Domain\License\Services\OnoxsoftLicenseClient;
use Illuminate\Support\Facades\Http;

test('end-to-end: intent → activate → heartbeat → transfer-ip (Http::fake ile)', function () {
    // Test key pair
    $kp = sodium_crypto_sign_keypair();
    $priv = sodium_crypto_sign_secretkey($kp);
    $pub  = sodium_crypto_sign_publickey($kp);

    // Override verifier ile test pubkey
    app()->instance(JwtVerifier::class, new JwtVerifier($pub));

    // 1) Intent token üret
    $builder = app(\App\Domain\License\Services\PurchaseIntentBuilder::class);
    config(['onoxsoft.intent.secret' => 'e2e-test-secret']);
    app()->forgetInstance(\App\Domain\License\Services\PurchaseIntentBuilder::class);
    $builder = new \App\Domain\License\Services\PurchaseIntentBuilder(
        app(\App\Domain\License\Services\MachineFingerprint::class),
        'e2e-test-secret'
    );

    $token = $builder->build('1.2.3.4', 'panel.test', '1.0.0');
    expect($token)->toBeString();

    // 2) /api/v1/activate mock — JWT döner
    $jwt = makeJwtForE2e($priv, [
        'sub' => 'license-e2e-uuid',
        'iss' => 'onox.com.tr',
        'aud' => 'onoxsoft-panel',
        'iat' => time(),
        'exp' => time() + 86400,
        'license' => [
            'tier' => 'pro',
            'account_limit' => 100,
            'features' => ['api','mail_server','autossl'],
            'bound_ip' => '1.2.3.4',
            'expires_at' => now()->addYear()->toIso8601String(),
            'customer_email' => 'test@example.com',
        ],
    ]);

    Http::fake([
        '*/api/v1/activate' => Http::response(['license_jwt' => $jwt, 'expires_at' => now()->addDay()->toIso8601String()], 200),
    ]);

    $client = app(OnoxsoftLicenseClient::class);
    $result = $client->activate([
        'intent_token' => $token,
        'ip' => '1.2.3.4',
        'machine_id' => 'mid-e2e',
        'hostname' => 'panel.test',
        'panel_version' => '1.0.0',
    ]);

    expect($result['claims']['license']['tier'])->toBe('pro');

    // 3) JWT cache'e store
    $manager = app(LicenseManager::class);
    $manager->storeJwt($result['jwt']);
    expect($manager->tier())->toBe('pro');
    expect($manager->hasFeature('mail_server'))->toBeTrue();
    expect($manager->hasFeature('cluster'))->toBeFalse();
});

test('KillSwitch lisans yenilenince deactivate olur', function () {
    $kp = sodium_crypto_sign_keypair();
    $priv = sodium_crypto_sign_secretkey($kp);
    $pub  = sodium_crypto_sign_publickey($kp);
    app()->instance(JwtVerifier::class, new JwtVerifier($pub));

    $killSwitch = app(\App\Domain\License\Services\KillSwitch::class);
    $killSwitch->activate('expired_test', 'JWT exp dolmuştu');
    expect($killSwitch->isActive())->toBeTrue();

    // Yeni JWT
    $jwt = makeJwtForE2e($priv, [
        'sub' => 'uuid',
        'exp' => time() + 86400,
        'license' => ['tier' => 'pro', 'features' => ['api'], 'account_limit' => 100, 'bound_ip' => '1', 'expires_at' => now()->addYear()->toIso8601String(), 'customer_email' => 'x@x.com'],
    ]);

    $manager = app(LicenseManager::class);
    $manager->storeJwt($jwt);

    expect($killSwitch->isActive())->toBeFalse('storeJwt KillSwitch otomatik deactivate etmeli');
});

function makeJwtForE2e(string $priv, array $claims): string {
    $h = ['alg' => 'EdDSA', 'typ' => 'JWT'];
    $h64 = rtrim(strtr(base64_encode(json_encode($h, JSON_UNESCAPED_SLASHES)), '+/', '-_'), '=');
    $p64 = rtrim(strtr(base64_encode(json_encode($claims, JSON_UNESCAPED_SLASHES)), '+/', '-_'), '=');
    $msg = $h64 . '.' . $p64;
    $sig = sodium_crypto_sign_detached($msg, $priv);
    return $msg . '.' . rtrim(strtr(base64_encode($sig), '+/', '-_'), '=');
}
```

- [ ] **Step 2: Run full suite**

```bash
cd /c/xampp/htdocs/PANELTR
C:/xampp/php/php-win.exe vendor/bin/pest 2>&1 | tail -5
```

Expected: ~275 passed (265 PANELTR prior + ~10 new L2). Eğer regression varsa fix gerekir.

- [ ] **Step 3: L2 release tag**

```bash
git add tests/Feature/Integration/
git commit -m "test(integration): e2e — intent → activate → JWT cache → KillSwitch deactivate

- Http::fake ile License Server simulation
- Full client + manager + KillSwitch zinciri test edildi
- 2 integration test pass"

git tag -a v0.2.0-L2-PanelHardening -m "L2 complete — PANELTR panel hardening

11 task tamamlandı:
- Phase 1: Branding (Onoxsoft → Onoxsoft Panel)
- Phase 2: License client core (MachineFP, IntentBuilder, JwtVerifier, OnoxsoftLicenseClient)
- Phase 3: KillSwitch + EnforceLicense + HeartbeatCommand
- Phase 4: Buy sayfası + IP transfer
- Phase 5: Integration testler

PANELTR artık L1 license server'a real HTTP üzerinden bağlı."

git log --oneline -15
```

---

## L2 Done — Definition of Done

- [ ] Tüm 11 task'ın commit'i var
- [ ] `vendor/bin/pest` → 275+ test, 0 fail
- [ ] Lisans yokken `/admin` → `/lisans/satin-al` redirect
- [ ] Lisans varken normal panel açılır
- [ ] `onoxsoft:license-heartbeat` artisan command çalışır
- [ ] "Lisans Satın Al" sayfasında machine_id kopyalanabilir, plan kartları görünür
- [ ] Manuel integration: PANELTR'den onox-license.test'e activate çağrısı (sandbox)
- [ ] v0.2.0-L2-PanelHardening tag landed
- [ ] Tüm "Onoxsoft " (ürün adı) → "Onoxsoft Panel" değişti, "ONOXSOFT" (şirket) korundu

---

## L3 Preview — Sonraki Faz

L2 sonrası **L3 anti-crack** (~4 hafta):
- TLS pinning (config/onoxsoft/tls-pinned.json)
- HMAC cache tamper detection
- Time warp tespit
- Honeypot endpoints
- ionCube production build pipeline
- Integrity check (boot-time SHA256)
- Dual-install server-side detection

L3 ayrı plan dosyası olarak yazılacak.

---

## Açık Sorular (L2 sırasında karara bağlanacak)

- Heartbeat 72 saat grace nasıl ölçülecek? (Şu an: anında KillSwitch. L3'te `failed_heartbeats` counter eklenir)
- KillSwitch'te SUDO yetkisi nasıl verilir? (`/etc/sudoers.d/onoxsoft` — Phase 1 plan'da tanımlandı, deployment task)
- License Server prod URL ne zaman değişir? (L1 GA'da onox.com.tr canlıya alındığında)
- Panel-side AGPL vs commercial-tier ayrımı kodda nasıl? (Branding fix sırasında karara bağlanır)

---

**Plan versiyonu:** v1.0 (2026-05-14)
**Toplam task:** 11 (Phases 1-5)
**Tahmini effort:** ~180 saat = 6 hafta part-time (haftada 12-15 saat)
**Spec referansı:** `docs/superpowers/specs/2026-05-13-onoxsoft-license-server-design.md` §8 Panel License Client
**L1 dependency:** `v0.1.0-L1-MVP` (commit `396f6ec`)
