# Licencing Guide — Integrate ANY product with the Apnar Business License Server

> **How to use this file:** Put this file at the **root of YOUR product's repository**, open that project in opencode / an AI coding tool, and say:
> `"Read licencing.md and integrate license protection into this product following the guide."`
>
> The guide is **self-contained**. It explains the license server's API contract, the signed-document format, and exactly what needs to be built in the product. It expects the AI to detect the product's language/framework and adapt the reference implementation.

---

## 1. What we are building

This repository is a **client-side license integration** for a downloadable/web software product (e.g. a coaching management software).

The product talks to a remote **License Server** that:

1. Issues license keys (server-side, by an administrator or the master platform).
2. **Activates** a key against a specific domain/installation.
3. **Validates** the license periodically (phone-home) and returns a **signed license document**.
4. Enforces domain limits, expiry, grace period, revocation.

**The product (this repo) must:**

- Let an admin/user enter a license key (activation form).
- Phone home (`activate` once, then `validate`/`ping` periodically).
- **Verify** the signed document using the product's RSA **public** key (never trust the network blindly).
- Lock the app to the `allowed_domains` and the wire `status`.
- Block/shut down the app when the license is revoked, expired past grace, or domain-mismatched.

The license key is **only shown ONCE** after purchase. The product stores it locally; the server only ever stores a SHA-256 hash of it.

---

## 2. License Server facts

| Item | Value |
|---|---|
| Server base URL (production) | `https://your-license-server.example.com/api/v1` |
| Server base URL (local dev) | `http://127.0.0.1:8000/api/v1` |
| Auth for client calls | **None** — client identifies itself with `app` (product app_key) + `license_key` |
| Protocol | JSON over HTTPS |
| Rate limits | `activate`: 30/min per IP+key · `validate`/`ping`: 120/min per IP |
| Key hashing | Server stores only `sha256(license_key)` |

Each product (this sales item) has a unique **`app_key`** and an **RSA keypair (2048-bit)**. The public key is what this product uses to verify signed documents. You must know the product's `app_key` and public key — see §6.

---

## 3. API endpoints

All endpoints accept JSON. Successful responses return a **signed document** (see §4).

### 3.1 Activate — `POST /license/activate`

Called **once per installation** (on first launch / first login).

**Request body:**
```json
{
  "license_key":  "COACH-XXXX-XXXX-XXXX-XXXX",
  "domain":       "app.myclientcoaching.com",
  "instance_id":  "uuid-or-machine-id",
  "app":          "coaching_app",
  "app_version":  "1.0.0"
}
```

| Field | Required | Notes |
|---|---|---|
| `license_key` | ✅ | The key the customer typed. |
| `domain` | ✅ | Domain/host where the app runs. Normalized server-side (strips scheme/`www.`/default ports/trailing dot, lowercased). |
| `instance_id` | | Unique id of this installation (helps identify which machine activated). |
| `app` | | **Must match** the product `app_key`, else 404. Strongly recommended. |
| `app_version` | | Version string for reporting. |

**Errors:**
- `404` `{"error_code":"invalid_key","error":"License key not found..."}` — wrong key or wrong `app`.
- `422` `{"error_code":"invalid_domain",...}` — malformed domain.
- `429` — rate limited.

**Success (200):** signed document object (see §4).

### 3.2 Validate — `POST /license/validate`

Called **periodically** (on app start, cron, every N hours) to refresh the license.

**Request body:** same as activate (`license_key`, `domain`, `instance_id`, `app`, `app_version`).

**Behavior:**
- Updates server-side `last_seen_at` + `instance_id` for the matching registered domain.
- Returns a fresh signed document with current `status` + `valid_until`.

### 3.3 Ping — `POST /license/ping`

Lightweight alias of validate — same contract, same body. Use it for frequent heartbeats.

---

## 4. Signed document format (IMPORTANT — verify this)

Every success response is a **signed document**, NOT plain JSON:

```json
{
  "payload":   "<base64 of canonical JSON>",
  "signature": "<base64 of RSA-SHA256 signature over the base64 string>"
}
```

**Verification algorithm (exact, must match):**

```
1. $canonical  = base64_decode(payload)                             // the JSON string
2. $json       = json_decode($canonical, true)                      // the data
3. $ok         = openssl_verify(
                    payload,                                        // the BASE64 string itself
                    base64_decode(signature),
                    PUBLIC_KEY,                                     // product's RSA public key PEM
                    OPENSSL_ALGO_SHA256
                 ) === 1
```

> ⚠️ The signature is computed over the **base64 payload string**, not over the decoded JSON bytes. This is on purpose — implement it exactly as above.

### Decoded `payload` contents

