# 03 — Laravel Integration (copy-paste)

Target: **Laravel 12 / PHP 8.2**. This is a focused, buildable guide. The behavioural source of truth
is `reference-server-node/server.js`; crypto details are in `02-CRYPTO-CONTRACT.md`.

---

## 0. Dependencies

```bash
composer require phpseclib/phpseclib:~3.0
```

## 1. Config & keys

`.env`:
```
LICENSE_APP_DEFAULT=convertly
LICENSE_LEASE_DAYS=365
LICENSE_MAX_DEVICES=3
LICENSE_PRIVATE_KEY_PATH=/etc/convertly/license_private.pem   # perms 600, outside web root
```
`config/license.php`:
```php
return [
    'lease_days'   => (int) env('LICENSE_LEASE_DAYS', 365),
    'max_devices'  => (int) env('LICENSE_MAX_DEVICES', 3),
    'private_key'  => rtrim(file_get_contents(env('LICENSE_PRIVATE_KEY_PATH'))),
];
```

## 2. Migrations (5 tables)

```php
Schema::create('customers', function (Blueprint $t) {
    $t->string('id')->primary();            // cus_uuid
    $t->string('email');
    $t->string('name')->nullable();
    $t->timestamp('created');
});
Schema::create('apps', function (Blueprint $t) {
    $t->string('appId')->primary();         // 'convertly'
    $t->string('name');
});
Schema::create('licenses', function (Blueprint $t) {
    $t->string('id')->primary();            // lic_uuid
    $t->string('key_hash')->index();        // sha256 hex — never the raw key
    $t->string('customerId');
    $t->string('appId');
    $t->string('tier')->default('standard');
    $t->string('status')->default('active');// active | revoked
    $t->integer('maxDevices')->default(3);
    $t->string('source')->nullable();       // admin | paypal | stripe
    $t->string('orderId')->nullable();
    $t->timestamp('created');
    $t->unique(['key_hash','appId']);
});
Schema::create('devices', function (Blueprint $t) {
    $t->string('id')->primary();            // dev_uuid
    $t->string('licenseId');
    $t->string('fingerprint');
    $t->string('name')->nullable();
    $t->timestamp('firstSeen');
    $t->timestamp('lastSeen');
    $t->unique(['licenseId','fingerprint']);// makes the device limit race-safe
});
Schema::create('events', function (Blueprint $t) {
    $t->id();
    $t->timestamp('ts');
    $t->string('type');                     // activate | refresh | deactivate | create | revoke
    $t->string('licenseId')->nullable();
    $t->string('detail')->nullable();
});
```
Seeder: `DB::table('apps')->insertOrIgnore(['appId'=>'convertly','name'=>'Convertly']);`

## 3. LicenseService (crypto + token helpers)

```php
namespace App\Services;

use phpseclib3\Crypt\RSA;
use phpseclib3\Crypt\PublicKeyLoader;

class LicenseService
{
    public static function b64url(string $bin): string {
        return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
    }
    public static function b64urlDecode(string $s): string {
        return base64_decode(strtr($s, '-_', '+/') . str_repeat('=', (4 - strlen($s) % 4) % 4));
    }
    public static function hashKey(string $key): string {
        return hash('sha256', strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $key)));
    }
    public static function signToken(array $payload): string {
        $p = self::b64url(json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
        $key = PublicKeyLoader::load(config('license.private_key'))
            ->withPadding(RSA::SIGNATURE_PSS)->withHash('sha256')
            ->withMGFHash('sha256')->withSaltLength(32);
        return $p . '.' . self::b64url($key->sign($p));
    }
    public static function readPayload(?string $token): ?array {
        if (!$token || !str_contains($token, '.')) return null;
        $json = self::b64urlDecode(explode('.', $token)[0]);
        return json_decode($json, true) ?: null;
    }
    public static function issueToken(object $lic, string $fingerprint, ?object $cust): string {
        $iat = time();
        return self::signToken([
            'sub' => $lic->customerId, 'lic' => $lic->id, 'app' => $lic->appId, 'tier' => $lic->tier,
            'name' => $cust->name ?? '', 'email' => $cust->email ?? '',
            'dev' => $fingerprint, 'iat' => $iat, 'exp' => $iat + config('license.lease_days') * 86400,
        ]);
    }
    // Crockford Base32 key generator (admin + shop share this)
    public static function generateKey(): string {
        $alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; // no I L O U
        $groups = [];
        for ($g = 0; $g < 3; $g++) {
            $s = '';
            for ($i = 0; $i < 5; $i++) $s .= $alphabet[random_int(0, 31)];
            $groups[] = $s;
        }
        return 'CONV-' . implode('-', $groups);
    }
}
```

