> ## Documentation Index
> Fetch the complete documentation index at: https://docs.oauth.fyi/llms.txt
> Use this file to discover all available pages before exploring further.

# License Operations

> Architecture, permission model, and limitations of the staff support control plane.

License Operations is a Discord-native control plane for KeyAuth customer support: look up a
license, reset a hardware ID, adjust a subscription, disable an account, and keep Discord roles
in step — without opening the KeyAuth dashboard.

<Warning>
  Before relying on any of this operationally, read [What KeyAuth cannot
  do](#what-keyauth-cannot-do) below. Four features ship deliberately reduced because KeyAuth's
  API does not support the full version, and each is labelled in the bot's UI at the point of
  use, not only here.
</Warning>

**KeyAuth remains authoritative.** This layer never becomes the source of truth for license
state. Everything it stores is either a clearly-marked cache or data KeyAuth has no concept of:
local reset policy, staff notes, Discord associations, and a record of what the bot itself did.

## Architecture

```
Discord
   ↓  cogs/licops.py · cogs/licadmin.py      commands, dispatch
   ↓  ui/licops_views.py                     Components V2 rendering
   ↓  licops/service.py                      all mutations; audits; verifies
   ↓  licops/{policy,permissions,resolver}   local rules
   ↓  core/{seller_client,app_client}        the validated KeyAuth clients
KeyAuth
```

No cog performs HTTP directly. Every read and write goes through the license service, which:

<Steps>
  <Step title="Check permission">
    Server-side, from the resolved actor's capabilities — never trusted from the client.
  </Step>

  <Step title="Capture state before">
    Read the account's current state before making any change.
  </Step>

  <Step title="Perform the KeyAuth call">
    Execute the mutation against the seller or application API.
  </Step>

  <Step title="Re-read and compare">
    Read the account's state again afterward and compare it to what was expected.
  </Step>

  <Step title="Record the outcome">
    Write the result to the immutable audit timeline — success or failure.
  </Step>
</Steps>

The re-read step is why results distinguish *applied* from *verified*. A KeyAuth call returning
`success: true` is not proof the change actually took effect, and the UI never presents it as
though it were.

Both existing KeyAuth clients are reused unchanged underneath this layer. The application client
(API 1.3) verifies an Ed25519 signature over the response on every call. The seller client
handles all administrative operations and redacts the seller key from every error path. Both
share the same sliding-window rate limiter, so a reconciliation sweep can never starve
interactive commands.

## Permissions

Seventeen capabilities rather than one admin bit, because the person who should be able to check
an expiry is rarely the person who should be able to delete an account.

| Capability               | Grants                                    |
| ------------------------ | ----------------------------------------- |
| `license.view`           | Look up licenses and customers            |
| `license.view_sensitive` | Reveal full license keys and hardware IDs |
| `license.link`           | Link a Discord user to a KeyAuth account  |
| `license.unlink`         | Remove an account link                    |
| `license.hwid_reset`     | Reset a customer's hardware ID            |
| `license.hwid_override`  | Bypass reset cooldowns and quotas         |
| `license.extend`         | Extend or shorten a subscription          |
| `license.set_expiration` | Set an absolute expiry date               |
| `license.enable`         | Re-enable a disabled account              |
| `license.disable`        | Disable an account (reversible)           |
| `license.revoke`         | Ban an account and its key                |
| `license.delete`         | Permanently delete (irreversible)         |
| `license.transfer`       | Transfer a license between Discord users  |
| `license.notes`          | Read and write internal staff notes       |
| `license.view_history`   | View the action timeline                  |
| `license.role_sync`      | Manage role mappings and trigger syncs    |
| `license.bulk`           | Run bulk operations                       |
| `license.admin`          | Configure policies and permissions        |

`license.view_sensitive`, `license.hwid_override`, `license.revoke`, `license.delete`,
`license.transfer`, `license.bulk`, and `license.admin` are treated as **sensitive** — they
expose secrets or destroy data, and are never granted implicitly by a preset.

### Resolution order

1. Configured admins and the guild owner hold **every** capability. The existing permission
   model is preserved rather than replaced, so nobody gets locked out of a bot they already
   administer.
2. Otherwise, the union of capabilities granted to the member's Discord roles.
3. Otherwise, **nothing**. Default deny.

### Presets

Two presets exist for convenience, applied with `/licops permissions preset`:

| Preset    | Grants                                                                                                   |
| --------- | -------------------------------------------------------------------------------------------------------- |
| `Support` | View, history, notes, HWID reset, link                                                                   |
| `Manager` | Everything in Support, plus unlink, HWID override, extend, set-expiration, enable/disable, and role sync |

Neither preset includes anything destructive — `revoke`, `delete`, `transfer`,
`view_sensitive`, `bulk`, and `admin` are always granted explicitly, one at a time.

## Customer linking

Ownership is proven by license key, never by password. `/license link` resolves the key through
the seller API, confirms it belongs to a real account, and records
`discord_id → keyauth_username`.

A unique index on the username means one KeyAuth account can be claimed by exactly one Discord
user — without it, anyone who learned a key could bind themselves to an account somebody else
controls and then reset its hardware ID. A Discord user holds at most one link; transferring
onto an already-linked user is refused rather than silently orphaning the account they had.

## Hardware ID management

**Nobody ever sees or types a hardware ID.** The bot clears the binding server-side; KeyAuth
stores `hwid = NULL`, and the next login from any machine binds that machine automatically — the
customer just opens their loader and signs in.

### Reset policy

Configured with `/licops policy set`:

| Setting            | Meaning                                                           |
| ------------------ | ----------------------------------------------------------------- |
| `self_service`     | Customers may reset their own hardware ID                         |
| `max_resets`       | Resets per rolling window. `0` = unlimited                        |
| `window_days`      | Length of that window                                             |
| `cooldown_hours`   | Minimum gap between customer resets                               |
| `require_reason`   | Reason mandatory for every reset                                  |
| `require_approval` | Route customer requests to staff instead of resetting immediately |
| `staff_override`   | `license.hwid_override` lets staff bypass the limits above        |

<Warning>
  All of this is enforced by the **bot**, not KeyAuth. KeyAuth stores a single cooldown
  timestamp, keeps no counter, and its `resetuser` call succeeds regardless of whether that
  cooldown has elapsed. The reset ledger this bot maintains is the real enforcement — the UI says
  "policy" wherever it shows an allowance, precisely so it isn't read as a KeyAuth guarantee.
</Warning>

Staff resets do not consume a customer's allowance — a support agent helping someone should not
burn the quota that person needs.

## Subscription operations

| Operation  | KeyAuth call                                                           |
| ---------- | ---------------------------------------------------------------------- |
| Extend     | `extend` (adds days)                                                   |
| Shorten    | `subtract` (removes seconds)                                           |
| Set expiry | Computed delta — see [What KeyAuth cannot do](#what-keyauth-cannot-do) |

Quick buttons on the panel offer 1 / 7 / 30 / 90 days; a modal takes a custom value, where a
negative number shortens the subscription. Every change records the before state, the requested
change, the after state, the acting staff member, a timestamp, and a reason.

## Disable, revoke, delete

Three different KeyAuth operations with different reversibility, and the layer never conflates
them:

| Action      | KeyAuth call | Reversible          |
| ----------- | ------------ | ------------------- |
| **Disable** | `pauseuser`  | Yes — `unpauseuser` |
| **Revoke**  | `banuser`    | Yes, by an admin    |
| **Delete**  | `deluser`    | **No**              |

All three require confirmation and a reason captured in a modal. Delete is verified by absence:
the account is re-checked afterward, and if it still exists the operation is reported as
**failed**, not succeeded.

Disable is the one case that can't be verified at all — `userdata` exposes no paused flag — and
the result says so rather than implying a check that didn't happen.

## Role synchronisation and reconciliation

Roles sync when a license is linked, enabled, disabled, revoked, or expires; when a customer
rejoins the server; on a manual `/licops roles sync`; and during reconciliation.

**Only mapped roles are ever touched.** A sync that computed "roles this member should have" and
removed everything else would strip moderator and booster roles on first run. Mapping-time
validation also rejects a role positioned at or above the bot's own, which is the most common
cause of a silent sync failure.

### Reconciliation

The KeyAuth dashboard is a fully functional second writer — someone can always change a license
there directly. Reconciliation re-reads accounts from KeyAuth, corrects the local mirror, applies
any resulting role changes, and alerts on drift.

Triggered with `/licops reconcile`, or on a configurable recurring schedule. It is
batched, ordered by staleness (so an interrupted run resumes naturally), idempotent, serialised
by a lock, and **stops immediately on a rate limit** rather than spending the remaining budget —
breaching KeyAuth's 30 requests / 30 seconds costs 30–60 minutes of downtime. Drift is always
resolved in KeyAuth's favor; the mirror is a cache, never the source of truth.

## What KeyAuth cannot do

Four features ship deliberately reduced, because KeyAuth's seller API does not offer the full
version of what you'd expect. Each is labelled in the bot's UI at the point where it's used, not
only here.

### No authentication history

The seller API exposes no per-user authentication log. `userdata` returns `lastlogin`, `ip`, and
`hwid` — one data point, not a history.

**Consequence:** the panel shows *last known activity* only. There is no session-history view,
because there is no data to build one from. Use the KeyAuth dashboard for anything more.

### No absolute expiry setter

KeyAuth offers only `extend` (add) and `subtract` (remove). There is no "set expiry to date X."

**Consequence:** "Set Expiration" computes a delta from the expiry read moments earlier. This is
a read-modify-write and is **not atomic** — a dashboard edit landing in between would make the
result wrong. The result reports the expiry KeyAuth actually ended up with, and warns when it
differs from what was asked for by more than a day.

### No license transfer

`assignkey` binds a key to a user, but nothing in KeyAuth's API moves an existing account or
subscription between users.

**Consequence:** `/license transfer` moves the **Discord association only**. KeyAuth-side
ownership is unchanged, and the result says so explicitly. Doing more would mean deleting and
recreating the account, losing its history in the process.

### No reset allowance

Covered above under [Hardware ID management](#hardware-id-management) — KeyAuth has no counter
and enforces no quota of its own.

### Other limits

* **Rate limit:** 30 requests / 30 seconds; a breach costs 30–60 minutes. `/license search` runs
  against the local mirror for this reason, not a live call.
* **No server-side search:** `fetchallkeys` returns everything at once, with no filter or
  cursor. Mirror refresh is explicit and staff-triggered.
* **Email:** not reliably exposed and not searchable. Email lookup is refused with an
  explanation rather than silently returning nothing.
* **HWID search:** covers mirrored licenses only — KeyAuth itself cannot search by hardware ID.
* **Paused state:** invisible in `userdata`, so an active-looking account may actually be
  disabled until reconciliation runs. The panel warns about this.
* **Seller key:** GET-only, sent in the query string, account-wide, and unscopeable. IP-whitelist
  it — see [Configuration](/keyauth/configuration).

## Data model

Nine additive tables. No existing table was changed to add this layer.

| Table                        | Kind     | Contents                                     |
| ---------------------------- | -------- | -------------------------------------------- |
| `license_mirror`             | Cache    | KeyAuth state, for search and reconciliation |
| `license_action`             | Authored | Immutable action timeline                    |
| `license_note`               | Authored | Staff notes, staff-visible or admin-only     |
| `license_hwid_reset`         | Authored | Reset ledger backing the quota engine        |
| `license_role_mapping`       | Config   | Subscription → role                          |
| `license_policy`             | Config   | Per-application reset policy                 |
| `license_reconciliation_run` | Log      | Sweep results                                |
| `capability_grant`           | Config   | Role → capability                            |
| `alert_state`                | Log      | Alert deduplication                          |

**Never stored:** plaintext license keys (a SHA-256 fingerprint plus a display mask instead),
passwords, full hardware IDs (prefix and hash only), or IP addresses.

## Audit

Every mutation writes to the action timeline with the action, actor, actor kind (staff /
customer / system), before state, after state, reason, success, and timestamp. Failures are
recorded too — a run of unsuccessful link attempts is the signature of someone guessing at
license keys.

The table is append-only; there is no update or delete path, because an audit trail staff can
edit is not an audit trail. View it with the **History** button on a license panel, or
`/license customer` → History for a rollup.

## Alerts

Enabled by configuring an alert channel. Every alert passes a deduplication gate first — without
one, a sweep over a few hundred licenses would post a few hundred messages and the channel would
be muted within a day.

| Alert                    | Cooldown        |
| ------------------------ | --------------- |
| Expiring soon            | 24h per license |
| Disabled outside the bot | 1h              |
| Linked license vanished  | 24h             |
| Repeated resets          | 6h              |
| API unavailable          | 30m             |
| Reconciliation failed    | 1h              |

## Rollout checklist

Before enabling License Operations in production:

<Steps>
  <Step title="IP-whitelist the seller key">
    In Seller Settings. This layer makes many more seller calls than the base bot, which raises
    the value of the whitelist.
  </Step>

  <Step title="Confirm HWID checking is enabled">
    Check with `/licops status`.
  </Step>

  <Step title="Grant capabilities deliberately">
    Start with the `Support` preset rather than hand-picking capabilities for a first rollout.
  </Step>

  <Step title="Set a reset policy before enabling self-service">
    Otherwise customers reset with no limit at all.
  </Step>

  <Step title="Map roles">
    Check the bot's role sits above every role you map.
  </Step>

  <Step title="Run /licops reconcile once">
    Populate the mirror before relying on `/license search`.
  </Step>

  <Step title="Only then enable the recurring reconcile schedule">
    Initial mirror population already spends real API budget — do that in batches of roughly 50
    rather than one large sweep, then schedule ongoing sweeps once the mirror is warm.
  </Step>
</Steps>