```json
{
  "license_key":     "COACH-XXXX-XXXX-XXXX-XXXX",
  "client":          "Customer name",
  "plan":            "monthly",
  "max_domains":     1,
  "instance_id":     "",
  "app":             "coaching_app",
  "app_version":     "1.0.0",
  "allowed_domains": ["app.myclientcoaching.com"],
  "issued_at":       "2026-08-10T12:00:00Z",
  "valid_until":     "2026-09-09T12:00:00Z",
  "status":          "active"
}
```

| Field | Meaning |
|---|---|
| `status` | **`active`** or **`revoked`** (`blocked`/`revoked` server states are flattened to `revoked` on the wire). |
| `valid_until` | License expiry (ISO8601). Combine with a local grace policy (§7). |
| `allowed_domains` | The domains activated for this license. **This app must confirm its own domain is in the list**, otherwise treat as `domain_mismatch`. |
| `max_domains` | How many domains are allowed. |
| `app` | The product app_key. Sanity-check it equals what you expect. |
| `plan`, `client`, `app_version`, `instance_id` | Display/reporting metadata. |

---

## 5. What to implement in THIS product (codebase changes)

### 5.1 A `LicenseClient` service/class

Centralizes all server calls. Responsibilities:

- `activate(key, domain)` → returns verified document
- `validate()` → refresh + verify + persist
- `ping()` → cheap heartbeat
- `verify(document)` → cryptographic verification (§4)
- `persist(document)` → save JSON locally (config file, DB, file in user-data dir)
- `load()` → read + verify saved doc
- Network timeouts (e.g. 10s) + graceful offline handling (see §8).

### 5.2 Activation UI

- A page/screen where the customer pastes the license key.
- On submit: call `activate`, verify, persist, unlock the app.
- Show clear errors for: `invalid_key`, `invalid_domain`, network failure, revoked.
- **Do NOT bake in a license key** anywhere in the product.

### 5.3 Startup validation hook

On app start:
1. Load saved doc.
2. If none → show activation screen (locked mode).
3. If present → verify signature (fail closed if tampered).
4. Call `validate()` to refresh (async/background if possible, so the app starts fast).
5. Apply enforcement (§7).

### 5.4 Enforcement points (deactivate features when not licensed)

Define a single `isLicensed(): bool` helper and call it at gates:
- Login / session start
- User-interface shell / main window (lock the app if unlicensed)
- Long-running loops (re-check every few minutes)
- Do NOT just hide UI — also enforce on the backend (API middleware / service layer), so a bypass of the UI can't defeat the gate.

### 5.5 Scheduled validation (heartbeat)

- **Desktop/CLI:** a background thread / scheduler ticking every N minutes.
- **Web (Laravel/etc.):** a scheduled job or middleware re-validating per request (cached with a short TTL to avoid hammering the server, staying under 120/min per IP).

### 5.6 Logging

Log license events locally: activation attempt, validation ok/fail, domain mismatch, enforcement trigger. **Never log the full license key** — log the last 4 chars only (`COACH-XXXX-XXXX-XXXX-<last4>`).

---

## 6. What YOU must supply to this product (configuration)

Create a config file (`.env` for Laravel, `config.json` etc. otherwise) with:

```dotenv
LICENSE_SERVER_BASE_URL=https://your-license-server.example.com/api/v1
LICENSE_SERVER_APP_KEY=coaching_app
LICENSE_SERVER_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
MIIBIjANBgkq...
-----END PUBLIC KEY-----"
LICENSE_GRACE_SECONDS=172800          # 48h local grace after valid_until
LICENSE_CHECK_INTERVAL_SECONDS=3600   # how often to validate
```

Obtain `LICENSE_SERVER_APP_KEY` + the public key from the License Server admin (product detail page). They are issued **once per product**.

> Device/installation identification: derive `instance_id` from a machine GUID (desktop) or the install's primary domain + a persisted random UUID (web). Persist it so it stays stable.

---

## 7. License state logic (make this exact)

Given a **verified** document, compute the enforcement decision:

```
status == "revoked"            -> LOCKED (terminal). Show "license revoked".
valid_until > now              -> LICENSED
valid_until < now < valid_until + grace -> LICENSED_WITH_WARNING ("expired soon / renewal")
now > valid_until + grace      -> LOCKED ("license expired")
own domain NOT in allowed_domains -> LOCKED as domain_mismatch
signature invalid / doc tampered    -> LOCKED (fail closed)
```

- **LICENSED**: full functionality.
- **LICENSED_WITH_WARNING**: keep working but warn about renewal.
- **LOCKED**: show activation/renewal screen; allow the user to enter a valid key to re-activate.

---

## 8. Offline behavior

The product must still work with a verified local doc when offline:

- If a valid doc exists and is within `valid_until + grace` → run.
- If offline and doc is expired → lock (do not extend locally).
- Validate on next opportunity; if the server says `revoked`, lock immediately on next online check.