## 4. Public API routes (`routes/api.php`)

```php
use App\Http\Controllers\ActivationController;

Route::post('/activate',   [ActivationController::class, 'activate'])->middleware('throttle:20,1');
Route::post('/refresh',    [ActivationController::class, 'refresh'])->middleware('throttle:60,1');
Route::post('/deactivate', [ActivationController::class, 'deactivate'])->middleware('throttle:60,1');
Route::get('/health', fn() => response()->json(['ok' => true]));
```
> If `routes/api.php` is prefixed with `/api`, the paths become `/api/activate` etc. — which is exactly
> what the apps call. Confirm the final URL is `https://license.kotsch.tech/api/activate`.

## 5. ActivationController

```php
namespace App\Http\Controllers;

use App\Services\LicenseService as L;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

class ActivationController extends Controller
{
    public function activate(Request $r) {
        $d = $r->validate([
            'key' => 'required|string', 'appId' => 'required|string',
            'fingerprint' => 'required|string', 'deviceName' => 'nullable|string',
        ]);
        $lic = DB::table('licenses')->where('key_hash', L::hashKey($d['key']))
            ->where('appId', $d['appId'])->first();
        if (!$lic) return response()->json(['error' => 'invalid_key'], 404);
        if ($lic->status !== 'active') return response()->json(['error' => 'revoked'], 403);
        $cust = DB::table('customers')->where('id', $lic->customerId)->first();

        $limited = false;
        DB::transaction(function () use ($lic, $d, &$limited) {
            $dev = DB::table('devices')->where('licenseId', $lic->id)
                ->where('fingerprint', $d['fingerprint'])->lockForUpdate()->first();
            if (!$dev) {
                $count = DB::table('devices')->where('licenseId', $lic->id)->lockForUpdate()->count();
                if ($count >= $lic->maxDevices) { $limited = true; return; }
                DB::table('devices')->insert([
                    'id' => 'dev_' . Str::uuid(), 'licenseId' => $lic->id,
                    'fingerprint' => $d['fingerprint'], 'name' => $d['deviceName'] ?? null,
                    'firstSeen' => now(), 'lastSeen' => now(),
                ]);
            } else {
                DB::table('devices')->where('id', $dev->id)->update(['lastSeen' => now()]);
            }
        });
        if ($limited) return response()->json(['error' => 'device_limit', 'maxDevices' => $lic->maxDevices], 403);

        $this->log('activate', $lic->id, substr($d['fingerprint'], 0, 12));
        return response()->json([
            'token' => L::issueToken($lic, $d['fingerprint'], $cust),
            'tier' => $lic->tier, 'name' => $cust->name ?? '',
        ]);
    }

    public function refresh(Request $r) {
        $payload = L::readPayload($r->input('token'));
        if (!$payload) return response()->json(['error' => 'bad_token'], 400);
        $lic = DB::table('licenses')->where('id', $payload['lic'])->first();
        if (!$lic || $lic->status !== 'active') return response()->json(['error' => 'revoked'], 403);
        $dev = DB::table('devices')->where('licenseId', $lic->id)
            ->where('fingerprint', $payload['dev'])->first();
        if (!$dev) return response()->json(['error' => 'device_not_bound'], 403);
        DB::table('devices')->where('id', $dev->id)->update(['lastSeen' => now()]);
        $cust = DB::table('customers')->where('id', $lic->customerId)->first();
        $this->log('refresh', $lic->id, substr($payload['dev'], 0, 12));
        return response()->json(['token' => L::issueToken($lic, $payload['dev'], $cust), 'tier' => $lic->tier]);
    }

    public function deactivate(Request $r) {
        $payload = L::readPayload($r->input('token'));
        if (!$payload) return response()->json(['error' => 'bad_token'], 400);
        DB::table('devices')->where('licenseId', $payload['lic'])
            ->where('fingerprint', $payload['dev'])->delete();
        $this->log('deactivate', $payload['lic'], substr($payload['dev'], 0, 12));
        return response()->json(['ok' => true]);
    }

    private function log(string $type, ?string $licId, ?string $detail): void {
        DB::table('events')->insert(['ts' => now(), 'type' => $type, 'licenseId' => $licId, 'detail' => $detail]);
    }
}
```

