# License Management System

MokoGitea includes a built-in commercial license management system for organizations that sell extensions (Joomla, Dolibarr, etc.). It handles license key generation, validation, update feed gating, domain enforcement, and payment integration.

## Overview

The license system operates at two levels:

- **Organization-level**: Manages packages and keys shared across all repos in an org
- **Repository-level**: Same features scoped to a single repo, plus update feed URLs

## Core Concepts

### License Packages

A package defines a subscription tier (e.g., "Pro Annual", "Basic Monthly"). Each package specifies:

| Field | Description |
|---|---|
| **Name** | Human-readable name (e.g., "Pro Annual") |
| **Duration** | Key validity in days. 0 = lifetime |
| **Max Sites** | Maximum unique domains. 0 = unlimited |
| **Channels** | Which update streams this package grants access to |
| **Active** | Whether the package is enabled |

### License Keys

Keys are generated from packages. Format: `MOKO-XXXX-XXXX-XXXX-XXXX`.

**Important**: Keys are hashed (SHA-256) before storage. The full key is only shown **once** at creation time. Store it securely.

Each key can have:
- **Licensee name/email** for customer tracking
- **Domain restriction** (comma-separated allowed domains)
- **Max sites override** (0 = use package default)
- **Custom expiry date**

### Master Package

Every org/repo automatically gets a "Master (Internal)" package with an internal key. This provides unlimited access to all channels and cannot be edited or deleted. Master keys can only be revoked.

## Web UI

### Repository Licenses Page

`/{owner}/{repo}/licenses`

- View all packages and issued keys
- Create new packages with channel selection
- Generate keys from packages (auto-generated or custom key for admins)
- Renew expired keys (sync icon, extends by package duration)
- Edit packages and keys (pencil icon)
- Revoke keys (X icon, with confirmation modal)
- Delete packages (trash icon, site admins only, with confirmation modal)
- Copy update feed URLs (clipboard button with tooltip feedback)
- Copy newly created keys (clipboard button in success message)

### Organization Licenses Page

`/{org}/-/licenses`

Same features as repo level, scoped to the organization. Packages and keys created here are shared across all repos in the org.

## API Endpoints

All endpoints are under `/api/v1/repos/{owner}/{repo}/`.

### Authenticated (requires token + repo admin)

| Method | Path | Description |
|---|---|---|
| GET | `/license-packages` | List all packages |
| POST | `/license-packages` | Create a package |
| GET | `/license-keys` | List all keys |
| POST | `/license-keys` | Create a key (returns full key) |
| PATCH | `/license-keys/{id}` | Edit a key |
| DELETE | `/license-keys/{id}` | Delete a key |
| GET | `/license-keys/{id}/usage` | Get usage logs (last 100) |
| POST | `/license-keys/purchase` | Create key from payment webhook |

### Public (no auth required)

| Method | Path | Description |
|---|---|---|
| POST | `/license-keys/validate` | Validate a key + domain |

### Purchase Webhook

`POST /api/v1/repos/{owner}/{repo}/license-keys/purchase`

Designed for payment system integration (Stripe, PayPal, etc.):

```json
{
  "package_id": 1,
  "licensee_name": "John Doe",
  "licensee_email": "john@example.com",
  "domain": "example.com",
  "payment_ref": "stripe_pi_xxx"
}
```

The `payment_ref` field provides **idempotency** -- if a key already exists with that reference, the existing key is returned instead of creating a duplicate.

Response includes the full `raw_key` (only on first creation).

### Validation Endpoint

`POST /api/v1/repos/{owner}/{repo}/license-keys/validate`

For client-side license checks:

```json
{
  "key": "MOKO-XXXX-XXXX-XXXX-XXXX",
  "domain": "example.com"
}
```

Response:

```json
{
  "valid": true,
  "package_name": "Pro Annual",
  "channels": "[\"stable\",\"release-candidate\"]",
  "expires_at": "2027-05-30T00:00:00Z",
  "sites_used": 2,
  "max_sites": 5
}
```

## Update Feed Integration

### Joomla (updates.xml)

`GET /{owner}/{repo}/updates.xml`

When `RequireKey` is enabled for a repo, the XML feed includes a `<downloadkey>` element:

