External API contract
- Version
- 3.x
- Updated
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 —getOpenQuestionsis 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.tsrecords what exists now so it can be changed safely,specs/api/*.contract.spec.tsasserts this document and isfixme-marked until each endpoint complies, andspecs/backend/delete-task.spec.tscovers 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:
| # | Shape | Where |
|---|---|---|
| 1 | {success, feedback, errors[], validationIssues[], …customData merged at top level} | ConfigboxJsonResponse — delete, 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 |
| 4 | bare array, no envelope | configuratorpage::getMissingSelectionsProduct / …Page |
| 5 | bespoke engine payload with DOM instructions | configuratorpage::makeSelection |
| 6 | {success, errorMessage}logoutUser followed 2026-07-31 | user::loginUser, user::logoutUser |
| 7 | {success, error} (singular) | controllers/testautomation.php |
| 8 | HTTP 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 tail | cart::* 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." }
}
| Key | Meaning |
|---|---|
data | The payload. Always an object — never a bare array, never a scalar, so paging or an extra field never changes the shape. |
meta | Optional. 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.jsonandGET /cb-api/v1/schemas/{kind}/{entity}.jsonanswer bare — the document at the root, no envelope, the schemas under their ownapplication/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": "…" } ]
}
| Key | Meaning |
|---|---|
type | URI identifying the problem type; dereferenceable documentation. |
title | Human summary of the type — stable across occurrences, so it is derived from the status. |
status | Mirrors the HTTP status, so the body survives being logged on its own. |
code | Stable machine code, SCREAMING_SNAKE, never translated. See below. |
detail | Translated explanation of this occurrence. Free to change. |
errors | Every problem, each {code, message, meta?}. The first mirrors the top-level code/detail. |
validationIssues | Field-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.php — ok(), 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) orindex.php?option=com_configbox&controller=<controller>&task=<task>&output_mode=view_only. Both already work; the system plugin mapsoutput_mode=view_onlyto the host's raw document. Content-Type: application/jsonon 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
| Situation | Status | code example |
|---|---|---|
| Did what was asked | 200 (201 if it created something) | — |
| A parameter is missing or unusable | 400 | NO_RECORDS_SELECTED |
| We do not know who you are | 401 | AUTHENTICATION_REQUIRED |
| We do, and you may not | 403 | NOT_PERMITTED |
| No such controller, task or record | 404 | NOT_FOUND |
| Well-formed, refused by the current state | 409 | RECORD_IN_USE |
| Well-formed, the data is not acceptable | 422 | VALIDATION_FAILED + validationIssues |
| We broke | 500 | INTERNAL_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 thatrecord.title-en-GBis a subtraction, not a property access.specs/backend/record-tasks.spec.tspins 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.
| Task | Status | data 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} |
copy | 201 | {ids, newId, redirectUrl} |
storeOrdering | 200 | {ordered} |
getRecords | 200 | {items, total, offset, limit} — total is the count BEFORE paging |
getRecord | 200 | {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.
| Task | Status | data payload |
|---|---|---|
deleteCascadePreview | 200 | {products: [{id, title}], counts: {calculations, answers, questions, pages, detailPanes, examples, files}, blockers: string[], dialog: {…}} · 404 no such product |
deleteCascade | 200 | {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 fromdeleteandcopy; it never had any senders.publishcalling$this->display()→ same. It also never checked whether the publish had worked.quickedit=1(redirect toHTTP_REFERER) → same; the caller decides where to go.redirectUrlin a store response → moves intodata, 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.
| Step | Endpoint | data payload |
|---|---|---|
| Start a configuration | configuratorpage/startConfiguration (prod_id) | {cartPositionId, productId, cartId, firstPageId, product, pages[], openQuestions[], complete} — 201 |
| Read the structure | configuratorpage/getStructure (prod_id) | {product, pages[{id, title, questions[{…, answers[]}]}]} |
| Read live questions | configuratorpage/getQuestions (cartPositionId) | {questions[], count} — with applies, selection, outputValue |
| Why can't I have this | configuratorpage/explainQuestion (questionId) | {applies, rule{conditions[], blockers[]}, answers[], blockedBy[]} |
| What would this do | configuratorpage/previewSelection (questionId, selection) | {committed:false, wouldBeAccepted, effects{}, openQuestions{before,after}, price{before,after}} |
| What gates what | configuratorpage/getDependencies (prod_id) | {questions[{dependsOn[], affects[]}], roots[], decisionOrder[], cycles[]} |
| What is still open | configuratorpage/getOpenQuestions (cartPositionId) | {openQuestions[], count, complete} |
| Where things stand | configuratorpage/getConfiguration (cartPositionId) | {selections[], openQuestions[], complete, price{}, quantity, finished} |
| Choose | configuratorpage/setSelection (questionId, selection) | {accepted, question{}, previous{}, autoChanges{}, openQuestions[], price{}} |
| Finish the configuration | configuratorpage/finishConfiguration (cartPositionId) | {cartPositionId, finished, cart{}, cartUrl} |
| Read the cart | cart/getCart | {cartId, positions[], itemCount, totals{}} |
| Change quantity | cart/setCartPositionQuantity | {cartPositionId, quantity, position{}, cart{}} |
| Copy / reopen / remove | cart/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.
-
explainQuestiontakes 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 intoblockedBy. 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.
-
previewSelectionanswers "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 —beforeandafter, side by side. Built on the engine's own simulated selections, which are memory-only for the request (storeSelectionsInSession()writesgetSelections(false), which excludes them), and cleared in afinallyso 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:falsewith the reason as a 200: "that would not be accepted" is the correct answer to "what would happen", not a failure. -
getDependencieshands over the influence graph instead of making a client rediscover it.decisionOrderis 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 incyclesrather than jammed into the sequence.
Two smaller things in the same spirit:
- An answer carries
selectable, not onlyapplies. Availability is a separate axis: an answer can pass every rule and still be blocked because the store flagged it unavailable (available= 0 withdisable_non_available). A client reading onlyappliespicks it and fails. explainQuestionexposes 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,editCartPositionandcopyCartPositionare 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.setCartPositionQuantitywas 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, whilecopyCartPositionchecked 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. -
makeSelectionstays as it is;setSelectionis the projection. The raw payload is a set of instructions for the configurator's DOM.setSelectionruns 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. -
autoChangesis 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 withconfirmed=1. The request was fine and the server did the right thing — that is not a failure status. -
languageTag, notlanguage. On the site application Joomla's language filter consumes a request variable calledlanguagebefore the component sees it, solanguage=de-DEsilently yielded the default language. The runtime tasks readlanguageTag(whichmakeSelectionalready used) and keeplanguageas a fallback. The admin surface keepslanguage: 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.formattedis display sugar in the store's locale — never parse it. The amounts do not change with the language; onlyformatteddoes. -
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
getCartis a summary: for the complete configuration of a line, callgetConfigurationwith itscartPositionId.
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.
| # | Change | Where |
|---|---|---|
| 1 | data to the envelopeConfigboxJsonResponse is frozen and untouched; the new ConfigboxApiResponse carries data/meta and problem details | classes/ConfigboxApiResponse.php |
| 2 | store() emits the envelope201 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 why | KenedoController::store() |
| 3 | ajaxPublish emits the envelopepublish as a deprecated alias, exactly like ajaxDelete into delete | KenedoController |
| 4 | delete() drops show_list and quickedit; always JSON200 + data.ids, 400 NO_RECORDS_SELECTED, 409 RECORD_IN_USE — ajaxDelete folded in as an alias; the four quick-edit buttons now call the endpoint and reload; adminorders::remove() likewise | KenedoController::delete() |
| 5 | copy, storeOrdering payloads move into datacopy 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 model | KenedoController |
| 6 | publish to render the list) and productTree.js all read the STATUS now | assets/kenedo/kenedo.js, assets/javascript/productTree.js |
| 7 | loginUser emits the envelope; no redirect in API mode200 + 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 |
| 8 | getMissingSelections* return {missing:[…]} | Left as the browser's bare array; getOpenQuestions is the API read and says why each one is open |
| 9 | makeSelection gains a stable projectionsetSelection runs the same engine and reports the outcome (autoChanges, price, openQuestions); makeSelection is untouched | controllers/configuratorpage.php |
| 10 | removePosition, editPosition, copyPosition and getCart; setCartPositionQuantity migrated in place. The four link tasks the templates use still redirect | controllers/cart.php |
| 11 | configuratorpage/getStructurestartConfiguration, getQuestions, getOpenQuestions, getConfiguration, setSelection, finishConfiguration | controllers/configuratorpage.php, helpers/configuratorapi.php |
| 12 | cart/getCart | controllers/cart.php |
| 13 | testautomation adopts the envelopesendJson() 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 |
| 14 | data unchangedConfigboxApiRecord::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 it | helpers/mcp.php, helpers/entityapi.php |
| 15 | setResponseStatus() platform seam | |
| 16 | Remove success from the remaining frontend JS call sites | per endpoint, as each migrates |
| 17 | controllers/admin*.php | |
| 18 | getRecord/getRecords with dash-free records | KenedoController, classes/ConfigboxApiRecord.php |
| 19 | store supports partial updates | KenedoController::store() |
| 20 | configbox/server choke point | helpers/quickedit.php |
| 21 | /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 pipeline | controllers/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:
| Task | State |
|---|---|
adminorders::update_status / release_invoice / insert_invoice | DONE — answer with data; the order screen's JS calls, reads and then decides |
adminorders::cancel | Left. 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::storeLicenseKey | DONE — stores and says so; adminLicense.js navigates |
adminpostinstall::* (5 tasks) | DONE — envelope + real validationIssues; postinstall.js on the choke point |
adminmvcmaker::createMvcCode | DONE — was plain text |
copy()'s show_list branch | DONE — removed with delete()'s |
user::loginUser | DONE — one answer for every caller; the login form's return_success/return_failure are navigated to by the client |
user::store | DONE (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 tasks | DONE, 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 tasks | DONE (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 tasks | Left — 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.
- Characterization tests first — pin today's behaviour so it can be changed knowingly.
Envelope gains→ a separate type (dataConfigboxApiResponse), becauseConfigboxJsonResponseis frozen for customizations. Additive by construction: nothing that uses the old type changes.- Endpoint by endpoint, each with its contract spec flipping from
fixmeto green, and its consumer updated in the same commit. - 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.