# The entity API

> CRUD over the CBX catalog, over HTTP, authenticated with bearer tokens, described by OpenAPI 3.1 and the JSON Schemas the component already ships.

Source: CBX documentation, version 4.0 preview (unreleased). Canonical page: https://docs.configbox.at/docs/4.0-preview/technical/entity_api. Last updated 2026-08-25.

---
CRUD over the CBX catalog, over HTTP, authenticated with bearer tokens, described by OpenAPI
3.1 and the JSON Schemas the component already ships.

All paths below are relative to the component root (`components/com_configbox/`).

---

## 1. One registry, one pipeline, five surfaces

Read this section and you can predict what any surface does.

**Two things are shared, and both live in `helpers/entityapi.php` (`ConfigboxEntityApiHelper`):**

1. **The registry** — which entities exist, and which model each one is. Every model that returns a
   name from `getEntityName()` is in it. `ConfigboxTypeGenerator` walks the same answer when it
   writes `generated/records/read/*.php` and `generated/schemas/**`, so the entity list, the shipped
   schemas and the record stubs cannot disagree.
2. **The write pipeline** — `runStorePipeline()`: `prepareForStorage` → `isInsert` → `validateData`
   → `store` → purge. One place, one order, including the `isInsert` timing that decides whether a
   create reports itself as a create.

**Five surfaces sit on those two, and each adds only what is genuinely its own:**

| Surface | Where | What it adds, and nothing more |
|---|---|---|
| REST / HTTP | `controllers/apiv1.php` | bearer auth, status codes, RFC 9457 bodies |
| MCP / stdio | `helpers/mcp.php` | which entities are *writable*, `--read-only`, prose errors |
| Admin UI | `KenedoController::store()` / `delete()` | `core.manage`, reads the form post, renders |
| CLI | `configbox:run-task` → the same controller | argument parsing, exit codes |
| in-process | customizations, migrations | nothing — they call the helper directly |

**The rule that keeps it one system:** a surface may *translate* (a status, a message, an argument)
and may *refuse* (auth, a policy). It may **not** re-implement the registry or the pipeline. If a
surface needs behaviour they lack, the behaviour belongs in them, once.

### What legitimately differs, and what must not

The only honest difference between surfaces is **how a `$data` object is obtained**: a form post has
a request to read, an API call has a JSON body, an MCP tool has an arguments map. That difference
sits *above* `runStorePipeline()`. Everything below it is identical by construction, not by
agreement — which is the point, because agreement drifts and construction cannot.

So: **a fix to storage behaviour goes in `ConfigboxEntityApiHelper` and every surface gets it.** If
you find yourself fixing the same thing twice, one of the two places is wrong.

### The hooks, and which one actually runs

There are two `afterStore` hooks and it is worth knowing which is which:

- `KenedoModel::afterStore($id, $wasInsert)` — **the one that does work.** `store()` calls it inside
  the pipeline, so *every* surface gets it. The calculations model uses it to wipe the other calc
  types; that behaviour is therefore identical over HTTP, MCP and the admin form.
- `KenedoController::afterStore($success)` — empty in the base class and overridden by nothing in
  the component. A controller-only hook, and not where per-entity logic belongs.

### Why authorization is not in the shared layer

Because the stdio MCP server deliberately has none — its trust boundary is *shell access to the
site*, the same as every `configbox:*` command. A check inside the shared class would break that
caller while adding nothing to the others, each of which authenticates before it ever gets there:
the REST controller by bearer token, the admin controller by `core.manage`. **Authorization belongs
to the surface.**

### Nothing in the shared core is MCP-shaped

The code was extracted from the MCP helper — that is simply where a generic pipeline had been
written first — and its dependencies were renamed to match. The rule authoring vocabulary is
`ConfigboxRuleAuthoringHelper` (`helpers/ruleauthoring.php`); `ConfigboxMcpRulesHelper` survives only
as a deprecated alias. A shared core calling into something MCP-named is a dependency pointing the
wrong way, and it makes one system look like two.

### The two forms of every method

Each public CRUD method comes in a name-taking and a model-taking form:

