Skip to main content
Version: 4.0 preview

External API contract

Version
4.0 preview
Updated
View markdown

Status: largely met. This is the contract every CBX endpoint satisfies — admin CRUD, authentication, and the end-user configurator — so that the product can be driven entirely from outside: by an AI assistant over MCP, by a headless storefront, by the admin UI itself, and by the E2E suite.

What remains is the tail §8's ledger names: the checkout tasks (which still render pages), getMissingSelections* (left as the browser's bare array — getOpenQuestions is the API read), and the deprecated JS-off link-task fallbacks in the cart. Everything else answers in the two shapes of §2; the eight legacy shapes §1 records are what it migrated from. §8 is the ledger.

The E2E suite pins both sides: specs/api/*.characterization.spec.ts records what exists now so it can be changed safely, specs/api/*.contract.spec.ts asserts this document and is fixme-marked until each endpoint complies, and specs/backend/delete-task.spec.ts covers the migrated one.

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


1. Why — the eight shapes

Measured, with call sites. Every one of these is a JSON-ish response from a CBX endpoint today:

#ShapeWhere
1{success, feedback, errors[], validationIssues[], …customData merged at top level}ConfigboxJsonResponsedelete, copy, storeOrdering, addConfigurationToCart, getCalculationResult
2{success, errors[], messages[], wasInsert, data, redirectUrl}KenedoController::store() — hand-built
3{success, errors[]}KenedoController::ajaxDelete(), ajaxPublish() — hand-built, no feedback
4bare array, no envelopeconfiguratorpage::getMissingSelectionsProduct / …Page
5bespoke engine payload with DOM instructionsconfiguratorpage::makeSelection
6{success, errorMessage} gone — envelope / 401 problem since 2026-07-30, logoutUser followed 2026-07-31user::loginUser, user::logoutUser
7{success, error} (singular) gone — on the envelope since 2026-07-31controllers/testautomation.php
8HTTP redirect to an HTML page, no body — addProductToCart and setCartPositionQuantity answer the envelope now; remove/edit/copyCartPosition remain as deprecated JS-off fallbacks; checkout::* is the remaining tailcart::* link tasks, checkout::*

And one endpoint can be several of them at once: KenedoController::delete() answers with JSON, or a rendered HTML list (show_list=1), or a redirect to HTTP_REFERER (quickedit=1), depending on request parameters. A client cannot know which it will get without knowing the flags.

That is the whole problem: there is no shape a caller can rely on, so every consumer — the admin JS, the storefront JS, the MCP server, the test suite — hand-codes its own parsing per endpoint.


2. The two shapes, and the status that picks between them

The HTTP status carries the outcome. The body carries the detail. There is deliberately no success field.

A second copy of the outcome is a bug waiting to happen — the day a 200 carries success:false, which one is right? And practically: load balancers, APM, log aggregators, retry middleware and MCP clients all understand status codes and none of them understands success. An API that answers 200 to everything reports a 0% error rate while customers cannot check out.

Success — 2xx, Content-Type: application/json

{
"data": { "ids": [42], "deleted": 1 },
"meta": { "feedback": "Records deleted." }
}
KeyMeaning
dataThe payload. Always an object — never a bare array, never a scalar, so paging or an extra field never changes the shape.
metaOptional. About the response rather than the resource: paging counts, a translated feedback sentence, a deprecation notice. Never the outcome.

The one exception: standalone documents. GET /cb-api/v1/openapi.json and GET /cb-api/v1/schemas/{kind}/{entity}.json answer bare — the document at the root, no envelope, the schemas under their own application/schema+json. Their consumers are other people's tools (an OpenAPI importer, a $ref-dereferencing validator) which know the document's own root-level contract and not ours. Failures on those endpoints are problem+json like everywhere else. Nothing else qualifies: a payload read by clients of THIS API gets the envelope.

Failure — 4xx/5xx, Content-Type: application/problem+json

RFC 9457 Problem Details (which obsoleted RFC 7807), plus two extension members it explicitly permits:

{
"type": "https://www.configbox.at/api/errors/record-in-use",
"title": "Conflict",
"status": 409,
"code": "RECORD_IN_USE",
"detail": "Could not delete product list, because it contains products.",
"errors": [ { "code": "RECORD_IN_USE", "message": "…", "meta": { } } ],
"validationIssues": [ { "field": "title_en_GB", "code": "REQUIRED", "message": "…" } ]
}
KeyMeaning
typeURI identifying the problem type; dereferenceable documentation.
titleHuman summary of the type — stable across occurrences, so it is derived from the status.
statusMirrors the HTTP status, so the body survives being logged on its own.
codeStable machine code, SCREAMING_SNAKE, never translated. See below.
detailTranslated explanation of this occurrence. Free to change.
errorsEvery problem, each {code, message, meta?}. The first mirrors the top-level code/detail.
validationIssuesField-level problems, each {field, code, message}, for a form to highlight.

Stable codes are the point. errors: ["Cannot delete this record because it is linked with…"] is a translated sentence: an MCP agent, a headless storefront or a retry policy cannot branch on it, and it changes when someone edits a language string. A code can be branched on, and changing one is a breaking change. Messages are free to change; codes are not.

One parser, still. Status >= 400 → problem shape. That is how every HTTP client already works, so this is less client code than an always-200 envelope, not more.

The type that emits this

classes/ConfigboxApiResponse.phpok(), created(), badRequest(), forbidden(), notFound(), conflict(), validationFailed(), serverError(), then ->send().

ConfigboxJsonResponse is unchanged and stays supported. It is in heavy use in customizations and custom tasks, and its {success, feedback, errors, validationIssues, …} contract is frozen. The two coexist: an endpoint uses the new type once it has been migrated, and until then it does not.

ConfigboxApiResponse::getEnvelopeSchema() returns the JSON Schema (draft 2020-12) of both shapes as a oneOf. It is hand-written rather than generated from the code on purpose — it is the contract, and a schema derived from the implementation cannot catch the implementation drifting from it. Pass a per-endpoint payload schema to slot it into data.

Setting a status actually requires the platform

http_response_code() does not survive on Joomla: the host emits its own headers from the application object afterwards and overwrites it. This was not theoretical — controllers/testautomation.php called http_response_code(403) for years and clients received 200 text/html. Statuses therefore go through InterfaceKenedoPlatform::setResponseStatus(), which on Joomla sets the specially-handled status header on the application.

3. Transport

  • Endpoint: /cb-api/<controller>/<task> (SEF) or index.php?option=com_configbox&controller=<controller>&task=<task>&output_mode=view_only. Both already work; the system plugin maps output_mode=view_only to the host's raw document.
  • Content-Type: application/json on every response.
  • Parameters are ordinary GET/POST fields. Mutating tasks should be POST; the framework reads both through KRequest, so this is a convention clients can rely on rather than an enforcement.
  • Idempotency: reads are safe to repeat. Mutations are not, and the contract does not pretend otherwise — a client that needs at-most-once semantics must carry its own key.

4. Authentication and session

Two separate things, currently conflated.

Session (who the cart belongs to). CBX keeps a DB-backed session keyed by a cookie. A headless client that holds a cookie jar gets a working, persistent cart with no further work — verified: two requests sharing a jar share one cart and one guest user, while a fresh jar starts its own. A guest CBX user is created on first need. An explicit session token would be more idiomatic for an API, but it is a nicety, not a blocker, and is out of scope here.

Authentication (who the person is). user::loginUser must answer with the §2 envelope like everything else, data: {userId, …}, and must not redirect. return_success / return_failure redirect parameters stay supported for the classic form post, but must never apply when the request asks for the API — that is the same "one task, several response modes" defect as §6.

Admin endpoints keep the host's own permission model (isAuthorized()com_configbox.core.manage). The contract does not weaken it; it only fixes the shape of the answer, including the refusal.


5. Errors — which status

SituationStatuscode example
Did what was asked200 (201 if it created something)
A parameter is missing or unusable400NO_RECORDS_SELECTED
We do not know who you are401AUTHENTICATION_REQUIRED
We do, and you may not403NOT_PERMITTED
No such controller, task or record404NOT_FOUND
Well-formed, refused by the current state409RECORD_IN_USE
Well-formed, the data is not acceptable422VALIDATION_FAILED + validationIssues
We broke500INTERNAL_ERROR (log identifier in detail, never a stack trace)

The 409-vs-422 line is worth keeping straight: 422 means fix your input and retry; 409 means the input is fine, something else has to change first. "Cannot delete, other records reference it" is 409 — retrying it unchanged will fail identically until those references go.


5b. The details that bite later

Money is never a JSON number. "result": 149.7 is a float: 0.1 + 0.2 problems, and JavaScript parses every number as a double. CBX already learned this internally — the schema uses DECIMAL and the legacy FLOAT columns are on record as a mistake. The principle: money crosses the API as decimal strings plus an ISO 4217 code, never a float. The shipped concrete form is the runtime API's money object — nothing emits a bare {"amount", "currency"} pair:

"price": { "net": "100.00", "gross": "119.00", "tax": "19.00", "currency": "EUR", "formatted": "€ 119.00" }

Translations are flat keys, and no record key contains a dash. A record carries one key per translatable field per active language, underscore-separated: title_en_GB, title_de_DE — the same keys the model hydrates and the admin form inputs post (<input name="title_en_GB">). The API serves exactly that shape:

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

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

An earlier revision of this contract nested translations under their field ("title": { "en_GB": … }); that is gone — flat won, because one shape shared by the model, the admin forms and the API means nothing to project on the way out and nothing to unpack on the way in. Every key is a valid identifier: dot-accessible, destructurable, greppable. Sending the nested form now answers 422, naming the key to use instead: a shape that is silently ignored is a shape that comes back.

The language part is underscored — en_GB, never en-GB. KenedoLanguageHelper::getTagKey() is the one derivation that turns a BCP 47 tag into the key suffix, shared by the record keys, the admin form names and the API, so none of them can drift. The cost is a conversion for a client matching against Accept-Language (where the tag has a dash) — that conversion is the client's one-liner, and the write path accepts the same flat keys it serves, so round-tripping a record needs no reshaping at all.

This section used to argue the opposite, and was right when it was written: the keys were hyphenated until the 2026-07-27 conversion moved every one of them onto getTranslationKey(). The argument it made — that a BCP 47 tag should survive intact — lost to the fact that record.title-en-GB is a subtraction, not a property access. specs/backend/record-tasks.spec.ts pins the result.

Partial updates. store fills in what a request did not send from the record as it stands, so sending only the field you want changed no longer blanks the rest. A field sent empty still clears — that is how you clear one, and it is what a form post does.

Naming is camelCase at the boundary — on the runtime surface. The runtime endpoints hold to it (cartPositionId, openQuestions), even where a legacy parameter like prod_id is still accepted on the way in. The entity CRUD surface deliberately does not: its records carry the model's raw column keys (page_id, question_type), because those are the same keys the admin form posts, the generated schemas describe and the record stubs name — one shape shared end to end beats a translation layer that splits it in two.

Dates are ISO 8601 with an offset (2026-07-25T14:03:00Z). Never Unix ints, never local time. Some created columns are not even DATETIME; that must not reach the API. Status: holds on the runtime endpoints; the entity CRUD reads serve column values as the model hydrates them — the same raw-column deliberateness as the key naming above.

Nulls are present, not omitted. Always include the key and use null for "no value", so omission reliably means "this endpoint does not have that field".

Versioning: /cb-api/v1/… in the path, decided now even though the answer is "v1 for the foreseeable future". Retrofitting a version is far worse than carrying an unused one.

Bulk operations are all-or-nothing. delete takes ids plural, and KenedoModel::delete() pre-checks every id and refuses the whole batch if any is blocked. That is a fine answer — but it is now a documented decision, because the alternative (per-id results) is a different response shape and is not something to discover later.


6. Admin CRUD

The rule: a mutation returns the result of the mutation, and nothing else. It never renders a list, never redirects, never emits HTML. A UI that wants a refreshed list makes a second call.

That is the change with the widest blast radius, and it is deliberate: it decouples "what happened" from "what to show next", which is what makes the same endpoints usable by the admin UI, the MCP server and a third-party client without special-casing.

TaskStatusdata payload
store (create)201{id, wasInsert: true, record, redirectUrl?}
store (update)200{id, wasInsert: false, record, redirectUrl?}
delete (ajaxDelete is an alias)200{ids, deleted}
publish / unpublish (ajaxPublish is an alias)200{ids, published}
copy201{ids, newId, redirectUrl}
storeOrdering200{ordered}
getRecords200{items, total, offset, limit}total is the count BEFORE paging
getRecord200{record} · 400 no id / unknown language · 404 no such record

redirectUrl is advisory: where a client may want to go next. The server does not redirect.

adminproducts only — the cascading delete

Two tasks that exist on the products controller and nowhere else. delete above is unchanged and still refuses a product that has pages or detail panes, which is what the REST controller, the MCP tools and the CLI rely on; these are the deliberate way through, and the admin reaches them only from a dialog that made the operator type a confirmation word.

TaskStatusdata payload
deleteCascadePreview200{products: [{id, title}], counts: {calculations, answers, questions, pages, detailPanes, examples, files}, blockers: string[], dialog: {…}} · 404 no such product
deleteCascade200{deleted: {calculations, answers, questions, pages, detailPanes, examples, products}, products: int[]} · 404 no such product · 409 RECORD_IN_USE

Both keyed by ENTITY, never by model class name — counts and deleted use the same words so the preview and the result describe the same thing. Both are generated from one level list (ConfigboxProductDeleteHelper::getLevels()), so they cannot drift apart; counts omits products because it answers "what goes WITH the product you picked".

The keys above are the stock graph, not a closed set. The cascade reads what belongs to a product off the models themselves, so an install whose customization adds a child list to the product form gets one key more, named after that model (LEVEL_KEYS pins the stock names; anything else is derived from the model class). Read the keys you know and ignore the rest. A customization whose records should not be taken by a product delete says so with 'deleteWithParent' => false on that property.

blockers is what stops the delete: a record outside the plan that still needs one inside it. It is computed for the preview and re-checked by deleteCascade before it opens its transaction, so a client may show it but must not rely on having asked. A non-empty blockers means deleteCascade would answer 409.

dialog is display copy composed server-side, where the translations are — title, lead, item list, prompt, confirmation word and button labels. A client renders it and holds no wording of its own.

counts.files is how many uploaded images and files go with the records — asked of the properties that own them, removing nothing. It is worth showing separately: rows can be brought back from a database backup, an uploaded image cannot.

Deleting is transactional over rows and files. A property reports the files it owns rather than unlinking them (KenedoProperty::getDeletionFilePaths), KenedoModel::delete() queues them, and KenedoDatabase::deferUntilCommit() removes them only when the OUTERMOST transaction commits — so a refusal at any level leaves the install exactly as it was, on disk as much as in the tables. That applies to every KenedoModel delete, not only this one.

Retired, with their replacements:

  • show_list=1 (render the list instead of answering) → the caller issues a second, explicit list request. The admin JS becomes: mutate → check the status → reload list. Gone from delete and copy; it never had any senders.
  • publish calling $this->display() → same. It also never checked whether the publish had worked.
  • quickedit=1 (redirect to HTTP_REFERER) → same; the caller decides where to go.
  • redirectUrl in a store response → moves into data, and is advisory. The server does not redirect; the client may.

7. Headless configurator

The end-user flow must be completable with an HTTP client and a cookie jar, with no HTML parsing. This is done. The tasks below are live, answer with the envelope, and never redirect; the journey is asserted end to end by tests/specs/api/headless-configurator.journey.spec.ts.

StepEndpointdata payload
Start a configurationconfiguratorpage/startConfiguration (prod_id){cartPositionId, productId, cartId, firstPageId, product, pages[], openQuestions[], complete}201
Read the structureconfiguratorpage/getStructure (prod_id){product, pages[{id, title, questions[{…, answers[]}]}]}
Read live questionsconfiguratorpage/getQuestions (cartPositionId){questions[], count} — with applies, selection, outputValue
Why can't I have thisconfiguratorpage/explainQuestion (questionId){applies, rule{conditions[], blockers[]}, answers[], blockedBy[]}
What would this doconfiguratorpage/previewSelection (questionId, selection){committed:false, wouldBeAccepted, effects{}, openQuestions{before,after}, price{before,after}}
What gates whatconfiguratorpage/getDependencies (prod_id){questions[{dependsOn[], affects[]}], roots[], decisionOrder[], cycles[]}
What is still openconfiguratorpage/getOpenQuestions (cartPositionId){openQuestions[], count, complete}
Where things standconfiguratorpage/getConfiguration (cartPositionId){selections[], openQuestions[], complete, price{}, quantity, finished}
Chooseconfiguratorpage/setSelection (questionId, selection){accepted, question{}, previous{}, autoChanges{}, openQuestions[], price{}}
Finish the configurationconfiguratorpage/finishConfiguration (cartPositionId){cartPositionId, finished, cart{}, cartUrl}
Read the cartcart/getCart{cartId, positions[], itemCount, totals{}}
Change quantitycart/setCartPositionQuantity{cartPositionId, quantity, position{}, cart{}}
Copy / reopen / removecart/copyPosition, cart/editPosition, cart/removePosition{cartPositionId, …, configuration{}} / {…, cart{}}

Every projection is built by ConfigboxConfiguratorApiHelper, so an in-process caller — the MCP server, the CLI — gets the same shapes without going through HTTP.

Finding a configuration, not just reporting one

The reads above describe state. Three more exist because state is not enough in a product with real rules: a client that is told applies:false knows it cannot have something, and nothing about what to do instead. Searching a configuration space by trial and error against setSelection does not work either, because rules clear other selections as a side effect and changing your mind does not bring them back.

  • explainQuestion takes the governing rule apart. Each condition is reported with its subject question, operator, expected value and current value, and whether it currently holds; the failing ones are flattened into blockedBy. That turns "unavailable" into "select Option A for Colour". Answers are explained the same way, because a question can apply while the answer you want does not — the case that most often needs explaining.

    Per-condition verdicts come from each condition type; the overall verdict comes from the engine, so the explanation can never disagree with the store about bracket and combinator precedence.

  • previewSelection answers "what would happen" and changes nothing. It reports which questions would appear or disappear, which selections the rules would clear or swap, what would still be open and what it would cost — before and after, side by side. Built on the engine's own simulated selections, which are memory-only for the request (storeSelectionsInSession() writes getSelections(false), which excludes them), and cleared in a finally so a preview cannot poison later reads in the same request even on the way out of an exception.

    A value the store would refuse comes back wouldBeAccepted:false with the reason as a 200: "that would not be accepted" is the correct answer to "what would happen", not a failure.

  • getDependencies hands over the influence graph instead of making a client rediscover it. decisionOrder is a topological sort — decide in that order and nothing you choose is invalidated by something you choose later. Rules that reference each other cannot be ordered and are reported in cycles rather than jammed into the sequence.

Two smaller things in the same spirit:

  • An answer carries selectable, not only applies. Availability is a separate axis: an answer can pass every rule and still be blocked because the store flagged it unavailable (available = 0 with disable_non_available). A client reading only applies picks it and fails.
  • explainQuestion exposes the store's rule logic to anyone with a cookie jar. Deliberate: it is business configuration, not customer data, and the storefront already reveals all of it through behaviour — a client can map the same rules by trial and error, just slowly.

Decisions worth knowing

  • New task names, not converted ones. cart::addProductToCart, removeCartPosition, editCartPosition and copyCartPosition are reached as plain links from the storefront templates; their job is to change something and put the customer on the next page. Converting them means reworking the cart and product templates, which is its own pass. The tasks above do the same mutations through the same model methods and answer with data. setCartPositionQuantity was the exception and was migrated in place: it was already XHR-only with a single caller.

    A parallel surface needs the same guards, and did not have them. The two flows drifted: the runtime tasks checked ownership and editOrder, while copyCartPosition checked neither (a guessed id wrote an unfinished line into a stranger's cart) and the other two links checked only ownership, so an ordered line could be reopened or removed through a URL the template no longer renders. Both flows now ask both questions. If you add a task on one side, add the guards on the other — the deprecated flow is the one nobody looks at, which is exactly why it drifts.

  • makeSelection stays as it is; setSelection is the projection. The raw payload is a set of instructions for the configurator's DOM. setSelection runs the same engine and reports the outcome: what is selected now, what the rules moved as a consequence (autoChanges), what it costs, what is still open.

  • autoChanges is not optional reading. Choosing one thing can clear or replace another. A client that assumes only the question it named changed will report a configuration the store does not have.

  • Confirmation is a 200, not an error. When a change would undo selections the customer already made, the answer is {accepted:false, needsConfirmation:true, confirmationText, affects[]} and nothing has changed. Repeat with confirmed=1. The request was fine and the server did the right thing — that is not a failure status.

  • 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. The runtime tasks read languageTag (which makeSelection already used) and keep language as a fallback. The admin surface keeps language: it runs under /administrator, where the filter is not in play.

  • Single-language strings. Unlike the authoring surface (§6), a runtime response resolves every translatable string to one language. A configurator client shows the customer one language, and loading every translation of every answer of every question would cost real queries for data nobody reads.

  • Money is {net, gross, tax, currency, formatted} with decimal strings. A JSON number is a double and a double cannot hold 0.1; a client adding up line items in floats produces totals that are a cent off and cannot say why. formatted is display sugar in the store's locale — never parse it. The amounts do not change with the language; only formatted does.

  • A foreign cart position is a 404, not a 403. "It exists but is not yours" is a way to count the store's carts.

  • The cart total is the items total. Deliberately no grand total: delivery and payment are only priced once the customer picks them in checkout, and a "total" that silently excludes shipping is worse than none.

  • A cart line lists only the selections marked "show in overview". That mirrors what the cart page shows and is what the setting is for, but it means getCart is a summary: for the complete configuration of a line, call getConfiguration with its cartPositionId.

Found on the way: the engine does not validate answer ids

ConfigboxQuestion::isValidValue() checks bounds, input restrictions and upload rules, but it never checks that a selection is one of the question's own published answers. makeSelection will therefore store the id of an answer belonging to a different question, or one that does not exist at all, leaving a cart position holding a selection that renders as a raw number and prices at zero. The storefront never sends such a value, which is why it has gone unnoticed.

There is a second, worse version of the same gap one level up: nothing checked that the question belongs to the product being configured. Sending a question from a different product together with one of its own real answer ids satisfied every existing check — the engine recorded the selection, ConfigboxConfiguration silently dropped it again on reload (it filters selections to the product's own questions), and the caller received 200 accepted:true with the other product's label echoed back. A success reported for a change that did not happen is worse than an error, because nothing looks wrong.

setSelection refuses both: 422 UNKNOWN_ANSWER (see ConfigboxConfiguratorApiHelper::isSelectableValue()) and 422 QUESTION_NOT_IN_PRODUCT (see questionBelongsToProduct()). Two more refusals sit beside them for values the engine would take and then betray: 422 ANSWER_NOT_SELECTABLE for an answer that exists but is currently disabled by a rule or flagged unavailable (the engine records it and the next rules pass quietly clears it), and 422 VALUE_OUT_OF_BOUNDS for a value outside the question's own advertised constraints (which the engine would store and price — refusing it is what makes getStructure's bounds mean something). getStructure likewise refuses a prod_id and cartPositionId that disagree, which would have built one product's tree against another's configuration.

These guard the new boundary without touching the shared engine path the live configurator runs on — tightening isValidValue() is a change that deserves its own pass with the storefront's tests green. Recorded here so it is not mistaken for a fixed problem: makeSelection still accepts both.

Still not covered

Checkout. cart/checkoutCart and the checkout steps still render pages, so the flow above stops at the cart. That is the next piece, and the larger one — it drags in addresses, delivery, payment methods and the PSP bridge.


8. What has to change

The implementation checklist. Each row has a contract spec that is fixme until it lands.

#ChangeWhere
1Add data to the envelope DONE, differentlyConfigboxJsonResponse is frozen and untouched; the new ConfigboxApiResponse carries data/meta and problem detailsclasses/ConfigboxApiResponse.php
2store() emits the envelope DONE201 on insert / 200 on update with data:{id, wasInsert, record, redirectUrl}, 422 VALIDATION_FAILED, 409 STORE_FAILED, 500 INTERNAL_ERROR when the model fails without saying whyKenedoController::store()
3ajaxPublish emits the envelope DONE — folded into publish as a deprecated alias, exactly like ajaxDelete into deleteKenedoController
4delete() drops show_list and quickedit; always JSON DONE, and migrated to status codes: 200 + data.ids, 400 NO_RECORDS_SELECTED, 409 RECORD_IN_USEajaxDelete folded in as an alias; the four quick-edit buttons now call the endpoint and reload; adminorders::remove() likewiseKenedoController::delete()
5copy, storeOrdering payloads move into data DONEcopy 201 + data:{ids, newId, redirectUrl}; storeOrdering 200 + data:{ordered}, 400 INVALID_ORDERING for unreadable input, 500 ORDERING_FAILED instead of an uncaught DB exception on an unorderable modelKenedoController
6Admin JS: mutate → check → reload list DONE for the migrated tasks — the form handler, the list handler, the publish toggle (which used to ask publish to render the list) and productTree.js all read the STATUS nowassets/kenedo/kenedo.js, assets/javascript/productTree.js
7loginUser emits the envelope; no redirect in API mode DONE200 + data:{loggedIn, userId} / 401 LOGIN_FAILED; the login form's return_success/return_failure are navigated to by the client. logoutUser answers data:{loggedOut}controllers/user.php
8getMissingSelections* return {missing:[…]}Left as the browser's bare array; getOpenQuestions is the API read and says why each one is open
9makeSelection gains a stable projection DONE, alongsidesetSelection runs the same engine and reports the outcome (autoChanges, price, openQuestions); makeSelection is untouchedcontrollers/configuratorpage.php
10Cart tasks answer with the envelope DONE via new tasksremovePosition, editPosition, copyPosition and getCart; setCartPositionQuantity migrated in place. The four link tasks the templates use still redirectcontrollers/cart.php
11New configuratorpage/getStructure DONE, plus startConfiguration, getQuestions, getOpenQuestions, getConfiguration, setSelection, finishConfigurationcontrollers/configuratorpage.php, helpers/configuratorapi.php
12New cart/getCart DONEcontrollers/cart.php
13testautomation adopts the envelope DONEsendJson() emits the envelope (the legacy success key is dropped at the seam), failures use problem+json with real statuses (403 guard, 400 missing/invalid parameters, 404 unknown records, 500 with the message), and presence reads answer 200 with a null field rather than an error. The suite's ApiClient branches on the status, once, in call()controllers/testautomation.php, tests/support/api-client.ts
14MCP tools return the envelope's data unchanged DONE, via the shared layer — since ConfigboxApiRecord::project() moved into ConfigboxEntityApiHelper, the service's return value IS the wire shape, and the MCP tools hand it over untouched (flattenForTool only translates the typed exceptions into prose, which is MCP's own boundary). entity-api.spec.ts's "same input through HTTP and through MCP" pins ithelpers/mcp.php, helpers/entityapi.php
15setResponseStatus() platform seamall five platforms DONE
16Remove success from the remaining frontend JS call sitesper endpoint, as each migrates
17Custom admin endpoints: currencies, dashboard, config, calc matrices, charset fixer DONEcontrollers/admin*.php
18Read tasks getRecord/getRecords with dash-free records DONEKenedoController, classes/ConfigboxApiRecord.php
19store supports partial updates DONEKenedoController::store()
20Frontend quick-edit goes through the configbox/server choke point DONEhelpers/quickedit.php
21A surface a program can actually call DONE — the REST entity API at /cb-api/v1/: resource URLs, HTTP verbs, RFC 6750 bearer tokens, OpenAPI 3.1 over the committed schemas. The task endpoints stay as they are; this sits beside them on the same pipelinecontrollers/apiv1.php, helpers/entityapi.php, helpers/apitokens.php, helpers/openapi.php

Still muddy — what is left

Most of the list is done. What remains, and why:

TaskState
adminorders::update_status / release_invoice / insert_invoiceDONE — answer with data; the order screen's JS calls, reads and then decides
adminorders::cancelLeft. It is navigation-only, not a mutation, so it is not ambiguous — just a server round-trip for something the client could do itself.
adminlicense::storeLicenseKeyDONE — stores and says so; adminLicense.js navigates
adminpostinstall::* (5 tasks)DONE — envelope + real validationIssues; postinstall.js on the choke point
adminmvcmaker::createMvcCodeDONE — was plain text
copy()'s show_list branchDONE — removed with delete()'s
user::loginUserDONE — one answer for every caller; the login form's return_success/return_failure are navigated to by the client
user::storeDONE (2026-07-31) — the base class helpers answer for it: 200 + data:{id} with meta.feedback, 422 with validationIssues (the model's field-level issues ride the exception), 500 STORE_FAILED. user.js saves over the choke point; customerform.js's displayValidationIssues() accepts both field (problem shape) and fieldName (model shape), so customization callers keep working.
cart tasksDONE, additively — §7's flow is complete through the cart. setCartPositionQuantity was migrated in place; removePosition / editPosition / copyPosition / getCart are new envelope tasks beside the template's link tasks, which still redirect.
the four cart LINK tasksDONE (2026-07-31) — addProductToCart answers the envelope in place (201 + data:{cartPositionId, complete, redirectUrl, cart?|missingSelections?}); the stock links are intercepted by configbox/cart::initLinkTasks (matched by task name in the href, so customization templates keep working) which calls the envelope tasks and navigates on the data. editPosition/copyPosition carry a routed configuratorUrl as data for exactly that. The three legacy redirect tasks stay reachable for JS-off browsers, deprecated and logged. No template changed — the interception made that unnecessary.
checkout tasksLeft — now the biggest piece. checkoutCart and the checkout steps render pages. Drags in addresses, delivery, payment methods and the PSP bridge.

Two component-level authorization gaps found and fixed in the same pass: admindashboard::removeFileStructureWarning and adminconfig::renewWordpressPages never called isAuthorized(). Joomla's admin area still gated them — an anonymous request gets a 403 from the host — so this was not an open door, but it meant any logged-in backend user could call them regardless of their CBX permissions. KenedoController::execute() has no global gate; every task authorizes itself, so a missing call is silent.


9. Rollout

The frontend and the admin UI both consume today's shapes, so this cannot land as one commit.

  1. Characterization tests first — pin today's behaviour so it can be changed knowingly.
  2. Envelope gains data → a separate type (ConfigboxApiResponse), because ConfigboxJsonResponse is frozen for customizations. Additive by construction: nothing that uses the old type changes.
  3. Endpoint by endpoint, each with its contract spec flipping from fixme to green, and its consumer updated in the same commit.
  4. The HTML/redirect modes go last, once no consumer depends on them — they are the only genuinely breaking removals.

A response may carry both the new data and the old top-level keys during the transition; the duplicates come out when step 4 does.


10. See also

  • docs/technical/com_configbox_runtime_api.md — §7 written for someone building a client: the configurator and cart flow, worked through call by call.
  • docs/technical/com_configbox_entity_api.md — the REST entity API at /cb-api/v1/, and the one pipeline it shares with the MCP server and the CLI.
  • docs/technical/com_configbox_mcp_server.md — the MCP server, the first consumer written against this contract.
  • docs/technical/com_configbox_kenedo_controller.md — how a task ends, and the raw output mode that makes JSON responses possible.
  • docs/platform/joomla/com_configbox_sef_urls.md — how /cb-api/ routes.
  • tests/docs/SPECS.md — where the characterization and contract specs live.