```xml
<update>
  <name>MokoOnyx</name>
  <version>3.10.9</version>
  <downloads>
    <downloadurl type="full" format="zip">https://git.mokoconsulting.tech/.../mokoonyx.zip</downloadurl>
  </downloads>
  <downloadkey prefix="&amp;dlid=" suffix=""/>
</update>
```

This tells Joomla to show a **Download Key** field in System > Update Sites. The key is sent as `&dlid=MOKO-XXXX-...` on download requests.

The server accepts keys via three query parameters: `key`, `download_key`, or `dlid`.

### Dolibarr (dolibarr.json)

`GET /{owner}/{repo}/updates/dolibarr.json`

Uses the same license key validation as the Joomla XML feed. All platforms share the same licensing system. Invalid or missing keys receive an empty response.

## Domain Enforcement

When a key has a `DomainRestriction` set (comma-separated domains):

1. The domain from the request is checked against the allowed list
2. Comparison is case-insensitive
3. Master/internal keys bypass domain checks

### Auto-Association (Lock-on-First-Use)

When a key has **no** `DomainRestriction` set and a domain is provided during validation:

1. The domain is automatically appended to the key's `DomainRestriction`
2. On subsequent heartbeats, additional domains can be added (up to `MaxSites`)
3. Once `MaxSites` is reached, no new domains are accepted
4. This provides zero-config domain locking — the first site to use a key "claims" it

### Site Limit Enforcement

When `MaxSites` is set (on key or package):

1. The system counts unique domains from usage records
2. If the requesting domain is already known, it's allowed through
3. If the domain is new and the limit is reached, the request is rejected
4. Error: `site limit reached (N/N)`
5. Auto-association also respects this limit — domains won't be added past the cap

## Channel System

Channels control which update streams a license key grants access to. Available channels come from the org's Update Stream configuration:

**Default Joomla streams**: stable, release-candidate, beta, alpha, development

Channels are selected via checkboxes when creating/editing a package. They're stored as a JSON array (e.g., `["stable","release-candidate"]`).

A package with no channels selected grants access to **all channels**.

## Enabling the Licensing System

Licensing is **off by default** and must be enabled explicitly.

### Organization Level

Go to **Organization Settings > Licenses & Update Streams** and check:
- **Enable licensing system** -- shows the Licenses tab for all org members
- **Require license key for update feeds** -- gates update XML/JSON feeds behind key validation

### Repository Level

Go to **Repository Settings > Advanced** and check:
- **Enable licensing system** -- shows the Licenses tab for this repo
- **Require license key for update feed access** -- gates this repo's update feeds

Either org-level or repo-level enabling is sufficient to show the Licenses tab.

### Feed Visibility

Controls what unauthenticated users see when accessing update feed URLs:

| Mode | Update Feed | Release Page | Download Files |
|---|---|---|---|
| **Public** | Full feed with download URLs | Public | Public |
| **No-download** | Version info only (URLs stripped) | Public (notes visible) | "Sign in to download" |
| **Hidden** | Empty feed | Redirects to login | Login required |

The **no-download** mode is ideal for commercial extensions: customers see that updates exist (motivating purchase/renewal) but cannot download without signing in. Release notes and changelogs remain publicly readable.

## Key Heartbeat

Every time a license key is successfully validated (via update feed or API), the `LastHeartbeatUnix` timestamp is updated. This is shown as "Last Seen" in the keys table and included in API responses as `last_heartbeat`.

Use this to identify inactive keys or monitor customer usage patterns.

## Release Tag Enforcement

When licensing is enabled, release tags with prerelease suffixes (e.g., `-rc`, `-beta`, `-alpha`, `-dev`) must match a configured update stream suffix. This ensures the update feed generator can correctly categorize releases.

- Tags without prerelease suffixes (e.g., `v1.0.0`) are always allowed (stable stream)
- Tags with suffixes must match a stream (e.g., `v1.0.0-rc1` matches the `release-candidate` stream with suffix `-rc`)
- When licensing is disabled, any tag format is accepted

## Permissions

Licenses use Gitea's **unit permission system** (`TypeLicenses`). Teams can be granted Read, Write, or Admin access.

