# Client Registry

`live-intelligence/clients.js` — schema v3.

## Storage

Default path `live-intelligence/var/clients.json`, overridable with
`LI_CLIENTS_PATH` (the legacy `LI_KLIENTET_PATH` is still read).

```json
{
  "schema_version": 3,
  "clients": [
    {
      "id": "a1b2c3d4e5f60718",
      "name": "Shoqëria XH",
      "status": "ACTIVE",
      "token_prefix": "LI-abcd••••••",
      "token_salt": "…32 hex…",
      "token_hash": "…64 hex…",
      "allowed_origins": ["https://bast.example.com"],
      "subscriptions": [
        {
          "id": "9f8e7d6c5b4a3928",
          "product": "live_intelligence",
          "starts_at": "2026-10-08T00:00:00.000Z",
          "expires_at": "2026-11-08T00:00:00.000Z",
          "created_at": "2026-10-08T07:40:11.004Z"
        }
      ],
      "additional_features": { "advanced_stats": true },
      "created_at": "2026-10-08T07:40:02.511Z",
      "rotated_at": null,
      "last_used_at": "2026-10-08T08:02:44.900Z"
    }
  ],
  "operations": {}
}
```

`schema_version` is **declared, never inferred**. A reader that guesses the
version from field shapes will one day misread a v5 file as a v4.

Writes are atomic: temp file, `fsync`, `rename`. A crash mid-write leaves the
previous registry intact.

## Tokens

A token is `LI-` plus 24 random bytes, base64url. It is stored only as
`scrypt(token, token_salt)` and **returned exactly once** — at creation, and
again only on rotation. It cannot be recovered, by design.

`token_prefix` holds the first seven characters plus bullets. It exists so an
operator can tell two clients apart in a list; it is not a credential.

Lookup walks every client and compares with `timingSafeEqual`. There is no index
on the token, because an index would require storing it in a readable form.

### What a rotation does and does not do

```
rotateToken(id)   new salt, new hash, new prefix, rotated_at stamped
                  subscriptions · features · origins · status  UNTOUCHED
```

The old token stops working the moment the write lands. A retry carrying the
same `operation_id` does not rotate again and returns `token: null` with a note
— the token was never stored, so it cannot be handed out twice.

## Allowed origins

`allowed_origins` is an allowlist, not a record of origins seen. Entries are
normalised: lowercased, trailing slashes stripped. `*` is refused on input.

An origin is a **deployment boundary, not authentication**. The `Origin` and
`Referer` headers come from a browser and a non-browser client forges them. This
list stops the panel being embedded on pages you did not approve; it does not
stop an attacker with `curl`. That is why the server binds to loopback.

`setAllowedOrigins` replaces the set and writes one `ORIGIN_ADDED` or
`ORIGIN_REMOVED` audit entry per actual change — a single "updated" event would
hide which origin gained access.

## Status

| status | meaning |
|---|---|
| `ACTIVE` | normal |
| `SUSPENDED` | reversible; subscriptions are preserved and resume on reactivation |
| `REVOKED` | **final**; no reactivation, no hard delete |

`REVOKED` is terminal because the alternative — deleting the client — would
delete the only record that it ever existed. See the note on `CLIENT_DELETED` in
[`commercial-platform-model.md`](commercial-platform-model.md).

## API

### Read

```js
listClients(now)                     // admin list; no salt, no hash
findByToken(token, now)              // resolved state for a token
findByOrigin(origin, product, now)   // the client that owns an origin AND holds the product
resolveEntitlements(client, now)     // the single authority
auditLog(limit)                      // newest first
```

`findByToken` requires a numeric `now`. A non-numeric one returns `null` rather
than silently falling back to the clock — a calling mistake must not become
permission.

### Write

```js
createClient({ name, origins })                      -> { ok, id, token }
addSubscription(id, { product, starts_at, expires_at, parallel, operation_id })
extendSubscription(id, subscription_id, expires_at, actor, { operation_id })
setStatus(id, 'ACTIVE' | 'SUSPENDED' | 'REVOKED')
setFeature(id, feature, enabled)
setAllowedOrigins(id, origins)
rotateToken(id, actor, { operation_id })
recordUsage(id, now)                                  // throttled to once a minute
```

Every write records an audit entry before returning success.

## Reading legacy registries

The reader accepts **v1, v2 and v3** and normalises all of them to the English
v3 model in memory. `normalizeClient()` is the only function where Albanian
field names appear, and only because a historical file has to be read.

Nothing is rewritten on read. An unmigrated registry keeps working and no
irreversible conversion happens behind the operator's back. Only
`migrate-registry-v3.js` writes v3 over legacy data, and only after proving
semantic identity — see [`migration-v2-v3.md`](migration-v2-v3.md).

```
READ      v1 · v2 · v3
INTERNAL  v3 only
WRITE     v3 only
```

## Idempotency

`operations` maps an `operation_id` to the result it produced. It lives in the
registry file so the record and the change share one atomic rename; stored
separately, an operation could take effect while its record was lost, and the
retry would apply it twice.

Capped at 500 entries. No token is ever written into it.