## 6. Admin (issue a key by hand)

Minimum: a form that creates a customer (if new) + a license and shows the **raw key once**
(only the hash is stored — you can't show it again later).

```php
public function store(Request $r) {
    $d = $r->validate(['name'=>'required|string','email'=>'required|email','appId'=>'required|string']);
    $cust = DB::table('customers')->where('email', $d['email'])->first();
    $custId = $cust->id ?? 'cus_'.Str::uuid();
    if (!$cust) DB::table('customers')->insert(
        ['id'=>$custId,'email'=>$d['email'],'name'=>$d['name'],'created'=>now()]);

    $key = \App\Services\LicenseService::generateKey();      // show ONCE
    DB::table('licenses')->insert([
        'id'=>'lic_'.Str::uuid(), 'key_hash'=>\App\Services\LicenseService::hashKey($key),
        'customerId'=>$custId, 'appId'=>$d['appId'], 'tier'=>'standard', 'status'=>'active',
        'maxDevices'=>config('license.max_devices'), 'source'=>'admin', 'created'=>now(),
    ]);
    DB::table('events')->insert(['ts'=>now(),'type'=>'create','licenseId'=>null,'detail'=>$d['email']]);
    return back()->with('key', $key);   // display it to the admin; email it to the customer
}
```
Revoke = `UPDATE licenses SET status='revoked'`. Full admin surface (list/filter/detail/audit) is in
`spec/WEBSITE-ADMIN-LICENSING-SPEC.md §5`.

## 7. Shop (Phase 3 — PayPal / Stripe)

On a verified paid order, do exactly what admin `store()` does, then **email the key**:

```php
// inside your verified webhook handler (signature-checked!)
$key = LicenseService::generateKey();
// ... insert customer + license with source='paypal'|'stripe', orderId=<order>
Mail::to($email)->send(new \App\Mail\LicenseKeyMail($key, $name));  // "your key: CONV-…"
```
- **PayPal:** verify the webhook signature / capture via the Orders API before issuing.
- **Stripe:** verify the `Stripe-Signature` header (`checkout.session.completed`).
- Idempotency: key off `orderId` so a re‑delivered webhook doesn't mint a second key.
- Email must state: *"Enter this key in Convertly (Windows or Android) → works on up to 3 devices."*

## 8. Security checklist (must all be true)

- [ ] HTTPS only; the 4 public routes rate‑limited (see throttle middleware above).
- [ ] Private key perms `600`, outside web root, not in git.
- [ ] Keys stored **hashed** only; raw key shown once at creation, never persisted.
- [ ] `/admin/*` behind auth (your existing login) + CSRF.
- [ ] Webhooks signature‑verified and idempotent.
- [ ] `devices` unique index `(licenseId, fingerprint)` present (race‑safe limit).
- [ ] Verified: a token your server issues passes `public_app_trusted.b64` verification
      (`02-CRYPTO-CONTRACT.md §verify`).