| Action | Required Permission |
|---|---|
| View licenses page | Unit Read (or org member) |
| Create/edit packages | Unit Write |
| Generate/revoke/renew keys | Unit Write |
| Edit keys | Unit Write |
| Set custom key value | Site admin or org owner |
| Delete packages | Site admin only |
| Edit/delete master package | Blocked (not allowed) |
| Edit master keys | Blocked (only revoke allowed) |

**Note**: Admin-level teams implicitly have admin access to all units (including Licenses) even without explicit TeamUnit records. This means existing admin teams automatically gain license access.

### Key Renewal

The **Renew** action extends a key's expiration by the package's `DurationDays`:
- If the key is still valid, renewal extends from the current expiry date
- If the key has already expired, renewal extends from now
- Renewal also re-activates revoked keys
- For lifetime packages (DurationDays=0), renewal extends by 365 days

### Custom Keys

Site admins and org owners can set a **custom key value** instead of auto-generating. Use cases:
- Migrating from an existing licensing system
- Keys that match external records or payment systems
- Deterministic keys for testing

The custom key is hashed identically to auto-generated keys — no security difference.

## Package Archiving

Packages can be **archived** instead of permanently deleted. This is the recommended workflow for end-of-life packages.

**How archiving works:**
- Archived packages are hidden from the main list
- Existing keys from archived packages **continue to work** (validation, heartbeats, update feeds)
- No new keys can be generated from an archived package
- Archived packages appear in a collapsible "Archived Packages" section at the bottom
- Admins can restore (unarchive) a package at any time
- Only site admins and org owners can permanently delete a package

**Permanent deletion** removes the package record entirely. Any keys that reference it will become orphaned and fail validation. Use with caution.

## Step-by-Step: Setting Up Licensing for a Joomla Extension

### 1. Enable Licensing

**Organization level** (recommended — applies to all repos):
1. Go to your org → **Settings** → **Licensing & Update Streams**
2. Check **Enable licensing for this organization**
3. Check **Require license key for all update feeds**
4. Click **Save**

**Repository level** (per-repo override):
1. Go to your repo → **Settings** → scroll to **Licensing & Updates**
2. Check **Enable licensing for this repository**
3. Select the **Update Feed Format** (Joomla, Dolibarr, or Both)
4. Check **Require license key for update feeds**
5. Click **Save Changes**

### 2. Configure Extension Metadata

In **Organization Settings → Licensing & Update Streams**, under **Extension Metadata**:
1. Set the **Platform** (Joomla, Dolibarr, WordPress, etc.)
2. Set the **Extension Name** (e.g., `pkg_mokowaas`) — this becomes the `<element>` in the XML feed
3. Set the **Display Name** (e.g., "Package - MokoWaaS") — shown in Joomla update manager
4. Set the **Extension Type** (component, module, plugin, package, template, library)
5. Set the **Target Version** regex (e.g., `(5|6)\..*` for Joomla 5 and 6)
6. Set the **PHP Minimum** if applicable (e.g., `8.1`)
7. Set **Maintainer** and **Maintainer URL**

### 3. Create a License Package

1. Navigate to the **Licenses** tab (repo or org level)
2. Click **New Package**
3. Fill in:
   - **Name**: e.g., "Pro Annual"
   - **Duration**: e.g., `365` days (0 = lifetime)
   - **Max Sites**: e.g., `3` (0 = unlimited)
   - **Channels**: check which update streams this package grants access to
4. Click **Create License Package**

### 4. Generate a License Key

1. On the Licenses page, find your package row
2. Click the **+** button to generate a key
3. The full key (e.g., `MOKO-A1B2-C3D4-E5F6-G7H8`) is shown in a success banner
4. Copy the key and send it to your customer

### 5. Set Up the Client (Joomla Example)

In the customer's Joomla admin:
1. Go to **System → Update Sites**
2. Add or edit the update site URL: `https://git.mokoconsulting.tech/{org}/{repo}/updates.xml`
3. If prompted for a **Download Key**, enter the license key
4. Joomla will automatically append `&dlid=MOKO-XXXX-...` to download requests

### 6. Monitor Usage

- The **Last Seen** column shows when each key last checked for updates
- Click the edit (pencil) button to view/modify key details
- Domain restrictions are auto-populated on first use (lock-on-first-use)