```
getMany($entity, …)                 resolves the name against the registry, then calls ↓
getManyForModel($model, $entity, …) the actual work
```

That is a resolver over a core, not two implementations. The name-taking form serves callers that
speak entity names (REST, MCP). The model-taking form serves callers that already hold a model and
may have no registry entry at all — `KenedoController` works for *every* model in the component,
including the many that never opted into type generation.

---

## 2. The endpoints

```
GET    /cb-api/v1/                          what this store exposes
GET    /cb-api/v1/openapi.json              the OpenAPI 3.1 document
GET    /cb-api/v1/schemas/read/product.json the committed read schema
GET    /cb-api/v1/schemas/write/product.json the committed write schema

GET    /cb-api/v1/{entity}                  a page of records
POST   /cb-api/v1/{entity}                  create                     -> 201
GET    /cb-api/v1/{entity}/{id}             one record
PATCH  /cb-api/v1/{entity}/{id}             change some fields         -> 200
DELETE /cb-api/v1/{entity}/{id}             delete                     -> 200
```

`{entity}` is any name in `GET /cb-api/v1/` — the same registry `configbox:generate-types` walks, so
the entity list and the shipped schemas cannot disagree.

**Three schemas per entity**, and the distinction matters when reading the OpenAPI document:

| Schema | Describes |
|---|---|
| `<Entity>Read` | one **full** record, as `GET /{entity}/{id}` returns it |
| `<Entity>Write` | what a `POST` or `PATCH` may send — appears under `requestBody`, not `parameters` |
| `<Entity>ListRow` | one row of `GET /{entity}` — the **listing columns only**, not the full record |

A collection read deliberately returns less than a single read: a full record carries every joined and
derived column, which is a lot of bytes for a caller deciding what to read next. `ListRow` is derived
from the same `getPropertiesForListing()` the runtime uses, so the two cannot disagree — and
`specs/api/api-description.spec.ts` asserts every `ListRow` against a real response.

**`PUT` is deliberately absent.** It means "replace the resource with this representation", and this
API cannot honour that: the storage pipeline fills unsupplied fields from the stored record, so a PUT
would behave exactly like a PATCH while promising something stronger. It answers `405` and says so.

### Not every entity supports every operation

An entity's model declares what makes sense for it — `KenedoModel::getApiOperations()`, the full
`list/read/create/update/delete` set by default. The **singletons** are why the declaration exists:
`settings` and `store` are one-row entities seeded at install, so they narrow to
`list, read, update` — a second settings row or a deleted store record is not a permission problem
but a request that cannot mean anything. The shared layer refuses a withheld operation with `422`
naming the supported set (HTTP and MCP identically), and the OpenAPI document reads the same
declaration, so it never advertises an operation that would only ever refuse.

### Sensitive fields never come back