---

## 9. Reference implementation (PHP — adapt to your stack)

A minimal, dependency-free client you can port to any language:

```php
final class LicenseClient
{
    public function __construct(
        private string $baseUrl,
        private string $appKey,
        private string $publicKeyPem,
        private string $domain,
        private string $instanceId,
        private string $storePath,
    ) {}

    public function activate(string $licenseKey): array
    {
        return $this->request('activate', compact('licenseKey'));
    }

    public function validate(): array
    {
        return $this->request('validate');
    }

    public function ping(): array
    {
        return $this->request('ping');
    }

    private function request(string $endpoint, array $extra = []): array
    {
        $body = array_merge(
            ['domain' => $this->domain, 'instance_id' => $this->instanceId, 'app' => $this->appKey],
            $extra,
        );

        $ch = curl_init($this->baseUrl . '/license/' . $endpoint);
        curl_setopt_array($ch, [
            CURLOPT_POST           => true,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 10,
            CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
            CURLOPT_POSTFIELDS     => json_encode($body),
        ]);
        $raw  = curl_exec($ch);
        $code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);

        $doc = json_decode((string) $raw, true) ?: [];

        if ($code !== 200 || ! $this->verify($doc)) {
            throw new \RuntimeException($doc['error'] ?? 'License request failed.');
        }

        $this->persist($doc);

        return $doc;
    }

    public function verify(array $doc): bool
    {
        if (! isset($doc['payload'], $doc['signature'])) {
            return false;
        }

        $public = openssl_pkey_get_public($this->publicKeyPem);

        return openssl_verify(
            $doc['payload'],
            base64_decode($doc['signature'], true),
            $public,
            OPENSSL_ALGO_SHA256,
        ) === 1;
    }

    public function load(): ?array
    {
        if (! is_file($this->storePath)) {
            return null;
        }

        $doc = json_decode((string) file_get_contents($this->storePath), true);

        return is_array($doc) && $this->verify($doc) ? $doc : null;
    }

    public function save(): array
    {
        $doc = $this->load() or throw new \RuntimeException('Not licensed.');

        return $doc;
    }

    public function decision(array $doc): string
    {
        $payload  = json_decode(base64_decode($doc['payload'], true), true);
        $now      = time();
        $validTo  = strtotime($payload['valid_until']);
        $graceEnd = $validTo + (int) getenv('LICENSE_GRACE_SECONDS') ?: 172800;
        $inDomain = in_array($this->domain, $payload['allowed_domains'] ?? [], true);

        if (($payload['status'] ?? '') === 'revoked') return 'revoked';
        if (! $inDomain)                               return 'domain_mismatch';
        if ($now <= $validTo)                          return 'licensed';
        if ($now <= $graceEnd)                         return 'grace';
        return 'expired';
    }

    private function persist(array $doc): void
    {
        file_put_contents($this->storePath, json_encode($doc), LOCK_EX);
    }
}

// Usage sketch
$client = new LicenseClient(
    baseUrl:    getenv('LICENSE_SERVER_BASE_URL'),
    appKey:     getenv('LICENSE_SERVER_APP_KEY'),
    publicKeyPem: getenv('LICENSE_SERVER_PUBLIC_KEY'),
    domain:     $_SERVER['HTTP_HOST'] ?? 'localhost',
    instanceId: 'device-uuid',       // stable per install
    storePath:  storage_path('app/license.json'),
);

switch ($client->decision($client->load() ?: throw new \RuntimeException('Not activated'))) {
    case 'revoked':
    case 'domain_mismatch':
    case 'expired':
        // lock the app, show activation screen
        break;
    case 'grace':
        // run with renewal warning
        break;
    default:
        // fully licensed — schedule $client->validate() periodically
}
```

---

## 10. Checklist for the AI doing the integration

- [ ] Find the app's main entry point (desktop main window / web kernel / CLI bootstrap) and add the startup license gate.
- [ ] Create `LicenseClient` (or equivalent) in the app's service layer.
- [ ] Create the activation screen/route + persist the verified document.
- [ ] Add `isLicensed()` enforcement at backend service/middleware level, not just UI.
- [ ] Add scheduled validation (cron/queue/thread) using `LICENSE_CHECK_INTERVAL_SECONDS`.
- [ ] Add offline handling (§8) and exact state logic (§7).
- [ ] Add `.env` (or config) entries from §6.
- [ ] Add tests: verify a tampered doc is rejected, expired→locked, revoked→locked, valid→operational.

---

## 11. Local end-to-end test against the real License Server

Follow the **`activate-and-test-local.md`** (same folder) for the exact steps to run the license server locally, create a product + license key, activate via curl, and verify the signed document — including a ready-to-run PHP verification script.