## Step-by-Step: Payment Integration (Stripe/PayPal Webhook)

### 1. Create a Webhook Endpoint

Configure your payment provider to POST to:
```
POST https://git.mokoconsulting.tech/api/v1/repos/{org}/{repo}/license-keys/purchase
```

With headers:
```
Authorization: token YOUR_API_TOKEN
Content-Type: application/json
```

### 2. Webhook Payload

```json
{
  "package_id": 1,
  "licensee_name": "John Doe",
  "licensee_email": "john@example.com",
  "domain": "example.com",
  "payment_ref": "stripe_pi_abc123"
}
```

The `payment_ref` provides **idempotency** — duplicate webhooks return the existing key instead of creating duplicates.

### 3. Response

On first creation:
```json
{
  "id": 42,
  "raw_key": "MOKO-A1B2-C3D4-E5F6-G7H8",
  "package_id": 1,
  "licensee_name": "John Doe",
  ...
}
```

Send the `raw_key` to the customer via email.

## Step-by-Step: Validating a Key (Client-Side)

Your extension can validate its own key against the server:

```
POST https://git.mokoconsulting.tech/api/v1/repos/{org}/{repo}/license-keys/validate
Content-Type: application/json

{"key": "MOKO-A1B2-C3D4-E5F6-G7H8", "domain": "example.com"}
```

Response:
```json
{
  "valid": true,
  "package_name": "Pro Annual",
  "channels": "[\"stable\",\"release-candidate\"]",
  "expires_at": "2027-06-01T00:00:00Z",
  "sites_used": 1,
  "max_sites": 3
}
```

This endpoint is **public** — no authentication required. Use it in your extension's license check flow.

## Supported Platforms

The update feed system currently supports:

| Platform | Feed URL | Format | Status |
|---|---|---|---|
| **Joomla** | `/{repo}/updates.xml` | XML with `<downloadkey>` | Production |
| **Dolibarr** | `/{repo}/updates/dolibarr.json` | JSON | Production |
| **WordPress** | `/{repo}/updates/wordpress.json` | PUC-compatible JSON | Production |
| **Composer** | `/{repo}/updates/packages.json` | packages.json | Production |
| **PrestaShop** | `/{repo}/updates/prestashop.xml` | Module update XML | Production |
| **Drupal** | `/{repo}/updates/drupal.xml` | Update status XML | Production |
| **WHMCS** | `/{repo}/updates/whmcs.json` | Module update JSON | Production |
| **Changelog** | `/{repo}/changelog.xml` | Joomla changelog XML | Production |

All platforms share the same licensing backend — the same keys, packages, and validation work across all feed formats.

---

*Repo: [MokoGitea](https://git.mokoconsulting.tech/MokoConsulting/MokoGitea)*

| Revision | Date | Author | Description |
|---|---|---|---|
| 1.0 | 2026-05-30 | Jonathan Miller (@jmiller) | Initial version |
| 1.1 | 2026-05-31 | Jonathan Miller (@jmiller) | Add feature toggle, unified update system, tag enforcement, heartbeat tracking |
| 1.2 | 2026-05-31 | Jonathan Miller (@jmiller) | Add permissions (TypeLicenses unit), renewal, auto-domain, custom keys, UI/UX cleanup |
| 1.3 | 2026-06-01 | Jonathan Miller (@jmiller) | Add package archiving, expanded delete permissions, migration v340, API renew, step-by-step guides |
| 1.4 | 2026-06-02 | Jonathan Miller (@jmiller) | WordPress feed, feed visibility modes, download gating, RepoScope enforcement, API package CRUD, settings API, combolist channel picker, double confirmation modals, extension metadata in repo settings, domain lock timer, Joomla-standard tags, SHA256 in XML, changelog XML, no-download release page mode |
| 1.5 | 2026-06-02 | Jonathan Miller (@jmiller) | All 7 platform feeds (Composer, PrestaShop, Drupal, WHMCS), enterprise sub-org hierarchy, three-level repo visibility (Public/Private/Hidden), styled 403 page with login form, separate licensing/advanced settings pages, icons on all navbars, manual stream mapping, configurable key prefix, feed always public, xorm column name fixes, security hardening |
