Skip to main content
Version: 4.0 preview

The entity API

Version
4.0 preview
Updated
View markdown

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 pipelinerunStorePipeline(): prepareForStorageisInsertvalidateDatastore → 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:

SurfaceWhereWhat it adds, and nothing more
REST / HTTPcontrollers/apiv1.phpbearer auth, status codes, RFC 9457 bodies
MCP / stdiohelpers/mcp.phpwhich entities are writable, --read-only, prose errors
Admin UIKenedoController::store() / delete()core.manage, reads the form post, renders
CLIconfigbox:run-task → the same controllerargument parsing, exit codes
in-processcustomizations, migrationsnothing — 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:

SchemaDescribes
<Entity>Readone full record, as GET /{entity}/{id} returns it
<Entity>Writewhat a POST or PATCH may send — appears under requestBody, not parameters
<Entity>ListRowone 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:

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

A collection read:

{
"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:

{
"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.

StatuscodeWhen
200read, update, delete
201create
401AUTHENTICATION_REQUIREDno token sent at all — carries WWW-Authenticate
401AUTHENTICATION_FAILEDa token that does not verify: mistyped, unknown, revoked or expired — one code for all of them, deliberately (§5)
403INSUFFICIENT_SCOPEvalid 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
404NOT_FOUNDno such entity, record or endpoint
405METHOD_NOT_ALLOWEDwrong method — carries Allow
422VALIDATION_FAILEDthe data was refused; validationIssues names the fields
500INTERNAL_ERRORsomething 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.)

{ "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 applieselsewhere
stored / readable / writableyesyes
shown in the admin formyesno
can be requiredyesnoplatforms overrides required
read by the applicationyesno — 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.

"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

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