A property declared `'apiSensitive' => true` (the licence key, the Anthropic API key, a payment
provider's credentials) is **writable but never returned**: the shared projection strips it from
every read and the read schema marks it `writeOnly: true`. Send it, and the response — like every
later read — simply does not carry the field. See
`com_configbox_property_definition_settings.md` §10.

### Request bodies

**The `Content-Type` decides how a write body is read**, not whether it happens to parse:

- `application/json` (or no content type with a JSON body) — a JSON object of field name => value.
- `application/x-www-form-urlencoded` / `multipart/form-data` — ordinary form fields. Works on every
  method, not just POST: PHP fills `$_POST` for POST alone, so the other methods are parsed out of
  the raw body.

Anything else with a non-JSON body is a `422` that says which two content types work.

`X-HTTP-Method-Override` is honoured **on POST only**, for proxies and clients that cannot send PATCH
or DELETE. Honouring it on a GET would let a plain link, an `<img>` or a browser prefetch delete a
record.

**Query parameters** on a collection read: `limit`, `offset`, `languageTag`, and `filter[name]=value`.
`limit` defaults to **50** with a hard ceiling of **200** — asking for more is **clamped, not
refused**, so a greedy client gets a page rather than an error. Filters are namespaced under
`filter` so one can never collide with a reserved parameter, now or when a new one is added.

`languageTag`, not `language`: on the **site** application Joomla's language filter consumes a request
variable called `language` before the component sees it, so `language=de-DE` silently yielded the
default language.

### Multilingual sites redirect

On a site with the language filter on, `/cb-api/v1/…` answers `301` to `/en/cb-api/v1/…`. That is
pre-existing behaviour for every `/cb-api/` route, not specific to this API. Clients should follow
redirects, or address the prefixed URL directly — **and a client that writes must use the prefixed
URL**.

Not for the reason you would expect: Joomla's language filter exempts `POST`, so a create is never
redirected. `PATCH`, `PUT` and `DELETE` are. A client that follows the `301` may re-issue it as a
`GET` — the historical behaviour RFC 9110 still permits — and then an update answers `200` with an
unchanged record and nothing reports a failure.

---

## 3. What a response looks like

The **HTTP status carries the outcome**; there is no `success` field. See §2 of
`com_configbox_api_contract.md` for the argument.

Success — `application/json`. A single-record read:

```json
{
  "data": { "entity": "product", "id": 25, "record": { "…": "…" } }
}
```

A collection read:

```json
{
  "data": { "entity": "product", "records": [ { "…": "…" } ], "fields": [ { "…": "…" } ] },
  "meta": { "total": 97, "offset": 0, "limit": 50, "returned": 50 }
}
```

`records` carries `ListRow` rows (§2); `fields` is the listing-column metadata — name, label and
type per column — so a client can render the rows as a table without fetching the schema. The
paging counts describe the *response*, which is why they ride in `meta`.

Failure — `application/problem+json`, RFC 9457:

```json
{
  "type": "https://www.configbox.at/api/errors/validation-failed",
  "title": "Validation failed",
  "status": 422,
  "code": "VALIDATION_FAILED",
  "detail": "Field Title cannot be empty.",
  "errors": [ { "code": "VALIDATION_FAILED", "message": "Field Title cannot be empty." } ],
  "validationIssues": [ { "field": "title", "code": "INVALID", "message": "Field Title cannot be empty." } ]
}
```

**Branch on `code`, never on `detail`.** Codes are stable and never translated; `detail` is prose that
may change with a language string edit.

| Status | `code` | When |
|---|---|---|
| `200` | — | read, update, delete |
| `201` | — | create |
| `401` | `AUTHENTICATION_REQUIRED` | no token sent at all — carries `WWW-Authenticate` |
| `401` | `AUTHENTICATION_FAILED` | a token that does not verify: mistyped, unknown, revoked or expired — one code for all of them, deliberately (§5) |
| `403` | `INSUFFICIENT_SCOPE` | valid token, wrong scope — the message names the exact scope needed, e.g. `This token does not carry the "orders:read" scope.` Retrying with the same credentials will never help |
| `404` | `NOT_FOUND` | no such entity, record or endpoint |
| `405` | `METHOD_NOT_ALLOWED` | wrong method — carries `Allow` |
| `422` | `VALIDATION_FAILED` | the data was refused; `validationIssues` names the fields |
| `500` | `INTERNAL_ERROR` | something broke; the detail is in the store's log under the identifier in the message |

The three typed exceptions map straight onto this: `KenedoValidationException` → 422,
`KenedoNotFoundException` → 404, `KenedoSystemException` → 500. A surface that wants a different
status is translating, which is allowed; a surface that wants different *behaviour* is not.

---

## 4. Records and translations

Records cross the wire **flat**: one key per translatable field per active language,
underscore-separated — the same keys the model hydrates and the admin form inputs post. (An
earlier revision nested translations under their field; flat won — see §5 of the API contract.)

```json
{ "id": 1, "sku": "CBX-0001", "title_en_GB": "Workbench", "title_de_DE": "Werkbank" }
```

**Every surface, not just this one.** The record shape is settled inside
`ConfigboxEntityApiHelper`, so HTTP, MCP and the CLI return the same shape from a single-record
read *and* from a list — one field never has two shapes decided by which call you made.

**Language keys are underscored** (`title_en_GB`, never a dash), matching the record keys and the
admin form inputs — one derivation (`KenedoLanguageHelper::getTagKey()`) produces all of them, so
none can drift.

**A single read carries the plain key and every language.** `title` holds the text for the language
the read asked for (`?language=de-DE`, else the store default); `title_en_GB` and `title_de_DE` carry
all of them. A **collection** read is projected down to the listing columns, so it carries the plain
`title` only — ask for the record when you want every language.

**Writes take the flat key**: `{"title_en_GB": "…"}`. The nested `{"title": {"en_GB": "…"}}` form is
**refused with 422**, naming the field and the key to use instead — it was accepted once, and letting
it pass silently is how the shape kept coming back.

**A create must supply every active language** for a required translatable field. That is the model's
own validation, not an API rule: sending only `en_GB` on a two-language store fails with "Field Title
cannot be empty". A **PATCH** has no such requirement — unsent languages keep their stored text.

**Unknown fields are refused, not ignored.** A silently dropped field is the worst available outcome:
the caller is told `200`, believes it set something, and the store disagrees.

### Platform-dependent fields

ConfigBox runs on Joomla, WordPress, Magento 2 and standalone from **one** codebase and **one** set
of migrations. A few dozen fields only mean something on some of those hosts — a product's
`baseprice` is the clearest case, because on Magento the *Magento* product owns the price.

Such a field is **present on every platform**: same column, same schema, readable and writable
through this API wherever you are. What varies is only whether the host has the concept:

| | on a platform where it applies | elsewhere |
|---|---|---|
| stored / readable / writable | yes | yes |
| shown in the admin form | yes | **no** |
| can be `required` | yes | **no** — `platforms` overrides `required` |
| read by the application | yes | **no** — nothing consumes it |

So writing `baseprice` on Magento succeeds and the value round-trips; it simply does not price
anything. That is deliberate: one shape everywhere means a client, a generated type and a schema
never disagree about which fields exist, and a value written on the wrong host is inert rather than
rejected.

**One exception to "identical everywhere": what a NEW record starts with.** A few fields start a
freshly created record at a different value on a host where they do not apply — a product's
`page_nav_show_buttons` declares `2` ("use the global setting") but starts at `0` on Magento, which
owns page navigation itself. Only the *starting* value differs; the column, its DB-level default and
every existing row are identical everywhere. Such a field carries `x-configbox-platform-defaults`
beside its `default`. **It only matters if you omit the field** — send a value and that value is
what you get, on every host.

**How to tell.** Every platform-dependent field carries `x-configbox-platforms` in the generated
schemas — **read and write both** — listing the hosts it applies to, and says the same thing in its
`description` so a reader who ignores extension keys is still told. `cbx_describe_entity` returns
both.

```json
"baseprice": {
  "type": "number",
  "description": "Base price — … Platform-dependent: this field only applies on joomla, wordpress, standalone. It is stored and writable on every platform, but elsewhere it is hidden in the admin form, never required, and nothing reads it.",
  "x-configbox-platforms": ["joomla", "wordpress", "standalone"]
},
"page_nav_show_buttons": {
  "default": "2",
  "x-configbox-platforms": ["joomla", "wordpress", "standalone"],
  "x-configbox-platform-defaults": {"magento2": 0}
}
```

A field that is genuinely **not writable** through the API is a different thing and is reported
separately — see `unsupportedFields` in `cbx_describe_entity` (an image needs a multipart upload;
child entries are stored through their own entity). Platform-dependence never puts a field there.

---

## 5. Authentication

Bearer tokens, RFC 6750. **Every endpoint needs one**, including discovery and the schemas — they
describe the store's field structure, languages and entity list, which is business configuration, and
every legitimate client already holds a token. What a token may do is its **scopes**, each
`<area>:<level>` — the reference for the seven areas, the rules, the presets and where tokens are
minted is `com_configbox_api_tokens_and_scopes.md`; this section covers what the REST surface does
with them.

```
Authorization: Bearer cbx_7_YvH_W6rwY11RumAsUWlq8Hd7tCpuEt1ARPIsFlXZyBs
```

Mint, list and revoke with `configbox:token:*` (see §1.10 of `com_configbox_cli_commands.md`) or
through the **API Tokens** admin screen (`controller=adminapitokens`, since 2026-08) — same helper
underneath, same show-once semantics; the screen's store task returns the cleartext in its JSON
payload so a headless admin client can mint too.

**The format is `cbx_<id>_<secret>`** and each part earns its place: the `id` makes verification an
indexed primary-key read instead of a table scan; the `cbx_` prefix is what lets a secret scanner
recognise a leaked token, where a bare random string looks like nothing.

**Only the SHA-256 is stored.** The token is displayed once, at mint time, and nothing can show it
again. SHA-256 rather than a slow KDF is deliberate: these are 32 CSPRNG bytes with no structure to
guess, so there is no dictionary and no offline attack a salt would frustrate — while a bcrypt round
would run on *every API request*. Comparison is `hash_equals()`, so the check does not leak how much
of a guess was right.

**A scope is `<area>:<level>`.** Seven areas — `catalog`, `store`, `customers`, `orders`,
`conversations`, `code`, `system` — each entity belongs to exactly one
(`ConfigboxApiTokenHelper::getEntityAreas()`, or a customization model's own `getApiArea()`
override), and each request needs the entity's area at the request's level: `GET` needs `<area>:read`,
`POST`/`PATCH`/`DELETE` need `<area>:write`. Within an area `write` implies `read` — a token minted
`catalog:write` can also `GET` the catalog — but nothing carries across areas: an `orders:write` token
cannot touch `product`, and a `catalog:write` token cannot touch `sales-order`. Every token also reads
`store` regardless of its grant (settings, currencies, tax classes, geography — the vocabulary every
other area is expressed in), which is safe only because credential-bearing fields are `apiSensitive`
and never returned (§2 above, "Sensitive fields never come back"). Grant the
narrowest area:level that works; `configbox:token:mint --preset` bundles the common combinations.

Every authentication failure answers **the same body**, whether the token was malformed, unknown,
wrong, revoked or expired. Distinguishing them tells a prober which half of a guess was right.

**Apache note.** The `Authorization` header does not reach PHP unless `CGIPassAuth` is on or a rewrite
copies it — and where a rewrite does it, the value arrives as `REDIRECT_HTTP_AUTHORIZATION`. The
controller reads both names and falls back to `apache_request_headers()`, so a stock Apache does not
silently refuse every token.

### Operational notes

- **There is no rate limiting.** Stated explicitly rather than left to be discovered: nothing at
  this layer throttles a caller. If a store needs one, it belongs in front — the web server or a
  proxy — not in the component.
- **Tokens can expire.** `mint()` takes an optional expiry (`configbox:token:mint --expires`), and
  `hasExpired()` is checked on every verification — an expired token fails like a revoked one, with
  the same body.
- **Last use is tracked.** Every authenticated request stamps the token row (`touch()`), so
  `configbox:token:list` can show which tokens are alive and which have been dead weight for months.
- **No CORS headers are set.** A browser-based cross-origin client cannot call this API — the
  preflight fails and the response carries no `Access-Control-Allow-*`. That is the stance, not a
  gap: the audience is server-side callers, and a bearer token has no business living in a
  browser.

---

## 6. Discovery

`GET /cb-api/v1/openapi.json` returns an **OpenAPI 3.1** document built per request.

**3.1 and not 3.0**, because 3.1 uses JSON Schema 2020-12 unchanged — which is exactly what
`generated/schemas/` contains. The schemas drop in by `$ref` to the URLs the API itself serves, so
there is one copy of each. Targeting 3.0 would mean converting every schema on the way out into a
dialect that only resembles JSON Schema (`nullable: true`, no `$id`), lossily, per request, with
nobody validating the output — and the document would then describe an API subtly different from the
one the schemas describe.

**Assembled per request, not committed**, because the entity list, the active languages and the base
URL are properties of the *store*, not of the codebase; a committed document would describe whichever
store generated it. The schemas it points at *are* committed, and are served from disk rather than
regenerated, so a client validates against the same artifact that shipped.

**Both document endpoints answer bare — the ONE deliberate exception to the `data` envelope.** An
OpenAPI document and a JSON Schema each have their own root-level contract (`openapi: "3.1"`,
`$schema`/`$id`), and their consumers are other people's tools: an importer pointed at
`/openapi.json`, a validator dereferencing a schema URL as a `$ref`. None of them unwrap an
envelope, so a wrapped document is a document these URLs might as well not serve — which was
precisely the state until 2026-07-31. The schemas answer with their own media type,
`application/schema+json`. Failures on either endpoint are problem+json like everywhere else: a
problem document is not the resource, so the contract applies to it unchanged. The index
(`GET /cb-api/v1/`) stays enveloped — it is ours, read by clients that speak this API.

**Discovery needs *a* token, not a specific scope.** `/`, `/openapi.json` and `/schemas/...` all
accept any valid token — describing the store's shape is not itself reading or writing an area, so
there is no `<area>:<level>` to require. What differs is what each one *shows*: `/openapi.json` and
the schema files describe every entity in the registry, the same for every caller, because they are
one committed artifact per install, not a per-token view. The index is the one that is filtered —
`buildIndex()` walks the registry and skips any entity whose area the caller's token cannot read, so
`GET /cb-api/v1/` never lists a `collectionUrl` that would answer `403`.

---

## 7. Worked example

```bash
TOKEN=$(php cli/joomla.php configbox:token:mint "importer" --preset=author | tail -1)
B=https://example.com/en/cb-api/v1

curl -sH "Authorization: Bearer $TOKEN" "$B/"                    # what exists
curl -sH "Authorization: Bearer $TOKEN" "$B/product?limit=5"     # a page

curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"page_id":1,"title_en_GB":"Colour","title_de_DE":"Farbe","question_type":"textbox"}' \
  "$B/question"                                                  # 201

curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"internal_name":"colour"}' "$B/question/123"               # 200, title untouched

curl -s -X DELETE -H "Authorization: Bearer $TOKEN" "$B/question/123"
```

---

## 8. Coverage, honestly

What has actually been exercised against a real store, rather than assumed:

- **Every registered entity** (see `GET /cb-api/v1/` for the live list) answers `GET /{entity}`
  and serves both schemas.
- **Just under half of them** held records on the development store and every one reads back
  through the projection. The rest (orders, quotes, payments, geography) were empty there, so
  their single-record read path is **untested**, not known-good.
- **Full create / read / update / delete** round trips on six entities of deliberately different
  shapes: `question` (FK + translatable + `applies-when`), `page` (product FK), `salutation` (plain
  translatable), `tax-class` (non-translatable title plus a numeric), `currency` (code/symbol with a
  uniqueness constraint) and `product-list`.
- JSON and **form-encoded** bodies, on POST and on PATCH; `X-HTTP-Method-Override`, including that it
  cannot escalate a GET; `limit` clamping; `filter[…]`; an inactive `languageTag`.
- Every status code above, both required headers, scope enforcement, and revoked and expired tokens.

**On the write path beyond the six.** `dropUnacceptableDefaults()` and
`nullBaseTableValuesToEmptyStrings()` were originally hardened against the MCP whitelist's six
catalog entities, and the honest expectation was that store-configuration entities would break on
property types those never exercised. Measured, they did not: the four non-catalog entities above
went through create, read and delete unchanged. That is encouraging rather than conclusive — it is
six entities of the whole registry — but "expect breakage" would now be overstating it. The untested remainder is
mostly the transactional entities (orders, quotes, payments and their lines), which are also the ones
where a careless write is most expensive.

`meta.failedAt` (the exception context trail) is reachable but narrow: `KenedoModel::store()` adds
its context on failure, so a failure *inside* store is located, while a validation refusal — which
happens in `validateData()`, before store — is not. That is the common case and it carries
`validationIssues` instead, which is the more useful thing.

---

## 9. See also

- `com_configbox_api_contract.md` — the response contract every CBX endpoint targets
- `com_configbox_cli_commands.md` §1.9 — the token commands
- `com_configbox_mcp_server.md` — the MCP surface on the same pipeline
- `com_configbox_type_generation.md` — where the schemas and record stubs come from
- `docs/platform/joomla/com_configbox_sef_urls.md` — how `/cb-api/` routes
