# 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)
  - [App lock](#app-lock)
  - [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"`) and / or `app:*` grants (samples, control plane), and an
  optional CIDR allowlist. One grant of any kind is enough: a token for
  an app that has no entities yet can carry `app:*` grants only, e.g.
  `{"app:schemas": "rw"}` for an agent that is going to design the
  database.
- **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, "createdDocuments": [ { "_id": "6a3f...", "name": "...", ... } ] }
```

The flat `/create` response is **always** `createdDocuments` (an array), whether
you post a single object or an array — a single create just returns a one-element
array. Only `?return=graph` (nested writes) returns a `created` tree instead.

- `_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.

**Bulk create.** The same `/create` endpoint accepts a **JSON array** of objects
and inserts them in one round trip (Mongo `insertMany`). Each document is
validated and coerced independently. The response uses the same
`createdDocuments` array as a single create, with one element per input:

```
POST /d/{appKey}/Customer/create
[
  { "name": "Acme Inc",  "email": "billing@acme.com" },
  { "name": "Globex LLC", "email": "ap@globex.com" }
]
→ { "success": true, "createdDocuments": [ { "_id": "…", … }, { "_id": "…", … } ] }
```

A `unique`-index violation anywhere in the batch surfaces as
`400 unique-violated` (with the offending `index` + `fields`). Nested/relation
writes are **not** supported inside a bulk array — use the single-object form
with `?return=graph` for those.

### 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,
    "includeFields": ["_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, "matchedDocuments": 12, "modifiedDocuments": 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** (bare `{}`, or filter-only — no `metrics` / `groupBy` / `distinct`):
```
POST /d/{appKey}/{entity}/aggregate
{"filter": {"status:eq": "paid"}}
→ { "success": true, "result": {"count": 4210} }
```
There is **no** `count` key on `/aggregate` — unknown top-level keys are rejected with
`aggregate-body-invalid`. (The Read endpoint's `query.count` is a separate feature.)

**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, "result": {"values": ["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` | SQL-style pattern match, **anchored to the whole value** and **case-insensitive**: `%` = any run of characters, `_` = exactly one. Both also match line breaks, so `"%x%"` finds `x` on any line of a multi-line value. Prefix match: `"x%"`; suffix: `"%x"`; substring: `"%x%"`. | 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 — combinator keys are **colon-prefixed** (`:and` / `:or`), not `$and` / `$or`:

```
{
  ":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. |
| `includeFields` | `string[]` | Projection — include-list. |
| `excludeFields` | `string[]` | Projection — exclude-list. |
| `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 |
|---|---|---|
| `body-invalid` | 400 | Request body has the wrong shape for this verb. Carries `message`. |
| `validation-failed` | 400 | One or more fields invalid. Body carries `errors[]` with `{path, code, message}`; for nested writes the per-error `code` is a `nested-write-*` / `coercion` / `required` / `unknown-field` reason. |
| `coercion` | 400 | A value couldn't be coerced to its declared type. Carries `field`, `message`. |
| `required-fields-missing` | 400 | Required fields omitted from a create. Carries `fields`. |
| `required-relations-missing` | 400 | Required relation fields omitted. Carries `fields`. |
| `relation-target-missing` | 400 | A relation id (or nested `_connect`) doesn't point at an existing document. Carries `missing`. |
| `unknown-fields` | 400 | A strict entity rejected unknown property names. Carries `fields`. |
| `unique-violated` | 400 | A unique index rejected the write. Carries `index`, `fields`. |
| `nested-write-ambiguous` | 400 | `_create` + `_connect` on the same relation slot (surfaces inside `validation-failed`'s `errors[].code`). |

An unknown entity in the URL returns a bare **404** with no error envelope (there is no `entity-not-found` code).

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

Every structural error shares one envelope — the top-level `error` is always
`"dsl"`, with a stable sub-class in `code` (omitted only for a few legacy messages):

```
{"error": "dsl", "code": "<sub-code>", "message": "..."}
```

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-body-invalid` · `aggregate-unknown-field` · `aggregate-unknown-op`
· `aggregate-field-type-mismatch` · `aggregate-field-kind-unsupported`
· `aggregate-too-many-metrics` · `aggregate-too-many-group-keys`
· `aggregate-limit-out-of-range` · `aggregate-sort-unknown-key`

### Control-plane codes

| Code | Status |
|---|---|
| `invalid-schema` | 400 — body carries `details[]`. |
| `entity-exists` | 409 |
| `status-invalid` | 400 — requested app status transition isn't allowed. |
| `app-locked` | 423 — the app's configuration is locked (see [App lock](#app-lock)). Body carries `appKey`, `lockedAt`, `message`. The data plane and every read keep working; taking and deleting backups and reapplying indexes still work. |
| `unlock-requires-session` | 403 — `POST /apps/{appKey}/unlock` was called with a `plx_` token. Unlocking is deliberately dashboard-only; tokens can lock but never unlock. |
| `plan-limit-reached` | 402 — a limit of the account's plan has been reached (see [Account plan and limits](#account-plan-and-limits)). Body carries `limit` (`apps` · `backups` · `apiCalls` · `storage`), `max`, `current`, `license`. Also emitted on the data plane for `apiCalls` and `storage`. Retrying does not help. |
| `external-connections-not-allowed` | 403 — create / copy asked for an external MongoDB target, but the account is not enabled for external databases (a per-account switch, not part of any licence). |
| `external-connection-target-blocked` | 400 — the external connection string resolves to an internal or private address, or to PLEXXER's own cluster; only publicly reachable MongoDB servers are accepted. |
| `external-connection-host-unresolvable` | 400 — a host in the external connection string does not resolve from PLEXXER (names that only exist on a private network cannot be used). |
| `external-connection-option-not-allowed` | 400 — the external connection string carries a proxy option or an authentication mechanism other than SCRAM / PLAIN. |
| `account-disabled` | 403 — the owning account has been disabled by PLEXXER; nothing is deleted, requests are refused until it is enabled again. |
| `version-not-found` | 404 — rollback target version doesn't exist. |
| `confirm-mismatch` | 400 — typed confirmation didn't match (unlock-app, delete-entity, delete-backup, restore-backup, replace-app). |
| `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 |
| `copy-scope-invalid` | 400 — scope must be `config` or `full`. |
| `database-name-required` · `connection-string-required` · `invalid-connection-string` · `invalid-database-name` | 400 — the external copy target is unusable. |
| `copy-database-same-as-source` | 400 — the copy would land in the source's own database on the same cluster. Clusters are compared by identity (canonical member list, DNS-resolved), not by connection-string text. |
| `copy-database-not-empty` | 409 — the named customer database already holds collections. |
| `copy-external-unreachable` | 502 — body carries `reason`. |
| `copy-source-busy` | 409 — a backup or restore is running on the source. |
| `copy-failed` | 500 — body carries `message`; the half-built copy is scheduled for deletion, the source is unchanged. |
| `replace-source-not-found` | 404 |
| `replace-same-app` | 400 |
| `replace-same-database` | 400 — source and target are different apps on one physical database (same cluster by identity, same name); replacing would destroy the source. |
| `replace-target-busy` | 409 — a backup or restore is running on the target. |
| `replace-failed` | 500 — body carries `message` and `backupId`; the target is left suspended, the source is unchanged. |
| `duplicate-label` | 400 |
| `permissions-required` | 400 — app-scoped mint had no grants at all. One grant of any kind is enough: a per-entity grant or an `app:*` grant. |
| `permissions-required-account` | 400 — account-scoped mint had no `account:*` grants. |
| `permissions-entity-required` | 400 — a permission key was empty. |
| `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. |
| `ip-allowlist-invalid:<cidr>` | 400 — an `ipAllowlist` entry isn't valid CIDR. |
| `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, `locked` (see [App lock](#app-lock) — when true, configuration changes through a token answer `423 app-locked`, except taking / deleting backups and reapplying indexes, and only a signed-in owner can unlock), capability flags, entities the token can see (with verbs). The ETag also changes when the lock is toggled. |
| `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* grant on the app to hit any
`/_meta/*` endpoint: a per-entity grant or an `app:*` grant. A token with
only `app:*` grants gets its identity and capability flags and an empty
entity list. 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": "..."? }`. On a locked app: `423 app-locked` for tokens, allowed from the dashboard. |
| PATCH | `/apps/{appKey}/status` | `account:apps:w` | Suspend / resume. Body: `{ "status": "active" \| "suspended" }`. On a locked app: `423 app-locked` for tokens, allowed from the dashboard. |
| DELETE | `/apps/{appKey}` | `account:apps:w` | Soft-delete. Returns `202 Accepted`; PendingDelete sweep runs cleanup async. Refused with `423 app-locked` while the app is locked, also from the dashboard. |
| POST | `/apps/{appKey}/lock` | `account:apps:w` | **Lock** the app's configuration (see [App lock](#app-lock)). No body. Idempotent. Returns the app envelope with `locked: true` and `lockedAt`. An agent may lock the app it just finished deploying. |
| POST | `/apps/{appKey}/unlock` | *session JWT only* | **Unlock.** Body: `{ "confirm": "<appKey>" }`. Deliberately a dashboard-only step: any `plx_` token — whatever its grants — gets `403 unlock-requires-session`. Wrong `confirm` → `400 confirm-mismatch`. |
| POST | `/apps/{appKey}/copy` | `account:apps:w` | Duplicate an owned app into a brand-new independent app. Body: `{ "name": "...", "scope": "config" \| "full", "target": { "databaseName": "...", "connectionString": "..."? }? }`. `config` copies every entity (fields, validations, relations, indexes, drafts, version history); `full` also copies every document, taken at one point in time. `target` omitted → a PLEXXER-managed copy; `target` given → a new, still-empty database on a customer cluster (`connectionString` may be left out when the source is itself external, meaning the source's own cluster). Works in every direction: managed → managed, managed → your cluster, your cluster → managed. Tokens, backups and usage history are never copied. Returns `201` with `{ app, scope, entities, schemaVersions, collections?, documents?, pointInTime?, warnings[] }`. Synchronous; may run for minutes on large apps. |
| POST | `/apps/{appKey}/replace` | `account:apps:w` | Overwrite the app in the route (the **target**) with a copy of another owned app. Body: `{ "source": "<sourceAppKey>", "scope": "config" \| "full", "confirm": "<targetAppKey>", "backupFirst": true? }`. The target keeps its key, name, tokens and connection (managed or your own cluster). `config` replaces its entities and keeps its documents; `full` also deletes every document and copies the source's. A backup of the target is taken first unless `backupFirst` is false (not possible for external targets — reported in `warnings`). The target is suspended during the operation. The source is only read. Returns `200` with `{ app, scope, entities, schemaVersions, collections?, documents?, pointInTime?, backupId?, warnings[] }`. |
| 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). |

### App lock

Locking freezes an app's **configuration** so that an agent holding a token — or a person
clicking too fast — cannot change a production app by accident. The app itself keeps working
exactly as before.

| While locked | Answer |
|---|---|
| Every `/d/{appKey}/…` call: create, read, update, delete, aggregate, nested writes, graph reads, `/_meta` | **Unchanged.** The lock is invisible to the data plane. |
| Every read of configuration: `GET /apps/{appKey}`, schemas, diff, history, indexes, tokens, backups, usage, errors, client download | **Unchanged.** |
| Backups: take (`POST /apps/{appKey}/backups`), delete | **Allowed.** Keep your backup and retention agents running. |
| `POST /apps/{appKey}/schemas/{entity}/indexes/reapply` | **Allowed.** It rebuilds the indexes the published schema already declares; no configuration changes. |
| `POST /apps/{appKey}/copy` with the locked app as **source** | **Allowed.** The source is only read; the copy starts unlocked. |
| Tokens: mint (also via `POST /account/tokens` with `appKey`), update, revoke, permanent delete | **Dashboard only.** `423 app-locked` for any `plx_` token; a signed-in owner can still do it. |
| App: rename / description, suspend / resume | **Dashboard only.** `423 app-locked` for any `plx_` token; a signed-in owner can still do it. |
| Schemas: create, patch draft, discard draft, publish, rollback, delete | `423 app-locked` — for tokens and the dashboard alike. |
| Backups: restore (any kind) | `423 app-locked` — for tokens and the dashboard alike. |
| App: delete, `replace` **onto** the locked app | `423 app-locked` — for tokens and the dashboard alike. |

"Dashboard only" means the lock only stops agents: an owner can revoke a leaked token, rotate
credentials, suspend the app in an emergency or fix its name without unlocking it.

The `423` body is written for agents and people alike:

```json
{
  "error": "app-locked",
  "appKey": "ak_8f2e…",
  "lockedAt": "2026-09-26T10:00:00Z",
  "message": "App 'Orders Production' (ak_8f2e…) is locked. Its entities cannot be changed, backups cannot be restored and the app cannot be deleted or replaced until an owner unlocks it in the PLEXXER dashboard; its tokens, name and status can only be managed from the dashboard while it is locked. Unlocking is deliberately a dashboard-only step: no API call or access token can unlock an app. Documents can still be created, read, updated and deleted through /d/ak_8f2e…/…, backups can still be taken and deleted, and declared indexes can still be reapplied."
}
```

`423 Locked` is distinct from `403` on purpose: the token's grants are fine, so an agent should
**not** go looking for a missing permission. The right move is to stop and tell the operator.

**Locking is available to the API; unlocking is not.** `POST /apps/{appKey}/lock` accepts a
session or an `account:apps:w` token — an agent that just deployed an app may lock it. Unlocking
requires a signed-in owner in the dashboard, who types the app's name to confirm. This is the
whole point of the feature: an agent told to "deploy all changes" cannot lift the protection it
runs into. `GET /d/{appKey}/_meta` reports `app.locked` so an agent can see the state up front,
and `GET /d/{appKey}/_meta/errors` documents both `app-locked` and `unlock-requires-session`.

Existing apps are unlocked; nothing changes until you lock one.

### Account plan and limits

Every account has a licence — `free`, `standard`, `enterprise` or
`unlimited` — that applies to all apps the account owns; the `license`
field of the app envelope repeats it. The licence caps four resources:

| Licence | Apps | API calls / month | Database per app | Backups per app |
|---|---|---|---|---|
| `free` | 2 | 50,000 | 500 MB | 2 |
| `standard` | 10 | 5,000,000 | 8 GB | 14 |
| `enterprise` | 50 | 25,000,000 | 100 GB | 60 |
| `unlimited` | no limit | no limit | no limit | no limit |

External databases (your own MongoDB server) are a separate per-account
switch, not part of any plan.

A reached cap answers `402 plan-limit-reached`:

```json
{"error": "plan-limit-reached", "limit": "apps", "max": 2, "current": 2, "license": "free"}
```

| `limit` | Raised by | Effect |
|---|---|---|
| `apps` | `POST /apps`, `POST /apps/{appKey}/copy` | No new app until one is deleted or the plan is raised. |
| `backups` | `POST /apps/{appKey}/backups`; `POST /apps/{appKey}/replace` when its safety backup cannot be taken | Delete an older backup of that app first, or replace with `"backupFirst": false`. |
| `apiCalls` | Any `plx_` token request on `/d/{appKey}/…` | Refused for every app of the account until the calendar month (UTC) ends. |
| `storage` | `create` / `update` on an app over its database-size limit | Reads and deletes keep working, so data can be removed. |

Limits are evaluated periodically, not per request: a cap takes effect
within minutes rather than on the exact request that crosses it.

`GET /account/plan` (session JWT) →
`{ "license", "externalHostsAllowed", "limits": { "maxApps", "maxApiCallsPerMonth", "maxDatabaseBytesPerApp", "maxBackupsPerApp" }, "usage": { "apps", "apiCalls", "periodStart", "periodEnd", "largestDatabaseBytes" }, "restrictions": { "apiCallsExceeded", "storageExceededAppKeys": [] } }`.
A `null` limit means unlimited.

Placing an app on your own MongoDB server (`external` on create, `target`
on copy) is **not** part of a licence. It is enabled per account, on
request (`403 external-connections-not-allowed` otherwise), and the
server must be reachable on the public internet: internal and private
addresses and PLEXXER's own cluster are refused with
`400 external-connection-target-blocked`, a host that does not resolve
with `400 external-connection-host-unresolvable`, and a proxy option or
an authentication mechanism other than SCRAM / PLAIN with
`400 external-connection-option-not-allowed`.

### Per-app usage & errors

Single-app observability — the per-app analogue of the account rollups above.

| Verb | Path | Grant | Purpose |
|---|---|---|---|
| GET | `/apps/{appKey}/usage` | `app:tokens:r` | Usage for one app: totals + per-verb / per-time-bucket breakdown. Query: `from`, `to` (UTC ISO; default last 7 days). |
| GET | `/apps/{appKey}/tokens/{tokenId}/usage` | `app:tokens:r` | Same shape, scoped to a single token. |
| GET | `/apps/{appKey}/errors` | `app:tokens:r` | Recent error rows for one app, newest first. Query: `from`, `to`, `limit`. |

`*/usage` → `{ "from", "to", "totals": { "count", "errorCount", "durationSumMs", "durationMaxMs" }, "buckets": [ { "bucket", "verb", "count", "errorCount", "durationSumMs", "durationMaxMs" } ] }`.
`/errors` → `{ "from", "to", "limit", "errors": [ { "occurredAt", "tokenId", "tokenLabel", "verb", "path", "statusCode", "errorCode" } ] }`.

### 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. |
| GET | `/apps/{appKey}/schemas/{entity}/indexes` | `app:schemas:r` | List the **live** indexes on the collection (introspection — may show drift from the declared set). |
| POST | `/apps/{appKey}/schemas/{entity}/indexes/reapply` | `app:schemas:w` | Reconcile live indexes with the current published schema. Returns an apply report (`created`/`dropped`/`kept`/`failures`). |

### Indexes

Indexes are **not created through a dedicated endpoint** — they are declared
*inside the schema document* under an `indexes` array, alongside `fields`.
They are applied to the underlying collection automatically when the entity is
created (`POST /schemas`) and on every `publish`. Application is best-effort and
never rolls back the schema write: any failures come back in the publish
response's `indexes.failures[]` (and can be retried with `…/indexes/reapply`).

```jsonc
POST /apps/{appKey}/schemas
{
  "entityName": "Members",
  "document": {
    "fields": [
      { "name": "email",     "type": "string", "required": true },
      { "name": "createdAt", "type": "date" }
    ],
    "indexes": [
      {
        "name":   "ux_email",                       // required, unique per entity
        "fields": [ { "name": "email", "order": "asc" } ],   // each entry is an OBJECT
        "unique": true,
        "sparse": false,
        "ttlSeconds": null
      },
      {
        "name":   "ttl_createdAt",
        "fields": [ { "name": "createdAt", "order": "asc" } ],
        "ttlSeconds": 3600                           // TTL: single date field, not unique/sparse
      }
    ]
  }
}
```

Index field shape & naming rules:

- **`name`** (the index name) is **required** and must match `^[A-Za-z][A-Za-z0-9_]{0,62}$`
  (same rule as field names; not the `_plexxer_` reserved prefix). Convention:
  `ux_` for unique, `ix_` for plain, `ttl_` for TTL. Must be unique within the entity.
- **`fields`** is an array of **objects**, each `{ "name": "<field>", "order": "asc"|"desc" }`
  — **not** an array of bare strings and **not** a Mongo-style `{ "email": 1 }` key map.
  Every `name` must reference a field declared on the same entity. 1–32 fields; one
  field = single-column index, multiple = compound (order matters).
- **`unique`** / **`sparse`** are booleans (default `false`). **`ttlSeconds`** is an
  integer or `null`; when set, the index must be a single field of `type: "date"` and
  must not be `unique` or `sparse`.
- Omitting `name`, or sending a field entry without its `name`, returns
  `400 invalid-schema` with `details: ["index-field-name-required:<index>"]` — not a 500.

### 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.
