# Plexxer API reference

Machine-readable public reference for the Plexxer REST API. Pair with
`/d/{appKey}/_meta/*` (token-scoped introspection) for a complete picture:
this document is the **timeless catalogue** (grammar, endpoint shapes,
error envelopes, capabilities); `/_meta` is the **token's current view**
(the exact entities it can touch, field types, filter ops per field,
sample documents).

Base URL for all examples: `https://api.plexxer.com` (or your self-hosted
host — replace accordingly). Content type: `application/json`.

---

## Table of contents

- [Auth](#auth)
- [Data plane](#data-plane)
  - [Create](#create)
  - [Nested writes](#nested-writes)
  - [Read](#read)
  - [Deep eager-load](#deep-eager-load)
  - [Update](#update)
  - [Delete](#delete)
  - [Aggregate & count](#aggregate--count)
- [Filter grammar](#filter-grammar)
- [Update operators](#update-operators)
- [Query envelope](#query-envelope)
- [Error envelopes](#error-envelopes)
- [Reserved field names](#reserved-field-names)
- [Introspection (`/_meta`)](#introspection-_meta)
- [Control plane](#control-plane)
  - [Apps (`/apps`)](#apps)
  - [Account tokens (`/account/tokens`)](#account-tokens)
  - [Access tokens (per-app)](#access-tokens)
  - [Schemas](#schemas)
  - [Backups](#backups)
  - [Generated C# client](#generated-c-client)

---

## Auth

Two token types, one header shape.

```
Authorization: Bearer <token>
```

- **`plx_...` access token** — long-lived, per-app, revocable. Used by
  API consumers, agents, CI. Carries per-entity grants (`"Customer":
  "rw"`), optional `app:*` grants (samples, control plane), and an
  optional CIDR allowlist.
- **Session JWT** — short-lived (15min), tied to a browser session.
  The dashboard uses this; agents should not.

Mint an access token:

```
POST /apps/{appKey}/tokens
Authorization: Bearer <session JWT or plx_ token with app:tokens:w>

{
  "label":       "ci-deploy-bot",
  "permissions": {"Users": "r", "Orders": "rw"},
  "ipAllowlist": ["203.0.113.0/24"]
}
→ {
  "token": { "id": "...", "label": "...", "createdAt": "...", ... },
  "plaintextToken": "plx_abc..."    // shown exactly once
}
```

**The plaintext is only returned on mint.** The server stores SHA-256
of the plaintext; losing the plaintext means re-minting. Treat the
mint response like a KMS-issued secret.

Permission values:

| Value | Grants (per-entity) |
|---|---|
| `"r"` | `read` + `aggregate` |
| `"w"` | `create`, `update`, `delete` |
| `"rw"` | All five verbs |

### App-level grants (`app:*`)

Unlock app-scoped capabilities and the control plane. Live in the same
`permissions` dict at mint time.

| Key | Values | Unlocks |
|---|---|---|
| `app:meta-samples` | `"y"` | Up to 3 sample docs per entity in `/_meta/entities/{e}`. |
| `app:schemas` | `"r"` · `"w"` · `"rw"` | **r**: list / get / diff / history. **w**: create, patch draft, publish, rollback, delete schemas. |
| `app:tokens` | `"r"` · `"w"` · `"rw"` | **r**: list tokens. **w**: mint, update, revoke access tokens. |
| `app:backups` | `"r"` · `"w"` · `"rw"` | **r**: list / get. **w**: create, delete, restore backups. |
| `app:client` | `"y"` | Download the generated C# client zip at `GET /apps/{appKey}/client/csharp`. |

No `app:admin` shorthand — grants are always enumerated per-key.

### Account-scoped tokens (`scope: "account"`)

Account-scoped tokens authorise an **agent that doesn't yet have an
app**. Where a per-app `plx_` token can do everything *inside* one
existing app (schemas, tokens, backups, data CRUD), an account-scoped
token can *create* apps in the first place — and mint per-app tokens
for them once they exist. Wire shape is identical (`Authorization:
Bearer plx_…`); list / detail responses surface a `scope`
discriminator (`"account"` vs. `"app"`) plus a `null` `appKey` for
account-scoped tokens.

**When to use which:**

| Use-case | Token to mint |
|---|---|
| Service / agent operating inside an existing app | App-scoped (`POST /apps/{appKey}/tokens`) |
| Agent that needs to spin up a brand-new app | **Account-scoped** (`POST /account/tokens`) |
| Long-lived "owner" credential covering one account end-to-end | **Account-scoped** with `account:apps:rw` + `account:tokens:rw` |
| CI pipeline that re-publishes schemas in an existing app | App-scoped with `app:schemas:rw` |
| Data-plane consumer (read/write rows under `/d/{appKey}/...`) | App-scoped with per-entity grants — account tokens **cannot** reach the data plane |

**Account grants:**

| Key | Values | Unlocks |
|---|---|---|
| `account:apps` | `"r"` · `"w"` · `"rw"` | **r**: list / get apps. **w**: create / rename / suspend / resume / delete. |
| `account:tokens` | `"r"` · `"w"` · `"rw"` | **r**: list account tokens. **w**: mint / update / revoke account-scoped tokens. **w** also authorises minting per-app tokens for any owned app via `POST /account/tokens` with `appKey` set. |

**Mutual exclusion of grant families.** A single token carries either
`account:*` grants *or* `app:*` + per-entity grants — never both. The
mint validator rejects mixing scopes; see error codes below
(`permissions-account-token-no-app-grants`,
`permissions-account-token-no-entity-grants`,
`permissions-app-token-no-account-grants`). Why: the *scope* is a
property of the token's target, not of individual grants. Keeping them
apart means an account token cannot reach the data plane even by
accident, and an app token cannot create apps. The bootstrap pattern
is one credential via the polymorphic `POST /account/tokens` (see
below).

**Mint:**

```
POST /account/tokens
Authorization: Bearer <session JWT or plx_ account token with account:tokens:w>

{
  "label":       "agent",
  "permissions": {"account:apps": "rw", "account:tokens": "rw"},
  "ipAllowlist": null,
  "expiresAt":   "2026-05-26T00:00:00Z",
  "appKey":      null
}

→ 201 {
  "token": {
    "id":           "...",
    "label":        "agent",
    "scope":        "account",
    "appKey":       null,
    "permissions":  {"account:apps": "rw", "account:tokens": "rw"},
    "ipAllowlist":  null,
    "createdAt":    "...",
    "lastUsedAt":   null,
    "revokedAt":    null,
    "expiresAt":    "2026-05-26T00:00:00Z"
  },
  "plaintextToken": "plx_abc..."
}
```

`expiresAt` is **optional** — `null` means no expiry. When set, must
be in the future and at most one year out (server enforces both bounds
with a 5-second skew tolerance). The dashboard mint dialog defaults to
30 days because account tokens are powerful (they can delete apps);
you can extend or remove the default at mint time.

**Polymorphic minting via `appKey`.** The same endpoint mints
*app-scoped* tokens when called with `appKey` set to a caller-owned
app:

```
POST /account/tokens
Authorization: Bearer plx_AGENT_account_token

{
  "label":       "data-bot",
  "permissions": {"Customer": "rw", "app:schemas": "rw"},
  "appKey":      "abc123..."
}
→ 201 with scope:"app", appKey:"abc123..."
```

Validator runs the per-app shape rules in this branch — `account:*`
keys here are rejected with `permissions-app-token-no-account-grants`.
If the caller doesn't own that app, the response is 404 (no
existence-leak).

**Bootstrap recipe (the agent payoff).** From zero to a populated app
with one credential, no session JWT after step 1:

```
1. Mint account token (UI: avatar menu → Account tokens → Mint)
   POST /account/tokens
   { "permissions": { "account:apps": "rw", "account:tokens": "rw" } }
   → plx_AGENT…

2. Create an app (the new bit unlocked by Phase 10.6)
   POST /apps      Authorization: Bearer plx_AGENT
   { "name": "MyApp" }
   → { "appKey": "abc..." }

3. Mint a per-app token for that app via the same account credential
   POST /account/tokens   Authorization: Bearer plx_AGENT
   { "label": "app-tok",
     "permissions": { "Customer": "rw", "app:schemas": "rw" },
     "appKey": "abc..." }
   → plx_APP…

4. From here on use plx_APP for schemas + data
   POST /apps/abc.../schemas              Authorization: Bearer plx_APP
   POST /d/abc.../Customer/create         Authorization: Bearer plx_APP
```

**Wrong-scope behaviour (we deliberately don't leak endpoint
existence):**

| Caller | Endpoint | Response |
|---|---|---|
| Account-scoped `plx_` token | `/apps/{appKey}/...` (per-app surface) | `404 not-found` (matches the wrong-app path) |
| App-scoped `plx_` token | `/apps` or `/account/tokens` (account surface) | `401 unauthorized` (we never confirm or deny) |
| Expired / revoked token | Anything | `401 unauthorized` (the cache compiler treats expired plans as cache miss) |
| Cross-user app via `account:apps` / `appKey` mint | `/apps/{appKey}` / `POST /account/tokens` | `404 not-found` (cross-user existence is never confirmed) |

### IP allowlist

CIDR strings in the token's `ipAllowlist`. Empty / absent = no check.
Mismatched remote IP → `403 ip-not-allowed`. The server honours
`X-Forwarded-For` when the immediate hop is a trusted proxy.

---

## Data plane

All data-plane routes live under `/d/{appKey}/{entity}/{verb}`. Verb
is one of `create`, `read`, `update`, `delete`, `aggregate`. Every
request is a POST; GET is reserved for `/_meta`.

### Create

```
POST /d/{appKey}/{entity}/create
{
  "name":  "Acme Inc",
  "email": "billing@acme.com"
}
→ { "success": true, "document": { "_id": "6a3f...", "name": "...", ... } }
```

- `_id` is server-assigned (24-char hex ObjectId); never supply it yourself.
- Auto-timestamp fields (`createdAt`, `updatedAt` etc.) are filled server-side.
- Missing required fields → `400 validation-failed` with `errors[]`.
- Type coercion: dates accept ISO 8601 strings; numbers accept JSON numbers.
- Append `?return=graph` for nested creates (see below) to get the full
  hydrated tree back with every `_id` filled.

### Nested writes

Atomic multi-entity create. The server figures out dependency order
and writes leaves-first inside a transaction. Connect existing docs
by `_id`, or inline new ones.

```
POST /d/{appKey}/Customer/create?return=graph
{
  "name":  "Acme Inc",
  "address": {                               // implicit nested create
    "street": "Main 1", "city": "Amsterdam"
  },
  "orders": [                                // mixed many-relation
    "6a3f...",                               // connect existing
    { "sku": "W-1", "total": 29.95 }         // create new
  ]
}
```

Explicit forms work too:

```
"address": {"_create": {"street": "Main 1"}}   // same as bare object
"customer": {"_connect": "6a3f..."}            // same as bare string on one-cardinality
```

Supplying both `_create` and `_connect` → `400 nested-write-ambiguous`.
Depth cap: 5 levels. Any validation failure deep in the tree rolls
back the entire transaction — no partial writes.

When `?return=graph` is present the response mirrors the request shape
with every server-assigned field populated.

### Read

```
POST /d/{appKey}/{entity}/read
{
  "status:eq":    "active",
  "createdAt:gte": "2026-01-01T00:00:00Z",
  "query": {
    "sort":     {"createdAt": -1},
    "limit":    50,
    "offset":   0,
    "fields":   ["_id", "name", "email"],
    "related":  ["address"],
    "count":    true
  }
}
→ {
  "success":   true,
  "documents": [...],
  "total":     243         // only when query.count is true
}
```

Filter keys at the root (see [Filter grammar](#filter-grammar)) compose
with AND. The `query` envelope carries pagination, projection, eager
load, and count. Omit the body entirely (or send `{}`) to get the
default listing (all docs, default sort, server-side limit).

### Deep eager-load

Fetches related entities in a single request — no N+1. Accepts a
mixed array of dot-path strings and tree objects.

Flat:
```
"related": ["address", "orders"]
```

Nested (three levels deep):
```
"related": ["address.country"]
```

Per-relation filter / sort / limit / projection:
```
"related": [
  "address.country",
  {
    "field":   "orders",
    "filter":  {"status:eq": "paid"},
    "sort":    {"orderNumber": -1},
    "limit":   3,                     // per-parent top-N (many+inversedBy)
    "includeFields": ["orderNumber", "total"]
  }
]
```

Rules the parser enforces at parse time:

| Code | When |
|---|---|
| `related-unknown-field` | Field isn't declared on the schema. |
| `related-too-deep` | Tree exceeds depth cap (5). |
| `related-invalid-shape` | Entry isn't a string or a well-formed object. |
| `limit-not-applicable-on-one` | `limit` on a one-cardinality relation. |
| `limit-requires-inverse-on-many` | Many-cardinality `limit` needs `inversedBy`. |
| `limit-requires-sort` | Per-parent top-N needs an explicit `sort`. |
| `limit-no-offset-on-many` | Offset isn't supported on per-parent top-N. |

Deep reads require the `r` grant on **every** entity touched; missing
grant → `403 permission-denied { entity }`.

### Update

```
POST /d/{appKey}/{entity}/update
{
  "status:eq": "pending",
  ":set":      { "status": "active", "activatedAt": "2026-04-21T10:00:00Z" }
}
→ { "success": true, "matched": 12, "modified": 12 }
```

- Filter part (every key except those starting with `:`) is required.
  **Omit it → nothing matches → nothing updates.** No accidental
  "update everything" footgun.
- See [Update operators](#update-operators) for the `:op` vocabulary.
- Nested writes on `:set` with an inline object create the child + wire
  the relation in the same transaction.

### Delete

```
POST /d/{appKey}/{entity}/delete
{ "status:eq": "cancelled" }
→ { "success": true, "deleted": 7 }
```

Same filter-part rule as Update — empty filter matches nothing.
Bidirectional relations are unlinked automatically on delete.

### Aggregate & count

One endpoint, four response shapes driven by the body.

**B.1 — Count only** (`{count: true}` or bare `{}`):
```
POST /d/{appKey}/{entity}/aggregate
{"count": true}
→ { "success": true, "total": 4210 }
```

**B.2 — Single-row metrics over the whole match**:
```
{
  "filter":  {"status:eq": "paid"},
  "metrics": { "total":   {"sum": "amount"},
               "avg":     {"avg": "amount"},
               "orders":  "count" }
}
→ { "success": true, "result": {"total": 192830.5, "avg": 264.1, "orders": 730} }
```

**B.3 — Group-by rows**:
```
{
  "filter":  {"placedAt:gte": "2026-01-01T00:00:00Z"},
  "groupBy": ["country"],
  "metrics": {"revenue": {"sum": "total"}, "orders": "count"},
  "sort":    {"revenue": -1},
  "limit":   5
}
→ { "success": true, "results": [
    {"country": "NL", "revenue": 48210, "orders": 182},
    ...
] }
```

**B.4 — Distinct values**:
```
{"distinct": "country", "filter": {"status:eq": "paid"}}
→ { "success": true, "results": ["BE", "DE", "FR", "NL", "US"] }
```

Metric ops:
- `count` (whole rows, or `{count: "fieldName"}` to skip null/missing rows)
- `sum` / `avg` — number fields only
- `min` / `max` — number, date, or string fields

Limits: max 32 metrics, max 4 group keys, max 10000 rows (`limit` param).

Requires `r` (or `rw`) grant on the entity. No cross-entity aggregation
— issue one request per entity.

---

## Filter grammar

Filters live at the root of read / update / delete / aggregate bodies.
Shape: `{"fieldName:op": value, ...}` — keys compose with AND.

| Op | Semantics | Applies to |
|---|---|---|
| `eq` | Equal | any |
| `ne` | Not equal | any |
| `in` | Value in array | any scalar |
| `nin` | Value not in array | any scalar |
| `gt` · `gte` · `lt` · `lte` | Range | number, date, string |
| `like` | Case-insensitive substring | string |
| `startsWith` · `endsWith` | Anchored substring | string |
| `exists` | Field is present (truthy body) | any |

Relation fields accept an `_id` or the 24-char hex string; the server
coerces the hex to ObjectId automatically.

Logical composition via top-level arrays:

```
{
  "$and": [
    {"status:eq": "active"},
    {"$or": [
      {"country:eq": "NL"},
      {"country:eq": "BE"}
    ]}
  ]
}
```

Per-field `filterOps` lists are in `GET /d/{appKey}/_meta/entities/{entity}`
— that's the authoritative "what op can I use on what field" source.

---

## Update operators

All prefixed with `:` to disambiguate from filter fields.

| Op | Semantics |
|---|---|
| `:set` | Overwrite fields. `{":set": {"status": "active"}}` |
| `:unset` | Remove fields. Takes an **array** of field names — not an object: `{":unset": ["nickname"]}` |
| `:inc` | Increment numbers. `{":inc": {"views": 1}}` |
| `:push` | Append to array. `{":push": {"tags": "new"}}` |
| `:pull` | Remove matching from array. `{":pull": {"tags": "old"}}` |
| `:addtoset` | Append to array if not present. `{":addtoset": {"tags": "verified"}}` |

`:unset` is the only update operator whose value is an array; every other
operator takes a `{field: value}` object. Operator keys are case-sensitive.
To clear a date field, use `:unset` — `:set` with `""` fails coercion (an
empty string is not a valid ISO-8601 date); `:set` with `null` stores a null
rather than removing the field.

Nested writes on `:set` with inline objects: creates the child and
wires the relation in one transaction. See
[Nested writes](#nested-writes).

---

## Query envelope

Keys inside the top-level `"query"` object on Read bodies.

| Key | Type | Meaning |
|---|---|---|
| `sort` | `{field: 1 or -1}` | Sort direction per field; `_id` implicit tie-break. |
| `limit` | number | Max documents returned (server-capped). |
| `offset` | number | Pagination offset. |
| `fields` | `string[]` | Projection — include-list. |
| `excludeFields` | `string[]` | Projection — exclude-list. Cannot mix with `fields`. |
| `related` | mixed array | Eager-load. See [Deep eager-load](#deep-eager-load). |
| `count` | boolean | Adds `total` to the response. |

---

## Error envelopes

All errors carry `{"error": "<code>", ...}` at the top level.

### Common codes

| Code | Status | Meaning |
|---|---|---|
| `unauthorized` | 401 | Missing / invalid / revoked / expired bearer token; also wrong-scope `plx_` token on the account surface (we don't leak endpoint existence). |
| `forbidden` | 403 | Token lacks the required per-entity grant. |
| `permission-denied` | 403 | Deep eager-load needs read grant on a related entity. |
| `control-plane-forbidden` | 403 | Token lacks the `app:<resource>` or `account:<resource>` grant. Body includes `required`, e.g. `"app:schemas:w"` or `"account:apps:w"`. |
| `ip-not-allowed` | 403 | Caller IP outside the token's `ipAllowlist`. |
| `not-found` | 404 | URL appKey doesn't match the token's app, account-scoped token on per-app surface, or cross-user app reference. |
| `app-suspended` | 403 | App status is `Suspended` (data-plane only). |
| `app-no-access` | 403 | Token has zero grants on the app (meta routes). |

### Data-plane codes

| Code | Status | Meaning |
|---|---|---|
| `validation-failed` | 400 | One or more fields invalid. Body carries `errors[]` with `{path, code, message}`. |
| `entity-not-found` | 404 | Entity name isn't published on this app. |
| `relation-target-missing` | 400 | Nested write connects to an `_id` that doesn't exist. |
| `nested-write-ambiguous` | 400 | `_create` + `_connect` on the same relation slot. |

### DSL codes (query / update / aggregate)

`{"error": "invalid-query"|"invalid-update"|"invalid-aggregate", "code": "<sub-code>", "detail": "..."}`. Sub-codes:

`related-unknown-field` · `related-too-deep` · `related-invalid-shape`
· `limit-not-applicable-on-one` · `limit-requires-inverse-on-many`
· `limit-requires-sort` · `limit-no-offset-on-many`
· `aggregate-too-many-metrics` · `aggregate-too-many-group-keys`
· `aggregate-limit-out-of-range` · `aggregate-sort-unknown-key`
· `aggregate-field-kind-unsupported` · `aggregate-field-type-mismatch`

### Control-plane codes

| Code | Status |
|---|---|
| `invalid-schema` | 400 |
| `entity-exists` | 409 |
| `confirm-mismatch` | 400 |
| `no-draft-to-publish` | 400 |
| `confirmation-required` | 409 |
| `backup-external-not-supported` | 400 |
| `backup-kind-invalid` | 400 |
| `backup-kind-mismatch` | 400 |
| `backup-not-found` | 404 |
| `backup-in-progress` | 409 |
| `duplicate-label` | 400 |
| `permissions-required` | 400 — app-scoped mint had no per-entity grants. |
| `permissions-required-account` | 400 — account-scoped mint had no `account:*` grants. |
| `permissions-account-token-no-entity-grants` | 400 — account-scoped mint included a per-entity grant (only `account:*` allowed). |
| `permissions-account-token-no-app-grants` | 400 — account-scoped mint included an `app:*` grant. |
| `permissions-app-token-no-account-grants` | 400 — app-scoped mint included an `account:*` grant. |
| `permissions-unknown-app-grant:<key>` | 400 — unknown `app:foo` key. |
| `permissions-unknown-account-grant:<key>` | 400 — unknown `account:foo` key. |
| `permissions-invalid:<key>` | 400 — bad value for an entity / `app:*` / `account:*` grant. |
| `expires-at-in-past` | 400 — `expiresAt` is in the past (5-second skew tolerated). |
| `expires-at-too-far` | 400 — `expiresAt` is more than one year out. |
| `label-required` · `label-too-long` · `label-invalid-characters` | 400 |
| `token-not-revoked` | 409 — `DELETE …/permanent` hit a token whose `revokedAt` is still null. Revoke (soft) first, then call `…/permanent`. |

The full programmatic catalogue lives at `GET /d/{appKey}/_meta/errors`.

---

## Reserved field names

You cannot create a schema field with any of these names:

- `_id` — server-assigned ObjectId.
- Any name starting with `_` — reserved for server metadata.
- Any name containing `:` — conflicts with the filter `field:op` grammar.
- `query` — conflicts with the read envelope key.

---

## Introspection (`/_meta`)

Five GET endpoints under `/d/{appKey}/_meta/*`. All respond with an
`ETag: W/"<schemaHash>"` header; honour `If-None-Match` for a
cacheable 304.

| Path | Purpose |
|---|---|
| `GET /d/{appKey}/_meta` | App summary: name, apiVersion, schemaHash, capability flags, entities the token can see (with verbs). |
| `GET /d/{appKey}/_meta/entities/{entity}` | Fields (type, required, validators, filterOps, aggregateOps, description, example), relations (cardinality, inversedBy, supportsPerParentTopN), token's access verbs, optional `samples[]` (gated by `app:meta-samples`). |
| `GET /d/{appKey}/_meta/graph` | Relation graph — nodes + edges. Edges to entities this token can't see are pruned. |
| `GET /d/{appKey}/_meta/self` | Identity + per-entity access matrix + `appGrants` dict + echoed capability flags. |
| `GET /d/{appKey}/_meta/errors` | Programmatic dictionary of every envelope code. |

**Meta permission rule.** Token needs *any* per-entity grant on the
app to hit any `/_meta/*` endpoint. Zero grants → `403 app-no-access`.
Entity detail requires a grant on the specific entity (missing → 404
to avoid existence-probing).

### App capabilities (echoed on `/_meta`)

| Key | Meaning |
|---|---|
| `graphRead` | Deep eager-load is supported. |
| `perParentTopN` | Per-parent top-N via `limit` on many-cardinality. |
| `nestedWrites` | Nested writes in create / update. |
| `aggregate` | `/aggregate` endpoint. |
| `count` | `count: true` on Read. |
| `meta` | `/_meta/*` endpoints. |

---

## Control plane

Per-app endpoints (under `/apps/{appKey}/...`) accept **either** a
session JWT **or** a `plx_` access token carrying the matching
`app:*` grant. Account-level endpoints (`/apps`, `/account/tokens`)
accept session JWT **or** an *account-scoped* `plx_` token carrying
`account:apps` / `account:tokens`. See [Account-scoped
tokens](#account-scoped-tokens-scope-account) for when to choose
which.

### Apps

App lifecycle. `/apps` is account-level — the surface where a brand
new app is born.

| Verb | Path | Grant | Purpose |
|---|---|---|---|
| GET | `/apps` | `account:apps:r` | List apps owned by the caller. |
| POST | `/apps` | `account:apps:w` | Create. Body: `{ "name": "...", "description": "..."?, "external": { "connectionString": "...", "databaseName": "..." }? }`. Returns the new app envelope (with `appKey`). |
| GET | `/apps/{appKey}` | `account:apps:r` | Get one. 404 if the caller doesn't own it (no existence leak). |
| PATCH | `/apps/{appKey}` | `account:apps:w` | Rename / update description. Body: `{ "name": "...", "description": "..."? }`. |
| PATCH | `/apps/{appKey}/status` | `account:apps:w` | Suspend / resume. Body: `{ "status": "active" \| "suspended" }`. |
| DELETE | `/apps/{appKey}` | `account:apps:w` | Soft-delete. Returns `202 Accepted`; PendingDelete sweep runs cleanup async. |
| GET | `/account/usage` | `account:apps:r` | Usage rollup across **all** the caller's apps: grand totals + per-app + per-verb + per-day breakdowns. Query: `from`, `to` (UTC ISO; default last 7 days). Powers the dashboard. Only ever aggregates the caller's own apps. |
| GET | `/account/errors` | `account:apps:r` | Recent error rows (4xx/5xx) across **all** the caller's apps, newest first. Query: `from`, `to`, `limit` (default 20, max 100). |

### Account tokens

Manage tokens scoped to the calling user's account. Lists exclude
per-app tokens (those live under `/apps/{appKey}/tokens`).

| Verb | Path | Grant | Purpose |
|---|---|---|---|
| GET | `/account/tokens` | `account:tokens:r` | List **account-scoped** tokens owned by the caller. Returns active *and* revoked rows. |
| POST | `/account/tokens` | `account:tokens:w` | Mint. With `appKey: null` (or omitted) → account-scoped (validator allows only `account:*` grants). With `appKey` set to a caller-owned app → delegates to the per-app mint use case (validator allows only per-entity / `app:*` grants; cross-user app → 404). |
| PATCH | `/account/tokens/{tokenId}` | `account:tokens:w` | Update label / permissions / IP allowlist. `expiresAt` is **immutable** post-mint — revoke + re-mint to extend or shorten. |
| DELETE | `/account/tokens/{tokenId}` | `account:tokens:w` | Revoke (soft). Sets `revokedAt`; effective immediately in-process (cache invalidates). Idempotent. |
| DELETE | `/account/tokens/{tokenId}/permanent` | `account:tokens:w` | Permanently delete a *revoked* account-token row. `409 token-not-revoked` if the token is still live — revoke first. |

Mint payload (full shape):

```jsonc
{
  "label":       "string (required, [A-Za-z0-9 _.-], ≤64 chars)",
  "permissions": { "<grant-key>": "<value>" },     // see grant table in Auth section
  "ipAllowlist": ["10.0.0.0/8"] | null,
  "expiresAt":   "2026-05-26T00:00:00Z" | null,    // null = never; else future, ≤1 yr out
  "appKey":      "abc..." | null                   // null = account-scoped, else per-app under that app
}
```

Mint response (success):

```jsonc
{
  "token": {
    "id":           "...",
    "label":        "...",
    "scope":        "account" | "app",
    "appKey":       "abc..." | null,
    "permissions":  { "...": "..." },
    "ipAllowlist":  [...] | null,
    "createdAt":    "...",
    "lastUsedAt":   null,
    "revokedAt":    null,
    "expiresAt":    "..." | null
  },
  "plaintextToken": "plx_..."
}
```

### Access tokens (per-app)

Per-app tokens are minted under their owning app. `account:tokens: w`
on an account-scoped token can also reach this surface via the
polymorphic `POST /account/tokens` (with `appKey` set) — see the
[Account-scoped tokens](#account-scoped-tokens-scope-account)
section for that path. The endpoints below are the direct surface;
both paths share the same validator.

| Verb | Path | Grant | Purpose |
|---|---|---|---|
| GET | `/apps/{appKey}/tokens` | `app:tokens:r` | List every token for the app — active *and* revoked. Plaintext is one-shot. |
| POST | `/apps/{appKey}/tokens` | `app:tokens:w` | Mint. Response carries the plaintext exactly once. |
| PATCH | `/apps/{appKey}/tokens/{id}` | `app:tokens:w` | Rotate label / permissions / allowlist. Bytes unchanged. |
| DELETE | `/apps/{appKey}/tokens/{id}` | `app:tokens:w` | Revoke (soft). Sets `revokedAt`; takes effect immediately in-process. Idempotent. |
| DELETE | `/apps/{appKey}/tokens/{id}/permanent` | `app:tokens:w` | Permanently delete a *revoked* token row. `409 token-not-revoked` if the token is still live — revoke first. |

### Schemas

| Verb | Path | Grant | Purpose |
|---|---|---|---|
| GET | `/apps/{appKey}/schemas` | `app:schemas:r` | List published + drafted entities. |
| POST | `/apps/{appKey}/schemas` | `app:schemas:w` | Create a new entity (auto-published on create). Body: `{entityName, document: {fields: [...]}}`. |
| GET | `/apps/{appKey}/schemas/{entity}` | `app:schemas:r` | Full schema doc (current + optional draft). |
| PATCH | `/apps/{appKey}/schemas/{entity}/draft` | `app:schemas:w` | Write a draft. Doesn't publish. |
| DELETE | `/apps/{appKey}/schemas/{entity}/draft` | `app:schemas:w` | Discard the draft. |
| GET | `/apps/{appKey}/schemas/{entity}/diff` | `app:schemas:r` | Structural diff of draft vs. current. |
| POST | `/apps/{appKey}/schemas/{entity}/publish` | `app:schemas:w` | Publish the draft. `409 confirmation-required` on risky changes — resubmit with `{"confirm": "..."}`. |
| GET | `/apps/{appKey}/schemas/{entity}/history` | `app:schemas:r` | Version history. |
| POST | `/apps/{appKey}/schemas/{entity}/rollback` | `app:schemas:w` | Body: `{"toVersion": N, "confirm": "..."}`. |
| DELETE | `/apps/{appKey}/schemas/{entity}?confirm={entityName}` | `app:schemas:w` | Delete. `confirm` query must match the entity name. |

### Field types

`string`, `number`, `boolean`, `date`, `array`, `object`, `relation`.

Array types carry an `itemType` (any non-container scalar). Relation
fields carry `relatedEntity`, `cardinality` (`one` or `many`), and
optional `inversedBy` (symmetrical backref on the target).

### Backups

| Verb | Path | Grant | Purpose |
|---|---|---|---|
| GET | `/apps/{appKey}/backups` | `app:backups:r` | List backups. |
| POST | `/apps/{appKey}/backups` | `app:backups:w` | Create. Body: `{"kind": "full"\|"config"\|"data", "label": "..."}`. |
| GET | `/apps/{appKey}/backups/{id}` | `app:backups:r` | Get backup detail. |
| DELETE | `/apps/{appKey}/backups/{id}?confirm={backupId}` | `app:backups:w` | Delete (also drops shadow DB for data / full). |
| POST | `/apps/{appKey}/backups/{id}/restore` | `app:backups:w` | Body: `{"kind": "...", "confirm": "<appKey>"}`. |

Backups on `external` apps (bring-your-own-cluster mode) aren't
supported; `400 backup-external-not-supported`.

### Generated C# client

| Verb | Path | Grant | Purpose |
|---|---|---|---|
| GET | `/apps/{appKey}/client/csharp` | `app:client:y` | Download `application/zip` containing a ready-to-compile `.csproj`, typed `PlexxerClient`, per-entity POCOs, and a README. Regenerated per request from the current published schemas. |

---

## Versioning

Every `/_meta` response carries an `apiVersion` field (SemVer). Minor
bumps add behaviour without breaking wire-compatibility; major bumps
change an envelope, route, or verb. This document reflects the
current major version.

For changelog + release notes, see the dashboard's release feed, or
fetch `GET /_meta` and compare `apiVersion` across releases.
