Appearance
<div style="display: none;" hidden="true" aria-hidden="true">Are you an LLM? You can read better optimized documentation at /changelog/Changelog.4.121.md for this page in Markdown format</div>
Version 4
version 4.121
[4.121.0] feat(admin/payments): narrow the external-frontend payway panels to a curated, verified pool (Advisable-com/ecommercen#679)
- #672's two external-frontend panels (Settings → Payment settings → "External frontend payment methods" / "External frontend gift card payment methods") offered the merchant every payway
allPayWays()knows — 20 keys — because that panel's own acceptance criterion required matching the legacy storefront panel key-for-key. Only a subset has actually been driven end-to-end through an external frontend (nuxt / velora / mobile) with real credentials; #673 faithfully advertises whatever is selected overGET /rest/checkout/payment-methodsand accepts it atplace-order, so an unverified selection is not harmless. - New helper
getExternalPayWays()(ecommercen/helpers/eshop_helper.php), alongsidegetCardPayWays()/getGiftCardPayWays(). It is a capability list:delivery,bank_transfer,paid_at_store,vivawallet— payways verified end-to-end on an external frontend, not merely payways with credentials configured or an adapter registered.piraeusis deliberately absent (see Notes). - The external payments panel's available side is now
array_intersect_key(allPayWays(), array_flip(getExternalPayWays())), mirroring the shapegetGiftCardPayWays()already uses. It also no longer unions ingetVivaEnabledPaymentMethods()— the viva sub-method keys (vivawallet_credit_card,vivawallet_ideal, …) never resolved to an adapter on an external channel in the first place, sinceVivaWalletAdapter::getKey()andPaymentInitializer::getRegisteredPaymentMethods()only ever map the parentvivawalletkey. - The external gift-card panel's available side is driven by a second, dedicated helper,
getExternalGiftCardPayWays()(also ineshop_helper.php), returning['vivawallet']:array_intersect_key(allPayWays(), array_flip(getExternalGiftCardPayWays())). It is not derived fromgetExternalPayWays()— buying a gift card from an external frontend is a separate verification from external checkout, with different POS terminals and different code paths (PIRAEUSBANK_GIFTCARDS_EXTERNALvs.PIRAEUSBANK_EXTERNAL; viva's own separategiftCardSourceCodevs. its checkout flow). Intersecting the two lists would assert that verifying checkout also verifies gift cards, which does not hold. This also mirrors legacy, wheregetGiftCardPayWays()has always been independent of the payments roster rather than derived from it. The codebase now hand-maintains five payway lists:getExternalPayWays(),getExternalGiftCardPayWays(),getCardPayWays(),getGiftCardPayWays(), andallPayWays()itself. Adv_settings::payment_settings()now filters two of the page's five payway selections against their respective pools on save:PAYWAY_EXTERNALagainstgetExternalPayWays(), andPAYWAY_GIFT_CARDS_EXTERNALagainstgetExternalGiftCardPayWays()(samearray_values(array_intersect($this->input->post(...) ?? [], …))shape for each). Three remain unfiltered:PAYWAY,PAYWAY_ADMIN,PAYWAY_GIFT_CARDS. See Notes for why the gift-card leg is filtered even though nothing gates order placement on it.- A drift guard (
tests/Unit/Helpers/PayWayDebrisCoverageTest.php) asserts every pool entry is a realallPayWays()key. It cannot and does not assert completeness — an absence from the pool is a deliberate human decision (verification status), not something the codebase can derive; see the design rationale indocs/decisions/679-external-payway-pool.md.
- #672's two external-frontend panels (Settings → Payment settings → "External frontend payment methods" / "External frontend gift card payment methods") offered the merchant every payway
[4.121.0] feat(payments/piraeus): add per-channel Piraeus POS terminal credentials for the external/mobile frontends (Advisable-com/ecommercen#674)
- Why. Piraeus issues a separate POS terminal per channel — one set of credentials cannot serve all of them. Two credential groups already existed, fully independent of each other:
PIRAEUSBANK(legacy storefront checkout) andPIRAEUSBANK_GIFTCARDS(legacy gift cards). This adds the two external/mobile channels. - New credential groups.
PIRAEUSBANK_EXTERNALandPIRAEUSBANK_GIFTCARDS_EXTERNAL, each read by its own helper (getPiraeusExternalBankSettings(),getPiraeusGiftCardExternalBankSettings()) over the identical 13-key field set the existing readers use — includingINSTALLMENTS, so installments resolve per channel. Nothing is inherited from the group above it; every value comes from its own group, matching the existing invariant for the checkout/gift-card pair. - Admin UI. Two new credential blocks in Settings → Payment settings → Piraeus Bank: "External / mobile credentials" (rendered unconditionally, mirroring the checkout block) and "External / mobile Gift Card credentials" (rendered only when gift cards are enabled, mirroring its sibling block).
- The real defect this fixes.
PaymentInitializerFactory::registerPiraeus()previously read the legacy checkout group (PIRAEUSBANK), so the modern/REST checkout path (Checkout\PlaceOrderService,Rest\Checkout\Controllers\Checkout) was silently transacting on the legacy web POS terminal instead of its own. It now readsPIRAEUSBANK_EXTERNAL. No channel detection was needed or added — the factory's only two consumers are both REST, and the rendered storefront calls the legacy reader directly, so the modern factory is the external channel. - Webhook.
POST /rest/webhooks/piraeuskeeps its route, its handler and every response shape — this is a behaviour change, not a new endpoint. It now validates the callback HMAC against each configured Piraeus POS terminal in turn (PIRAEUSBANK, thenPIRAEUSBANK_EXTERNAL), accepting on the first match, because the callback payload carries no POS identifier and a payment taken on the external POS would otherwise fail validation against the checkout POS's credentials. - Commits:
4cc665b3b(registry groups + helpers + admin save/render legs),7cc1ffc66(src/**— factory + webhook),33a33bbf0(admin view credential blocks).
- Why. Piraeus issues a separate POS terminal per channel — one set of credentials cannot serve all of them. Two credential groups already existed, fully independent of each other:
[4.121.0] feat(rest/checkout): honour
METHODS.PAYWAY_EXTERNAL, fail closed when unset (Advisable-com/ecommercen#673)GET /rest/checkout/payment-methodsnow reports merchant INTENT, not configured credentials. It intersects the registered payment adapters (PaymentInitializerFactory) with theMETHODS.PAYWAY_EXTERNALregistry key #672 added, and returns them in the merchant's saved order — the admin panel's order — rather than the factory's registration order. The offline adapters (delivery,bank_transfer,paid_at_store) are filtered like any other payway, not special-cased.- Fail-closed when unset. An unset or empty
METHODS.PAYWAY_EXTERNALyields[]— no payment methods — and refuses every placement. It does not inheritMETHODS.PAYWAY(the rendered storefront's list) and does not mean "no filtering". Ratified on epic #671. POST /rest/checkout/place-ordernow refuses a payway the merchant did not select, with a422carrying a distinguishableerror.code(payway_not_available, newPaywayNotAvailableException). The gate is the first statement ofPlaceOrderService::placeOrder(), before any side effect — the guest-customer insert, the order insert, the points debit, the cart clear, the gateway charge — so a refused order writes nothing at all.- This is a behaviour change on a REST contract, but it affects no client today: nothing currently consumes
/rest/checkout/payment-methods, so there is no live caller for either half to break. Say both halves plainly — this is not "additive": a previously-populated response can now come back empty, and a previously-acceptedplace-orderrequest can now be refused. - A merchant must configure Settings → Payments (the #672 panel) before any external frontend can take payment at all. That is the designed forcing function, not a defect — for a client being onboarded onto nuxt/velora/mobile, an empty
paymentMethods[]means "nobody has filled in the panel yet", not a bug. semver:minor(the issue label) is correct because no client is live yet; it would not be once one is.- Commit:
2ca252a7b.
[4.121.0] feat(admin/payments): add external-frontend payway selection panels to Settings → Payment settings (Advisable-com/ecommercen#672)
- Two new dual-multiselect panels let a merchant curate which payways external frontends (nuxt, velora, the mobile app) may offer — one panel for payments ("External frontend payment methods"), one for gift cards ("External frontend gift card payment methods", shown only when gift cards are enabled). They mirror the existing storefront/admin/gift-card panels on the same page.
- The controller (
ecommercen/settings/controllers/Adv_settings.php) saves the selections to two new registry keys:METHODS.PAYWAY_EXTERNAL(unconditionally, mirroringMETHODS.PAYWAY) andMETHODS.PAYWAY_GIFT_CARDS_EXTERNAL(inside the existingGIFT_CARDS.ENABLEDguard, so a forged POST can't write it while gift cards are off). New label keys were added to all eight language bundles. - This slice only makes the selection configurable — nothing reads these two keys at runtime yet. That intersection against the registered payment adapters (and failing closed when unset) is issue #673.
- Commits:
5a374c4c8(controller + language bundles),e3de81718(the two admin views).
[4.121.0] fix(legacy): scope
hasProducts()to the category being deleted (Advisable-com/ecommercen#669)Adv_product_category_model::hasProducts(int $id)(ecommercen/eshop/models/Adv_product_category_model.php:206) never used its$id— with nowhere(), the emitted SQL wasSELECT product_id FROM shop_product_category_lp LIMIT 1, which answers "does this table contain any row at all", true on every live shop.canDeleteRecord()ishasChildren($id) ? false : !hasProducts($id), so it always evaluated tofalse: no product category could be deleted on any install. The failure was silent — the admin delete action guards withcanDeleteRecord()and then redirects unconditionally, with noelseand no flash, so it read as an unresponsive button rather than a refusal.- Introduced by
3b4b83a054(2026-02-09) — the commit that added the products check is what broke deletion, so deletion has been impossible for roughly six and a half months. - The fix (
d2d0e6476e) adds the missing predicate,->where('category_id', $id), and nothing else. The rule — a category may be deleted only when it has no child categories and no products — is unchanged; this restores that rule rather than altering it.canDeleteRecord()andhasChildren()(already correct, and the reference shape) are untouched.
[4.121.0] fix(video): stop fatalling on an unconfigured or non-
BUNNYvideo-stream provider; degrade to "no provider" instead (Advisable-com/ecommercen#663)- The root cause, one
matchshape, four copies.VIDEOSHOWCASE.PROVIDERresolution used amatchwhose intended fallback arm was written as the quoted string'default'— matching the literal text "default", not thedefaultkeyword. Any other value, including the registry's unset state (a shop that never configured the showcase), matched no arm and threw\UnhandledMatchError, which made theis_null($provider)guard immediately below each site unreachable. Fixed to thedefaultkeyword at:ecommercen/eshop/controllers/Adv_home.php:410,ecommercen/job/libraries/AdvCheckVideoStatusStream.php:35,ecommercen/job/libraries/AdvDeleteVideoToStream.php:35,ecommercen/job/libraries/AdvUploadVideoToStream.php:35. - A second, independent defect in the same area.
ecommercen/eshop/controllers/Adv_reels.phppassednullintoVideoManager::__construct(VideoStream $videoStream, ...), a non-nullable parameter — aTypeError. That happens in the constructor, before any "is the showcase enabled" check, so/reelsand/api/reelsreturned a hard 500 even when the video showcase was switched off — the operator-visible headline of this fix.$videoManageris now nullable andbuildPaginatedVideos()returns an empty result when no provider is configured, instead of constructing aVideoManageraround a null stream. - Behaviour unchanged for the configured case.
PROVIDER='BUNNY'behaviour is unchanged on all five surfaces (the homepage stream lookup, the three video-stream jobs, and/reels//api/reels) — this is a degradation fix for the unconfigured/other-provider path, not a behaviour change to the working path.
- The root cause, one
[4.121.0] test(domains/support): match visibility-scope targets across the PSR-4 root, so the positive invariant sees a fork's namespace-local relation copies (Advisable-com/ecommercen#661)
- What changed.
#641's positive invariant walkscustom/Domainson purpose — a fork's ownRepositoryConfigurator, aliased over its upstream counterpart incustom/Domains/container.php, is the class the DI container actually serves, and a stale copy of it is the whole hazard. For one relation shape that walk was inert.auditRequiredVisibilityScopes()selected relations with a rawisset($registry[$target])that did not strip the PSR-4 root, whilematchingExemption()deliberately did. This delivery routes the target lookup through the samewithoutPsr4Root()helper, via a newregisteredTargetFor(). - The shape that escaped is a NAMESPACE-LOCAL TARGET, not a self-referential relation. A target written as a bare
Repository::classresolves out of the declaring file's own namespace, so copying a wholeRepository/directory and rewriting only the namespace root shifts it toCustom\…without a character of the relation changing. Those copies matched no registry key, hit thecontinue, and were never checked at all — not offenders, not exempt, not even counted — with the per-entry blind floor staying quiet because upstream's own relations still matched.src/Domainsdeclares 48 such targets — 9 bareRepository::class(the recursiveparent/childrenpairs onBlog\Category,Blog\Comment,Cms\PageandProduct\Category, plusProduct\Category.relativeCategories) and 39 bareMuiRepository::class, everytranslationsrelation onto a same-namespaceMuiRepository. Only the first group can reach a registered target today; the 39 are harmless purely because noMuiRepositoryis in the registry, and if one is ever added they all shift together. (Counted here by resolving eachnew Relation(target against its file'suseimports and keeping the unqualified ones.#661's triage counted 38 and was correct at its base:#648addedCms\Reel'stranslationsbetween that commit and this one, and it is the only relation-bearing configurator added in the range. Re-derive rather than trusting either number.) - Live as of
#658, which is why this landed next.Cms\Page's two relations are declared as a bare self-referentialRepository::class, so registering that target was exactly what made the gap reachable.#658shipped with aKNOWN FORK-COVERAGE LIMITATIONnote on its registry entry saying upstream coverage was complete and fork coverage partial; that note is now false and has been restated in place — fork coverage is complete for both copy shapes, with the one bound below. - The bound that survives, recorded in
registeredTargetFor()'s docblock in the file's existing plain-limitation style. Matching is by trailing namespace, so aCustom\…class is reached only when it sits at the exact mirror path of a registered target. Measured, not assumed: a fork's genuinely newCustom\Domains\Marketplace\Widget\Repository\Repositorymatches no entry and is still skipped, and…\Repository\MuiRepositorynever collides with…\Repository\Repositorybecause only the PSR-4 root is stripped, never a trailing segment. That bound is deliberate rather than a hole — by this repo's own alias convention a class at the mirror path is that repository's override, while a fork's genuinely new domain lives atcustom/Domains/<New>/. So the guard asks a fork about its overrides of registered targets and about nothing else; there was no widening to choose. - Demonstrated failing-then-passing, with a control. A new
test_the_invariant_reports_a_fork_copy_whose_relation_target_is_namespace_local()carries aCustom\relation key and aCustom\target — the pair that was skipped. Both existing fork fixtures pair aCustom\key with anAdvisable\target, which rawisset()already caught, so copying their shape would have produced a test that passes without testing anything. The fixture's second row is the control: the same file, same missing scope, same relation shape, target written as the upstream FQCN. With the pre-fix lookup restored, exactly one of the two is reported and the test fails; with the fix, both are reported. Two further rows pin the bound from the other side — a fork's namespace-localMuiRepositoryand a fork's genuinely new module, both unscoped, both correctly ignored. $checkedkeying is structural, not a decision. It is seededarray_fill_keys(array_keys($registry), 0), so only registry keys can ever exist in it; a fork's copy increments the entry it overrides. The new test pins that aCustom\…target never introduces a key of its own, so a future change that keyed on the raw target cannot quietly split one target's coverage in two.- Nothing else moved.
visibilityScopeExemptions(),matchingExemption()andwithoutPsr4Root()are byte-identical, and so is the allow-list guardtest_only_the_reviewed_relations_declare_a_visibility_scope()and its 27 pinned keys — verified bymd5of each region at the base commit and atHEAD, not by reading the diff. No registry entry was added or removed: this changes coverage mechanics, while#656,#657,#659and#660change coverage membership.test_each_registered_repository_covers_the_relations_its_review_found()still pins Product 17, Article 4, Page 2 — it walksallRelations(), upstream only, and never sees aCustom\target in any tree. - No production code, no migration, no REST contract change, no new config key. Test-only.
- What changed.
[4.121.0] test(domains/support): register the blog-tag repository in the visibility-scope positive invariant, completing the registry at seven entries (Advisable-com/ecommercen#660)
- What changed.
#641shipped the positive invariant — every relation targeting a scope-bearing repository MUST declare a non-nullvisibilityScope, unless explicitly exempted — and#655,#658,#656,#657and#659added its second through sixth entries. The one relation reachingCms\Blog\Tag\Repository\Repositorywas still outside it: scoped by#640, with nothing asserting it stayed scoped. This delivery addsCms\Blog\Tag\Repository\Repository => BlogTagVisibilityScopeas the seventh entry. It is the last. Seven*VisibilityScopeclasses exist insrc/and all seven are now registered, so the registry docblock's running list of not-yet-registered targets is retired — replaced by a statement that the map is complete, with the "enabling one is a REVIEW, not a formality" rationale deliberately kept and re-pointed at any future eighth scope class. That sentence is what stops a future entry being added as a one-liner; retiring the list it qualified is not a reason to retire it. - What a stale fork copy was exposing.
Cms\Blog\Article.tagsembeds on the guest-readableGET /rest/cms/blog/article, so a fork whosecustom/Domainscopy ofBlog\Article's configurator predates#640returns inactive blog tags to unauthenticated callers through?with=tags, at every?with=depth that reaches a tag. The DI alias incustom/Domains/container.phpmakes that copy the class actually served, so the container boots clean,php cli.php job/check-containeris green, and the allow-list guard sees nothing — aCustom\...key contributes nothing to the array it pins. Only the positive invariant notices, and only once this target is registered in it. The rule itself is straight legacy parity, not a tightening:Adv_blog_tags_model::getTagsFront()has always selectedis_active = 1(ecommercen/blog/models/Adv_blog_tags_model.php:24-27), and= 1rather than!= 0withholds NULL-flagged rows exactly as legacy did. That is the asymmetry with the blog-category entry, whose rule is a tightening, and it is why#640built two scope classes rather than one parameterised by table. - Zero exemptions and zero allow-list edits were needed. The single relation was already scoped by
#640, and its key has been pinned intest_only_the_reviewed_relations_declare_a_visibility_scope()since then.visibilityScopeExemptions(),matchingExemption(),withoutPsr4Root(),registeredTargetFor(),instantiate()and the allow-list guard with its pinned keys are identical to the base, established at the git-blob level — no diff hunk touches any of them, which is the simpler and directly reproducible proof (a working-tree file comparison would need line endings normalised first, since the checkout is CRLF and blobs are LF) — this delivery changes coverage membership, not mechanics. - The audit was done by resolved class-string, not by grep. Measured over the 200 relations across 115 configurators the guard walks: exactly 1 relation resolves onto the target —
Cms\Blog\Article.tags,MANY_TO_MANY, with a non-nullvisibilityScope— declared in another module through an aliased import (use ...Cms\Blog\Tag\Repository\Repository as TagRepository,Cms\Blog\Article\Repository\RepositoryConfigurator:11), so an escaped-FQCN sweep misses it outright. Zero unscoped relations onto the target, therefore zero exemptions. - ⚠️ This target is NOT namespace-local — it is one of three registered entries with no own hop. Measured by resolved class-string:
Cms\Blog\Tag(1 inbound, 0 own) sits alongsideProduct\Product(17 inbound, 0 own) andCms\Blog\Article(4 inbound, 0 own), whose configurators likewise take their only namespace-local target to be aMuiRepository. The four entries that do own a bare self-referentialRepository::classhop areCms\Page(2 of 2),Product\Category(3 of 4),Cms\Blog\Comment(2 of 3) andCms\Blog\Category(2 of 3). This target carries none in either direction: its inbound relation is aliased from another module, and its own configurator declares onlytranslationsontoCms\Blog\Tag\Repository\MuiRepository, which is not a registry key today. So the fork shape#661exists for — a copiedRepository/directory shifting a bareRepository::classintoCustom\...— cannot arise from this target's own configurator, and the entry deliberately carries no fork-coverage note. A fork copyingBlog\Article's configurator still lands on this entry, because that copy keeps naming the upstream FQCN through its import. - ⚠️ The one non-additive part: a control row in another test had to be rewritten, and it fired first.
test_the_invariant_reports_an_article_relation_whose_visibility_scope_went_missing()(from#655) carried a deliberately unscopedArticle.tagsfixture row ontoCms\Blog\Tag\Repository\Repositoryas a "must NOT be reported" control, whose comment ended: "if this row ever starts being reported, someone enabled a target without doing that review." Registering the target turned that row into a genuine offender, the offender count went 1 → 2 and the test went red — the tripwire firing correctly, and this delivery is the review it was waiting for. It could not be replaced in kind, because after this delivery no unregistered target exists anywhere in the tree. It was therefore retargeted ontoCms\Blog\Article\Repository\MuiRepository, kept unscoped — preserving the exact property, skipped because of its target rather than by a null check, using a class that is structurally never a registry target. It was not scoped (that changes the control's kind to skipped-because-a-scope-is-present and silently retires the only target-based skip in that test) and not deleted (it is what makesassertCount(1, …)evidence rather than decoration). The count assertion stays at 2, unchanged, which is the point. The previous version is recorded in the test's docblock rather than quietly replaced. - The coverage pin was extended, not relaxed.
test_each_registered_repository_covers_the_relations_its_review_found()now pins Product 17, Article 4, Page 2, product-category 4, blog-comment 3, blog-category 3, blog-tag 1. This module's configurator declares exactly ONE relation and it is excluded:translationstargetsCms\Blog\Tag\Repository\MuiRepository, one final segment from the registered class-string and carrying no visibility flag. One declared, one excluded, zero own hops plus the one cross-module relation = 1 — the only entry in the pin with no own hop at all. 2 is wrong (it counts the module's whole configurator) and 0 would meanArticle.tagsstopped resolving onto the target. For this entry alone the coverage pin and the invariant's blind floor (count > 0) assert the same thing and fail together; that degenerate case is documented rather than tidied away, because the pin is what stops the number being edited upward to absorb an unreviewed relation, which the floor cannot see. - The per-entry blind floor still discriminates with seven entries.
test_the_blind_floor_is_per_entry_so_one_registered_target_cannot_mask_another()now expects the Article, Page, product-category, blog-comment, blog-category and blog-tag entries in the blind list while Product, which matched, stays out. Both expected arrays were updated to new exact values rather than the assertions loosened — a count, a subset check or a sort would delete exactly the per-entry discrimination property that test exists to hold. Its docblock records that seven is a ceiling for now rather than a waypoint: the next growth means a genuinely new scope class, and the extend-never-relax instruction has not softened because the known backlog is exhausted. - The failing case is demonstrated per target, not inherited. A new
test_the_invariant_reports_a_blog_tag_relation_whose_visibility_scope_went_missing()drives the same detection routine the tree-wide invariant uses against a fork's stale, unscoped copy ofBlog\Article.tags. Its sharpest negative control isProduct.tags→Product\Tag\Tag\Repository\Repository, a real and genuinely unscoped upstream relation that shares the registered key's entire trailingTag\Repository\Repositorytriple and differs only in the leading namespace, so anystr_ends_withor trailing-triple comparison flags it and only the whole resolved class-string keeps it out — which is what the row pins. It also carries the same relation name,tags, which costs nothing to carry but guards a hypothetical rewrite rather than a reachable mutation: no code path resolves a target from a relation name.Cms\Blog\Tag.translationsis pinned as a second unscoped control. A fifth row,Article.categories, is real and scoped, and pins the attribution rule with#659's roles inverted: since this target has no own relations, the same-configurator-different-target row is drawn from the configurator that declares the subject relation. Three of the fixture's five rows are keyed toBlog\Article's configurator and land on three different answers — two on blog-tag, one on blog-category, none on Article — which makes executable, from both directions, the rule that a relation is counted against the entry its TARGET resolves onto and never the entry its declaring configurator belongs to. It does not guard the coverage number; the assertions it literally executes are 2, 1 and 0. Verified by experiment: with the registry entry removed the new test fails (Failed asserting that actual size 0 matches expected size 1); with it present, green. - No production behaviour change. Test-only —
src/,application/,ecommercen/anddatabase/are untouched. Fork owners: if yourcustom/Domainscopy ofCms\Blog\Article'sRepositoryConfiguratorpredates#640, this guard now fails for you. Re-sync the copy againstsrc/— the fix is to restore thevisibilityScopeargument ontags, not to add an exemption.
- What changed.
[4.121.0] test(domains/support): register the blog-category repository in the visibility-scope positive invariant, bringing all three relations onto it inside the guard (Advisable-com/ecommercen#659)
- What changed.
#641shipped the positive invariant — every relation targeting a scope-bearing repository MUST declare a non-nullvisibilityScope, unless explicitly exempted — and#655,#658,#656and#657added its second through fifth entries. The three relations reachingCms\Blog\Category\Repository\Repositorywere still outside it: scoped by#640, with nothing asserting they stayed scoped. This delivery addsCms\Blog\Category\Repository\Repository => BlogCategoryVisibilityScopeas the sixth entry. - Zero exemptions and zero allow-list edits were needed. All three relations were already scoped by
#640: the two self-referencing hops declared in the module's own configurator (childrenandparent) plus the cross-moduleArticle.categories. All three relation keys were already pinned intest_only_the_reviewed_relations_declare_a_visibility_scope().visibilityScopeExemptions(),matchingExemption(),withoutPsr4Root(),registeredTargetFor(),instantiate()and the allow-list guard with its pinned keys are identical, md5-verified at the base and in the working tree (line endings normalised first; the checkout is CRLF and the blob is LF, so a naive byte-compare reports every region as differing) — this delivery changes coverage membership, not mechanics. - The audit was done by resolved class-string, not by grep, and that mattered here. An escaped-FQCN sweep for this target misses the module's own two hops entirely, because both are declared as a bare self-referential
Repository::class. A sweep for the source textCategoryRepositoryis worse than useless: that same alias names three different repositories across the tree — this target inCms\Blog\Article's configurator,Product\CategoryinProduct\Product's, andEvent\CategoryinEvent\Event's — so it returns two false positives while still missing the two namespace-local hops. Only resolvingRelation::$relatedRepositoryClassthrough the test's own reflection walk finds all three and nothing else. Measured over the 200 relations the guard walks: 3 relations onto the target, all three with a non-nullvisibilityScope, across exactly 2 configurators (Cms\Blog\Article's and the module's own). - The coverage pin was extended, not relaxed — and the exclusion arithmetic is the trap on this entry.
test_each_registered_repository_covers_the_relations_its_review_found()now pins Product 17, Article 4, Page 2, product-category 4, blog-comment 3, blog-category 3. This module's configurator declares FOUR relations, of which TWO are excluded:translationstargetsCms\Blog\Category\Repository\MuiRepository, the module's own Mui class one final segment from the registered class-string and carrying no visibility flag; andarticlestargetsCms\Blog\Article\Repository\Repository— it is scoped (#640), but byArticleVisibilityScopeonto the Article repository, so it is counted against that entry, exactly asProduct\Category.articlesis. Four declared, two excluded, two own hops plus the one cross-module relation = 3. 4 and 5 are both wrong, in both directions. That number is guarded by the coverage pin itself — it is what fails if someone edits the 3 to a 4 — not by the new failing-case test, whose own assertions execute 2 and 1 and pin a different property (see below). - The per-entry blind floor still discriminates with six entries.
test_the_blind_floor_is_per_entry_so_one_registered_target_cannot_mask_another()now expects the Article, Page, product-category, blog-comment and blog-category entries in the blind list while Product, which matched, stays out. Both expected arrays were updated to new exact values rather than the assertions loosened — a count, a subset check or a sort would delete exactly the per-entry discrimination property that test exists to hold. - The failing case is demonstrated per target, not inherited. A new
test_the_invariant_reports_a_blog_category_relation_whose_visibility_scope_went_missing()drives the same detection routine the tree-wide invariant uses against a fork's stale, unscoped copy ofBlogCategory.children. Its sharpest negative control isEvent\Event.categories, a real and genuinely unscoped upstream relation whose target shares the registered key's entire trailingCategory\Repository\Repositorytriple and differs only in the leading namespace — while being declared through an import aliased to exactly the sameCategoryRepositorytext the row above uses for the real target. A text sweep flags it; so does anystr_ends_withor trailing-triple comparison. No other fixture in the file pins that shape (Page pins the neighbouring-final-segment shape, product-category the inserted-segment shape, blog-comment the substituted-middle-segment shape).translationsis pinned as a second unscoped control. A fifth row,BlogCategory.articles, is real and scoped, and is there to pin the attribution rule: withtranslationspinned OUT of this entry andarticlespinned ONTO the Article entry, the two count assertions together make executable — from both directions, which nothing else in the file asserts — the rule that a relation is attributed by the entry its TARGET resolves onto, never by the entry its DECLARING CONFIGURATOR belongs to. It does not guard the coverage number.#656faced the identical shape (Product\Category.articles) and deliberately omitted it; including it here is the better call only because an omitted row cannot supply that second direction, and#660should copy this shape. Verified by experiment: with the registry entry removed the new test fails (0 offenders reported where 1 is required); with it present, green. - The rule this target carries is OWN-FLAG, not ancestor-chain, and that is not an oversight to "align" with the product-category entry.
BlogCategoryVisibilityScopenarrows toblog_categories.is_active = 1on the row's own flag.#640measured the difference across 14 tenant databases — zero active-but-unreachableblog_categoriesrows, a taxonomy that is essentially flat (max depth 2 in 2 of 14 tenants, no nesting at all in the other 12) — where#588found 42% of pharm16's product categories published-but-unreachable and#625was ratified onto a BFS ancestor chain on the strength of it. The two scope classes share the word Category and nothing else. The accepted delta is stated on the scope class itself: an active child under an inactive parent would remain visible, measured as a non-occurrence rather than an impossibility, with the escalation path recorded if a tenant ever creates one. - The rule is
= 1, never!= 0.blog_categories.is_activeisint(2) DEFAULT NULL, and NULL-flagged rows are deliberately withheld from storefront callers. Do not "tidy" the comparison. - This rule is a deliberate TIGHTENING over legacy, not parity — which is what a fork owner needs to read before resyncing. The legacy blog-category storefront never filters
is_activeat all:getCategoriesFront()applies only the conditions its caller supplies and every call site passeslangalone, while the model's twois_activeoccurrences emit the column into the returned array rather than filter on it. So inactive and NULL-flagged blog categories are reachable on the legacy storefront and stop being served over REST. That is intentional — the flag exists to hide rows and legacy simply never enforced it — but it is a behaviour change, not a formalization of existing behaviour. (The sibling blog-tag rule is the opposite, straight parity with legacy;#624'srest_api_versionsentry records the two justifications separately and tells fork owners to read them separately.) - No fork-coverage caveat on this entry, and the absence is deliberate.
#658's Page entry carries a restatedKNOWN FORK-COVERAGE LIMITATIONnote becauseauditRequiredVisibilityScopes()once matched the target with a rawisset($registry[$target])that did not strip the PSR-4 root, so a fork copying a wholeRepository/directory shifted a namespace-local target toCustom\…and was skipped outright.#661(401043cc1) closed that gap. Both of this target's own hops are exactly that namespace-local shape, so copying#658's note here would be false, not merely redundant — both fork copy shapes are caught from the outset.#656and#657correctly shipped none either. With this entry registered, no namespace-local target remains pending. - The one remaining pending target stays off.
Cms\Blog\Tag(#660) still needs its own relation-by-relation review before being registered, for the reason the in-test docblock states: enabling a target whose relations have not been reviewed is how a false exemption gets written. It must also land last — registering it flips theArticle.tagscontrol row in#655's demonstration test into an offender. One target per issue. - No production code, no migration, no REST contract change, no new config key. Test-only.
- What changed.
[4.121.0] test(domains/support): register the CMS Page repository in the visibility-scope positive invariant, bringing the two recursive page-tree relations inside the guard (Advisable-com/ecommercen#658)
- What changed.
#641shipped the positive invariant — every relation targeting a scope-bearing repository MUST declare a non-nullvisibilityScope, unless explicitly exempted — and#655added its second entry. The two relations reaching the CMS Page repository,Page.parentandPage.children, were still outside it: scoped by#639, with nothing asserting they stayed scoped. This delivery addsCms\Page\Repository\Repository => PageVisibilityScopeas the third entry. - Zero exemptions and zero allow-list edits were needed.
#639had already scoped both hops, and both relation keys were already pinned intest_only_the_reviewed_relations_declare_a_visibility_scope().visibilityScopeExemptions()and the allow-list's 27 pinned keys are byte-identical — this is a one-entry addition plus the counts and prose that quote it. - The coverage pin was extended, not relaxed.
test_each_registered_repository_covers_the_relations_its_review_found()now pins Product 17, Article 4, Page 2.Page.translationsis deliberately not in that 2: it targetsCms\Page\Repository\MuiRepository, a different class-string carrying no visibility flag, and thechildrenhop being scoped is what keeps an unpublished node'scategories_muirows from ever materialising. - The per-entry blind floor still discriminates with three entries.
test_the_blind_floor_is_per_entry_so_one_registered_target_cannot_mask_another()now expects both the Article and the Page entries in the blind list while Product, which matched, stays out. The expected values were updated rather than the assertions loosened — a subset check or a sort would delete exactly the property that test exists to hold. - The failing case is demonstrated per target, not inherited. A new
test_the_invariant_reports_a_page_relation_whose_visibility_scope_went_missing()drives the same detection routine the tree-wide invariant uses against a fork's stale, unscoped copy ofPage.children. Verified by experiment: with bothvisibilityScopearguments deleted fromsrc/Domains/Cms/Page/Repository/RepositoryConfigurator.php, the invariant is green without this registry entry and red with it, naming both relations. - A known bound on the fork-facing half, recorded in the registry entry's own comment (
#661).auditRequiredVisibilityScopes()selects relations with a rawisset($registry[$target])and does not strip the PSR-4 root, whilematchingExemption()deliberately does. Both Page relations are declared self-referentially — a bareRepository::classresolved inside the Page module's own namespace — so a fork that copies the wholeRepository/directory resolves them toCustom\…\Cms\Page\Repository\Repository, which is not a registry key, and those copies are skipped outright. A fork that copies only the configurator, leaving the reference on the upstream FQCN, is caught. Upstream coverage here is complete (2 of 2); fork coverage is partial. Registering the target is still strictly better than leaving it out, and#661tracks the fix separately because normalising the root changes the routine every entry shares. - The other four pending targets stay off.
Product\Category,Cms\Blog\Comment,Cms\Blog\CategoryandCms\Blog\Tageach still need their own relation-by-relation review before being registered, for the reason the in-test docblock states: enabling a target whose relations have not been reviewed is how a false exemption gets written. One target per issue. - No production code, no migration, no REST contract change, no new config key.
Cms\Page\WriteService::update()'sRelation::VISIBILITY_EXEMPT_ALLgrant (#639) is untouched and unaffected — the invariant asserts on a declaration in a configurator, the grant is a runtime argument at a call site, and neither can satisfy or defeat the other. Test-only.
- What changed.
[4.121.0] test(domains/support): register the blog-comment repository in the visibility-scope positive invariant, bringing all three relations onto it inside the guard (Advisable-com/ecommercen#657)
- What changed.
#641shipped the positive invariant — every relation targeting a scope-bearing repository MUST declare a non-nullvisibilityScope, unless explicitly exempted — and#655,#658and#656added its second, third and fourth entries. The three relations reachingCms\Blog\Comment\Repository\Repositorywere still outside it: scoped by#616, with nothing asserting they stayed scoped. This delivery addsCms\Blog\Comment\Repository\Repository => CommentVisibilityScopeas the fifth entry. - Zero exemptions and zero allow-list edits were needed. All three relations were already scoped by
#616: the two self-referencing hops declared in the module's own configurator (parentandchildren) plus the cross-moduleArticle.comments. Both of the module's own relation keys were already pinned intest_only_the_reviewed_relations_declare_a_visibility_scope().visibilityScopeExemptions(),matchingExemption(),withoutPsr4Root(),registeredTargetFor()and the allow-list guard with its pinned keys are identical, md5-verified at the base and in the working tree (line endings normalised; the checkout is CRLF) — this delivery changes coverage membership, not mechanics. - The audit was done by resolved class-string, not by grep, and that mattered here too. An escaped-FQCN sweep for this target misses the module's own two hops entirely, because both are declared as a bare self-referential
Repository::class. A sweep for the source textCommentRepositoryfinds only the third relation,Article.comments, which reaches the target through the aliased importuse ...Comment\Repository\Repository as CommentRepository. Only resolvingRelation::$relatedRepositoryClassthrough the test's own reflection walk finds all three and nothing else. Measured: 3 relations, all three with a non-nullvisibilityScope, across exactly 2 configurators (Cms\Blog\Article's and the module's own). - The coverage pin was extended, not relaxed.
test_each_registered_repository_covers_the_relations_its_review_found()now pins Product 17, Article 4, Page 2, product-category 4, blog-comment 3. No relation is excluded from that 3, and unlike every entry above it that is not an omission to go looking for. This module's configurator declares exactly two relations and both are counted; the module ships notranslationshop and noMuiRepository.phpat all, so the same-module-different-final-segment exclusion the Page and product-category entries each have to draw simply has no instance here. Which is why 2 would be wrong — it drops the cross-moduleArticle.comments, the only relation onto this target declared outside the module — and why any number above 3 would be wrong: there is no fourth relation to find. - The per-entry blind floor still discriminates with five entries.
test_the_blind_floor_is_per_entry_so_one_registered_target_cannot_mask_another()now expects the Article, Page, product-category and blog-comment entries in the blind list while Product, which matched, stays out. Both expected arrays were updated to new exact values rather than the assertions loosened — a count, a subset check or a sort would delete exactly the per-entry discrimination property that test exists to hold. - The failing case is demonstrated per target, not inherited. A new
test_the_invariant_reports_a_blog_comment_relation_whose_visibility_scope_went_missing()drives the same detection routine the tree-wide invariant uses against a fork's stale, unscoped copy ofComment.children. It pins two negative controls, both real upstream relations and both genuinely unscoped so a loose matcher would report them:Article.author, whose target shares the registered key's wholeCms\Blog\prefix and its whole trailingRepository\Repositorypair while differing only in the middle segment — a shape no other fixture in the file pins — andArticle.translations, aMuiRepositoryone final segment from a different registry key. Verified by experiment: with the registry entry removed the new test fails (0 offenders reported where 1 is required); with it present, green. - The registry stays names-only, and this target is the recorded reason it is.
CommentVisibilityScopeis the only static scope in the tree — a class-levelrelationScope()over aprotected const TABLE— so it is never constructed and never appears as a constructor parameter anywhere (a tree-wide search forCommentVisibilityScope $returns nothing). A registry that instantiated its values would need a per-class special case for exactly this one. It also meansinstantiate()needed no change: its scope-stubbing branch keys on a constructor parameter type, so it never sees this class at all. Confirmed rather than assumed, and deliberately not touched "for symmetry". - No fork-coverage caveat on this entry, and the absence is deliberate.
#658's Page entry carries a restatedKNOWN FORK-COVERAGE LIMITATIONnote becauseauditRequiredVisibilityScopes()once matched the target with a rawisset($registry[$target])that did not strip the PSR-4 root, so a fork copying a wholeRepository/directory shifted a namespace-local target toCustom\…and was skipped outright.#661(401043cc1) closed that gap. Both of this target's own hops are exactly that namespace-local shape, so copying#658's note here would be false, not merely redundant — both fork copy shapes are caught from the outset.#656correctly shipped none either. - The other two pending targets stay off.
Cms\Blog\Category(#659) andCms\Blog\Tag(#660) each still need their own relation-by-relation review before being registered, for the reason the in-test docblock states: enabling a target whose relations have not been reviewed is how a false exemption gets written. One target per issue. - No production code, no migration, no REST contract change, no new config key. Test-only.
- What changed.
[4.121.0] test(domains/support): register the product-category repository in the visibility-scope positive invariant, bringing all four relations onto it inside the guard (Advisable-com/ecommercen#656)
- What changed.
#641shipped the positive invariant — every relation targeting a scope-bearing repository MUST declare a non-nullvisibilityScope, unless explicitly exempted — and#655and#658added its second and third entries. The four relations reachingProduct\Category\Repository\Repositorywere still outside it: scoped by#625and#588, with nothing asserting they stayed scoped. This delivery addsProduct\Category\Repository\Repository => CategoryVisibilityScopeas the fourth entry. - Zero exemptions and zero allow-list edits were needed. All four relations were already scoped: the three hops declared in the module's own configurator (
parent,childrenandrelativeCategories, all#625) plus the cross-moduleProduct.categories(#588). All four relation keys were already pinned intest_only_the_reviewed_relations_declare_a_visibility_scope().visibilityScopeExemptions(),matchingExemption(),withoutPsr4Root(),registeredTargetFor()and the allow-list guard with its 27 pinned keys are byte-identical — this delivery changes coverage membership, not mechanics. - The audit was done by resolved class-string, not by grep, and that mattered. An escaped-FQCN sweep for this target returns zero hits, because the three own hops are declared as a bare self-referential
Repository::class. A sweep for theCategoryRepositoryalias returns false positives, because that alias names three different repositories across the tree (Product\CategoryinProduct\Product's configurator,Event\CategoryinEvent\Event's,Cms\Blog\CategoryinBlog\Article's). Only resolvingRelation::$relatedRepositoryClassfinds all four and nothing else. - The coverage pin was extended, not relaxed.
test_each_registered_repository_covers_the_relations_its_review_found()now pins Product 17, Article 4, Page 2, product-category 4. This module's configurator declares SIX relations, so THREE of them are deliberately not in that 4, and none of the three is a miscount:articlestargets the Article repository and is counted against that entry;tagGroupstargetsProduct\Tag\Category\Repository\Repository, which carries no visibility flag at all; andtranslationstargetsProduct\Category\Repository\MuiRepository, a different class-string carrying no visibility flag either — so 5, 6 and 7 would all be wrong. - The per-entry blind floor still discriminates with four entries.
test_the_blind_floor_is_per_entry_so_one_registered_target_cannot_mask_another()now expects the Article, Page and product-category entries in the blind list while Product, which matched, stays out. Both expected arrays were updated to new exact values rather than the assertions loosened — a count, a subset check or a sort would delete exactly the per-entry discrimination property that test exists to hold. - The failing case is demonstrated per target, not inherited. A new
test_the_invariant_reports_a_product_category_relation_whose_visibility_scope_went_missing()drives the same detection routine the tree-wide invariant uses against a fork's stale, unscoped copy ofProduct\Category.children. It pins two negative controls specific to this target, both unscoped so a loose matcher would report them:tagGroups, whose target differs from the registered key only by an insertedTag\segment while ending in the same three segments, andtranslations, one final segment apart onMuiRepository. Verified by experiment: with the threevisibilityScope:arguments deleted fromsrc/Domains/Product/Category/Repository/RepositoryConfigurator.php, the invariant is green without this registry entry and red with it, naming all three hops. - No fork-coverage caveat on this entry, and the absence is deliberate.
#658's Page entry originally carried aKNOWN FORK-COVERAGE LIMITATIONnote becauseauditRequiredVisibilityScopes()matched the target with a rawisset($registry[$target])that did not strip the PSR-4 root, so a fork copying a wholeRepository/directory shifted a namespace-local target toCustom\…and was skipped outright.#661(401043cc1) closed that gap before this delivery, so both fork copy shapes are caught here from the outset — the copy that keeps the reference on the upstream FQCN and the whole-directory copy that does not. Copying#658's note onto this entry would have been false as written. - The other three pending targets stay off.
Cms\Blog\Comment(#657),Cms\Blog\Category(#659) andCms\Blog\Tag(#660) each still need their own relation-by-relation review before being registered, for the reason the in-test docblock states: enabling a target whose relations have not been reviewed is how a false exemption gets written. One target per issue. - No production code, no migration, no REST contract change, no new config key.
CategoryVisibilityScopeis DB-backed — it takes aCI_DB_query_builderand walks the category tree — and that is irrelevant to this change: the registry maps class names and never constructs anything, and the test harness mocks scopes structurally off the presence of arelationScope()method. Test-only.
- What changed.
[4.121.0] test(domains/support): register the blog Article repository in the visibility-scope positive invariant, bringing the four
articlesrelations inside the guard for the first time (Advisable-com/ecommercen#655)- What changed.
#641shipped the positive invariant — every relation targeting a scope-bearing repository MUST declare a non-nullvisibilityScope, unless explicitly exempted — but seeded its registry (RelationConfigurationTest::scopeBearingTargetRepositories()) with exactly one active entry, the Product repository. The four relations reaching the blog Article repository therefore sat outside the invariant entirely: they were scoped, and nothing asserted they stayed scoped. This delivery addsCms\Blog\Article\Repository\Repository => ArticleVisibilityScopeas the second entry. - Zero exemptions were needed. All four relations were already scoped by earlier deliveries —
BlogAuthor.articles(#653),BlogCategory.articles(#640),Product\Category.articles(#625) andProduct.articles(#653). Registering the repository was a one-line addition with no behaviour change and no new entry invisibilityScopeExemptions(), which is the shape#641built the registry for. - A new coverage pin, because "registered" is not "covered".
test_each_registered_repository_covers_the_relations_its_review_found()asserts the exact per-target relation count each registry entry matches — Product 17, Article 4. The invariant's existing per-entry blind floor only asks for> 0, which catches a renamed class-string but not a partial match: an entry matching one of its four relations passes the floor while three sit outside the guard. That is not hypothetical — it is precisely the state this issue fixed, one registry entry with four relations unguarded and the whole suite green. It is a separate test rather than extra assertions on the invariant, so that ordinary relation additions never edit the assertion the safety property lives in. - The other five pending targets stay off.
Product\Category,Cms\Blog\Comment,Cms\Page,Cms\Blog\CategoryandCms\Blog\Tageach still need their own relation-by-relation review before being registered, for the reason the in-test docblock states: enabling a target whose relations have not been reviewed is how a false exemption gets written. One target per issue. - The allow-list guard
test_only_the_reviewed_relations_declare_a_visibility_scope()and its 27 pinned keys are untouched. The two guards remain complementary — the allow-list catches an unreviewed addition, the invariant catches a removal. - No production code, no migration, no REST contract change, no new config key. Test-only.
- What changed.
[4.121.0] fix(rest): scope
Product.articlesandBlogAuthor.articleswith #640'sArticleVisibilityScope, closing the last two guest-reachable relations that returned the complete pre-publication body of every draft article (Advisable-com/ecommercen#653)- The two relations.
Product.articles(src/Domains/Product/Product/Repository/RepositoryConfigurator.php,MANY_TO_MANYvia pivotproduct_blog) andBlogAuthor.articles(src/Domains/Cms/Blog/Author/Repository/RepositoryConfigurator.php,ONE_TO_MANYonblog_author_id) now carryArticleVisibilityScope(blog.is_published = 1) — the existing class #640 built and #625 already reused cross-module, not a new one. Both owning endpoints (GET /rest/product/product,GET /rest/cms/blog/author) areauth => guestonindex/show/itemand declare norelationsallow-list, which could not have closed this anyway:RelationFilterMiddlewarematches only the first?with=hop. - Severity.
blog.is_publishedisNOT NULL DEFAULT 0, so every draft ever created was in the exposed set.BlogArticleMuiResourceemitsblog_mui.descriptionwith noisBackend()gate, so what leaked was the full article body, not merely row existence.index()embeds the relation on every returned row, so both were bulk-enumerable in a single unauthenticated request.Product.articlessits on the highest-traffic guest surface in the API —GET /rest/product/product?with=articles.translations— and was the live path:Rest\Product\Resources\Product\Resourceserialisesarticlesunconditionally.BlogAuthor.articleswas not serialised by upstream's own Resource (translationsonly), so upstream's exposure there was latent rather than live — but the relation is advertised in that endpoint's OpenAPIx-relationsmetadata as available, so a client fork that serialises it from its ownResourcesubclass was live-exposed all along. Declaring the rule on the relation is what makes serialising it safe for anyone to do later. - This is the third and fourth call site for
ArticleVisibilityScopeand completes the set:#640built the scope and is its first consumer (BlogCategory.articles),#625reused it cross-module fromProduct\Category, and every article-targeting relation in the tree is now scoped.#641shipped the structural positive-invariant guard for this class of omission, but it is currently inert for these two relations — itsscopeBearingTargetRepositories()registry lists only the Product repository, and enabling the Article repository there is a separate later step. - No production behaviour change for backend callers: the central
Relation::VISIBILITY_EXEMPT_ALLgrant still returns drafts to admin screens through both relations, so admin product/author edit views are unaffected. - No write-path edge — both owning
WriteServices load['translations']only, so unlike#639noVISIBILITY_EXEMPT_ALLwas needed on a server-side relation list. - Files: the two configurators;
tests/Unit/Domains/Support/Repository/RelationConfigurationTest.php(declared-visibility-scope inventory 25 → 27); a newtests/Unit/Domains/Cms/Blog/Article/ArticleRelationVisibilityScopeTest.php;application/config/rest_api_versions.phpcarries its own1.Xentry, authored and maintained separately from this fragment.
- The two relations.
[4.121.0] fix(rest): bound the
?limitquery parameter for storefront list reads — newrest_max_page_sizeceiling, default 1000 (Advisable-com/ecommercen#652, ceiling value set by #664)- Why. A
?limitthat was actually supplied was never checked against a maximum.GET /rest/product/product?limit=47419returned 47419 rows, on roughly 114 list endpoints, most of them guest-reachable — a single anonymous request was a resource-exhaustion lever, with no authentication, rate limit or role check in front of it. - What the defect was NOT — the framing matters, because the obvious fix is the wrong one. The default was already correct and is completely unchanged.
GenerateListRequest::getLimit()is$this->request->get('limit') ?: self::PER_PAGE, so an omittedlimityields 15, and?limit=0/?limit=are falsy and yield 15 too;Paginationthen independently guards$perPage > 0 ? $perPage : 15and always emits aLIMIT. There was never an unbounded path from omitting the parameter.getLimit()andPER_PAGEare therefore untouched, and so isPagination— this bounds a supplied value and nothing else. - The change. One clamp in
HandlesRestfulActions::buildListRequest(), the single construction point shared byindex(),show()anditem()across the 110 controllers that extendHandlesRestfulActions.ListRequest::$perPageis a public, non-readonly promoted property, so the generated request is clamped on the way out — no new plumbing throughGenerateListRequest, which has noResourceContextto branch on anyway. - DECISION — clamp, never reject. No exception, no 4xx, no new error field. The platform has no 4xx path for a bad list value anywhere (an unparseable filter value is dropped, not refused), and refusing a limit that is accepted today would break live callers outright instead of degrading them. A request for 47419 rows returns HTTP
200with 1000 rows. - DECISION — storefront only; backend callers are exempt. Discriminated on
ResourceContext::isBackend(), mirroringProduct::enforceStorefrontProductScope()— an established pattern, not a new one. An admin export or a back-office grid legitimately pulls large pages and is authenticated and role-gated; a guest is neither. A customer token is still a storefront caller. Fail-closed: a route that never callssetResourceContext()has a null context, and null is not an exemption. - DECISION — the ceiling is 1000, and it is configuration, not a literal. New key
rest_max_page_sizeinapplication/config/app.php, sat besideproducts_list_limit/products_list_maxbecause it is the same family of setting on a different surface (REST vs the rendered storefront's per-page selector). 1000 specifically (Advisable-com/ecommercen#664), superseding an earlier 200 that never shipped. Sizing a platform-wide ceiling to the page size one headless consumer happened to use was the wrong basis: it turns one client's paging strategy into every shop's bound, and it breaks any other consumer that reads a reference list in a single request. 1000 is set high enough that a legitimate one-shot bulk read — a reference list, a facet computation, a data sync — completes without paging. The bound is loosened, not removed, and that is a deliberate trade-off: the ceiling exists as resource-exhaustion hardening, so 1000 gives back most of the mitigation 200 provided while still refusing?limit=47419a 47419-row page. A shop wanting the tighter bound setsrest_max_page_sizeto it locally, andmaxPageSize()staysprotectedfor a fork overriding the read path outright — so do not "fix" 1000 back to 200 as an apparent regression. Read lazily throughget_instance()at request time — never in a constructor — the same seam constraintListingConfigResolverdocuments. - DECISION — the config read is fail-SAFE, not fail-open. An absent key, a non-numeric value, a zero or negative value, or no reachable CI instance at all all resolve to
1000— theHandlesRestfulActions::DEFAULT_MAX_PAGE_SIZEconstant, deliberately kept equal to the shipped config default and pinned to it by a test so the two cannot drift apart.application/config/app.phpis a client-owned file in a fork, so a fork that has not merged the new line must keep the ceiling rather than silently lose it. Losing the ceiling is the exact failure this change exists to prevent, so no degenerate input may resolve to "unbounded". - The bypass that was closed — the part most likely to be missed. A clamp in
buildListRequest()does not automatically reach every list endpoint: three controllers bypassed the seam with the raw(new $this->listRequestClass())->generate($this->input)construction, which opts an endpoint out of every server-side list constraint (forced and denied filter/sort keys, the backend relation-visibility exemption, and now the ceiling) with no error and a green boot.Admin\Role::index()was a real LIST read and has been migrated to$this->buildListRequest()— behaviour-neutral there, since Role registers no forced or denied keys and itsRepositoryConfiguratoris theNullRelationConfigurator, and the route is backend-only so the ceiling never applies to it anyway. The practical gap was nil today; it becomes a real hole the moment a guest endpoint copies that shape, which is why it was closed rather than merely noted.Order::show()andWishlist::show()keep the raw construction deliberately — both are per-row fetches by primary key, where a page size is not a meaningful concept. - Tests:
tests/Unit/Rest/Support/StorefrontPageSizeCeilingTest.php(new — guest and customer clamped, backend exempt, null context clamped, the ceiling read from config including a numeric-string value, seven degenerate config values all falling back to theDEFAULT_MAX_PAGE_SIZEconstant, no-CI-instance falling back to it too, a drift guard asserting the shippedrest_max_page_sizedefault inapplication/config/app.phpequals that constant,pagination.per_pagereporting the clamped size, and the unchanged-default regressions: omitted / empty / zerolimitstill 15, under-ceiling and exactly-at-ceiling passing through untouched) andtests/Unit/Rest/Support/ListRequestConstructionGuardTest.php(new — a static call-shape guard asserting that no controller outsideHandlesRestfulActionstouches$this->listRequestClass, with the two per-rowshow()branches allowlisted and their allowlist entries checked for staleness, plus a positive assertion thatAdmin\Role::index()routes through the shared seam).
- Why. A
[4.121.0] fix(lang): stop
t()/ExternalLang::line()fatalling on a format/argument mismatch; fix every cross-locale translation defect that was triggering it (Advisable-com/ecommercen#651)- The root cause, one shape, two copies. Both helpers handed an unvalidated translation string straight to
vsprintf(). On PHP 8, a format string whose%-specifiers don't line up with the supplied argument count (or that contains a bare%) is an uncaughtValueError, which white-pages the whole request.t()andExternalLang::line()were the same six lines duplicated, and had drifted into two copies of the same bug. - The merchant-visible headline. The admin products batch-update page was down in every shipped locale except greek — including a default english install. Product create/update/clone/ERP-add and the category discount pages were down in italian and spanish. Order and product batch updates were down in italian.
- The fix: degrade instead of fatal. Both helpers now delegate to a new shared
AdvLangFormatter(ecommercen/libraries/AdvLangFormatter.php). A zero-argument call skipsvsprintf()entirely. Anything else is wrapped: a mismatch logs the offending key with the expected-vs-supplied argument count and renders the unformatted text instead of throwing. Argument values are never logged — they carry order serials, names, and addresses. - The data fix. Every cross-locale inconsistency in the translation data is fixed — all six shipped language families now declare an identical required argument count per key across all eight locales.
- Customer-facing, and not in the original report: the order-cancellation email (
eshop.front.mail.order.update.order.has.been.canceled) carried a bare%in chinese, french, german and russian, raisingUnknown format specifier— unsatisfiable at any argument count. A merchant running any of those four locales could not send an order-cancellation email at all. - The gift-card SMS (
giftCard.sms.message) was broken in 7 of 8 locales. Five locales (chinese, english, french, german, russian) lacked the key entirely, so customers received an SMS whose body was the literal textgiftCard.sms.message— silently, with no error. Spanish declared four placeholders against three arguments and fatalled. Italian ordered its placeholders wrongly, so the customer received the site URL as the amount and the coupon code as the site. All eight locales now carry the message and use positional%1$sspecifiers. - Also customer-facing: the review-accepted, review-rejected and blog-comment-accepted emails (
eshop.front.mail.conclusion.of.review,eshop.front.mail.we.remain.at.your.side) now name the shop in chinese, english, french, german and russian. Those five locales previously rendered a generic phrase such as "its visitors" where greek, italian and spanish already named the shop; the wording is now aligned across all eight. - The spanish storefront registration block (
customer.register.info.text.home) declared two placeholders against a one-argument call site, fatalling for logged-out visitors in spanish.
- The root cause, one shape, two copies. Both helpers handed an unvalidated translation string straight to
[4.121.0] refactor(domains): consolidate the per-filter dispatch of all 114 domain Services into one shared implementation with a granular override seam (Advisable-com/ecommercen#650)
- What was duplicated. Every domain Service carried its own private
buildSpecifications()— the filter loop, thematch ($filter->type…)operator mapping, theNotEmptybranch, the root-sort loop, pagination and theWithRelationstail. 114 copies in 19 slightly different variants, 108 of them repeating the same three-arm operatormatch. Changing one operator for one endpoint meant copying all 39 lines, and there was no smaller unit to override. - What replaces it.
Advisable\Domains\Support\Service\BuildsFilterSpecifications(a trait — this layer composes, it has no base Service class) owns the whole loop, andAdvisable\Domains\Support\Request\QueryListBuilder\FilterOperatorMapowns the one filter-type → SQL-operator mapping behindFilterOperatorMapInterface. Zero Services now declarebuildSpecifications(); the security-criticalWithRelations(..., $listRequest->visibilityExemptions)line exists in exactly one place instead of 114. - The silent-fallback defect is gone. Each of the 108 copies ended in
default => '=', so aFilterRequestTypecase a Service did not map degraded to an exact match with no error and no failing test —?filter[priceGt]=0compiled toprice = 0and returned precisely the rows the caller asked to exclude (#642). The sharedmatchis exhaustive with nodefaultarm, so an unmapped case throws instead, and a new static guard fails in CI before it can reach a query. - No behaviour change on any endpoint. Asserted on emitted SQL operators per Service, not inferred from a green suite:
/rest/product/product— the one endpoint that deliberately diverges from the shared mapping — still applies=to every root filter except its four one-sided price comparisons; the oneBetweendeclaration and the four comparison declarations keep their operators; the four CMS Services that previously had no operator dispatch at all are unchanged because every root filter they declare isExact; and the two CMS root keys whose declaration was corrected (below) keep the exact matching they always had. - Two filter DECLARATIONS corrected to match their long-standing behaviour — no response changes.
filter[title]onGET /rest/cms/builder(+/item) andfilter[bannerImage]onGET /rest/cms/page(+/item) were declaredFilterRequestType::Partialwhile the code had always applied an exact match — the Services emitted a bare two-argumentFilter, takingFilter::__construct's$operator = '='default, so the declared type never selected the operator on those keys. Both are now declaredExact, which makes the declaration, the OpenAPI description and the machine-readablex-filterstoken all agree with the behaviour that already shipped. Nothing over the wire changes: both keys still match exactly, still accept a comma-separated list as aWHERE IN, and the twofilterOperator()overrides that had been holding that line are retired as redundant. The translation-relationPartialkeys on the same endpoints (name.{locale},metaTitle.{locale}, …) are untouched — they route toFilterByTranslation, which applies a realLIKE '%…%'. Advisable\Domains\Support\Service\HandlesNotEmptyFiltersis removed; its two methods now live onBuildsFilterSpecifications, and theNotEmptydispatch is inherited rather than restated in every Service.
- What was duplicated. Every domain Service carried its own private
[4.121.0] fix(rest): declare
filter[vendorCode]on/rest/product/productasexact, matching what it has always run (Advisable-com/ecommercen#649)- The documentation was wrong, not the behaviour.
filter[vendorCode]was declared — and published, in thex-filtersmachine-readable spec and the human-readableOA\Parameterdescription — as a partial (substring) match from the day the field was added. It has never actually been one. This fix corrects the declaration and the docs to match what has always shipped; it changes no behaviour. A client that built a substring-matching expectation, or a client-side workaround, from the published spec was never actually getting a substring match — there is nothing to migrate, but it's worth checking whether that assumption shaped anything on your end. - Why it was inert.
Product\Product\Service::filterSpecification()buildsnew Filter($filter->field, $filter->values())forvendorCode— no operator argument — which takesFilter::__construct's$operator = '='default. ItsfilterOperator()override reads$filter->typein amatchfor a non-relation column, but every type other than the four one-sided comparisons (Gte,Gt,Lte,Lt) maps to'='— soExactandPartialboth resolve to the same operator-lessnew Filter($filter->field, $filter->values())call and the same predicate, and thePartialdeclaration did nothing for its entire life.vendorCodewas the only non-relation Product filter declaredPartial; the genuinely partial fields (name.{locale},metaTitle.{locale},barcode) are routed throughFilterByTranslation/FilterByBarcode, which do receive and use the type. - The three corrections:
src/Domains/Product/Product/ListRequest.php:26—'type' => FilterRequestType::Partial→FilterRequestType::Exact.src/Rest/Product/Controllers/Product.php:39(x-filters) —'type' => 'partial'→'exact'.src/Rest/Product/Controllers/Product.php:89(OA\Parameter) —'Filter by vendor code (partial)'→'Filter by vendor code (exact match)'.
- A scaffolding slip, not a decision: the field was declared
Partialsincebc1ac36ce1(2026-01-02), alongside genuinely free-text fields in the same batch, anda14a0ab024later transcribed it into the publishedx-filtersspec — so it became a published contract without anyone deciding it should be one. - Tests:
tests/Integration/Domains/Product/Product/ServiceVendorCodeFilterTest.php(new, 2 tests). Fixture rowsvendor_code='ABC','ABC123','XABC'are chosen so aLIKE '%ABC%'would match all three while= 'ABC'matches only one, so the test discriminates rather than merely passing. The second test builds the sameFilterRequestonce withtype: Exactand once withtype: Partial, both throughService::all(), and asserts identical result sets — the test that fires if the generic branch ever starts reading$filter->type.
- The documentation was wrong, not the behaviour.
[4.121.0] feat(rest/cms): add a guest-readable reel showcase endpoint,
GET /rest/cms/reel(Advisable-com/ecommercen#648)- The gap. The platform has two unrelated video showcases.
GET /rest/cms/videoprojects the legacy YouTubevideotable; the merchant's own CDN-hosted reels — the content behind the homepage reel strip and the/reelspage — live invideo_streamsand had no REST projection at all. A headless storefront had no way to render the merchant's actual reels and could only fall back to the YouTube table, which is entirely different content. - The change.
GET /rest/cms/reel(plus/item,/{id}and their(\w{2})/locale-prefixed twins) serves the merchant's active, promoted, fully-transcoded reels. Guest-readable, matching the two legacy read paths it replaces. Each reel carries its localizedname(resolved fromvideo_streams_muifor the request language — what the locale route twins select), server-resolved playback URLs (playlistUrl,playUrl,thumbnail,preview) andproductIds— the ordered ids of the products it features, in the merchant's authored sequence (video_streams_lp.order). Default order is newest first (-createdAt, homepage parity);sort=-updatedAtreproduces the/reelspage ordering. One filter,filter[id](exact). - Row invariant, not a filter. Every read — collection and fetch-by-id alike — is scoped to
active = 1 AND is_promo = 1 AND status = 'FINISHED' AND cdn_video_id IS NOT NULL, identical to both legacy read paths. It is enforced in the repository rather than as a client-settable filter, so/rest/cms/reel/{id}on a row failing any clause returns 404, indistinguishable from a missing row. - Products are ids, not an embed. There is deliberately no
?with=products: the platform's many-to-many relation loader cannot carry a pivot-table ordering, so an embed would have silently scrambled the merchant-authored sequence.productIdsalso lists only products a shopper can actually reach — active and not soft-deleted — so every id a client fetches from/rest/product/product?filter[id]=...is guaranteed to come back for that same caller. A reel whose merchant later deactivates a featured product simply lists one id fewer, and the remaining ids keep their authored order; zero-priced products are deliberately still listed, since they are predominantly gift SKUs a storefront must render. Backend callers receive every id in the lookup table, unscoped. - Playback URLs degrade, never error. All four URL fields come back
null— with a 200 and every other field intact — when no stream provider is configured or reachable. A client must treat a null URL as "not playable yet", never as an error. - Two small, purely additive companions.
GET /rest/featuresgains avideoShowcaseboolean (discovery-only — noguardedentry, since legacy uses the flag to hide the storefront section, never to 404 the data).GET /rest/storefront-configgains a fourth section,videoShowcase, carryinghomepageLimit(int, default 8 — the same literal legacy falls back to). It is advisory, not enforced: pass it back as?limit; the endpoint applies no hidden cap of its own, and the/reelspage deliberately does not inherit it. - Read-only by design: admin CRUD over reels, including the CDN upload lifecycle, stays in the legacy admin module.
- Tests:
tests/Integration/Domains/Cms/Reel/RepositoryTest.phpandServiceTest.php;tests/Unit/Domains/Cms/Reel/ServiceDecorationTest.php;StorefrontConfigProviderTestextended to the fourth section;tests/Unit/Domains/StorefrontConfig/VideoShowcase/VideoShowcaseConfigResolverTest.php.
- The gap. The platform has two unrelated video showcases.
[4.121.0] fix(domains): bind the
BETWEENfilter bounds instead of concatenating them (Advisable-com/ecommercen#645)- Why.
Filter::apply()'scase 'BETWEEN'(src/Domains/Support/Repository/Specification/Filter.php) built ONE SQL condition string of the formfield BETWEEN 'lower' AND 'upper'out of the raw client value. CodeIgniter protects the identifier in that position but passes an already-quoted literal through untouched, so neither bound was escaped nor bound. - Reachable with no credentials.
filter[inDates]onGET /rest/cms/blog/article(index,/item,/{id}, plus locale-prefixed twins) is the platform's onlyFilterRequestType::Betweenkey, and those actions are allauth => guest. - It composed with the forced storefront row scope
#624gave this same endpoint — which is why it was p0, not a nuisance. A payload of the1' OR 1=1 -- ,2026-12-31shape escaped its literal, and because SQL bindsANDtighter thanORthe wholeWHEREread as(is_published = 1 AND blog_date >= ...) OR (1 = 1)— always true. The forcedblog.is_published = 1leg was bypassed, so one unauthenticated request could enumerate every unpublished article, bodies included (BlogArticleMuiResourceemitsblog_mui.descriptionwith noisBackend()gate). - The fix. The key now compiles to the inclusive pair
field >= min AND field <= max— two ordinary boundwhere()calls, each value escaped by the driver as a separate literal — and theBETWEENkeyword is no longer emitted. MySQL and MariaDB defineexpr BETWEEN min AND maxas exactly that conjunction, three-valued NULL propagation included, so this is a rewrite of the emitted SQL and not of the semantics: row membership does not move. A well-formed range returns identical rows with identical inclusivity at both ends, a NULLblog_dateis still dropped, and a date-only upper bound still coerces to midnight exactly as before. No route, response field, filter key, sort key, auth rule, policy, status code or response shape changes anywhere, and backend callers are entirely unaffected — the fix is in how the bounds are bound, not in what is filtered or by whom. - Deliberately preserved: the fail-open on a malformed value. A
filter[inDates]that is not exactly two comma-separated bounds — one bound, or three or more — continues to emit no clause at all, and so continues to widen the read rather than being rejected. This is a decision, not an oversight: the platform has no 4xx path for a malformed filter value (a throw there would 500 a guest-readable endpoint), and inferring a one-sided bound from a single value would narrow a live guest-readable result set — a behaviour change, not a security fix. A null bound (e.g.[null, '2026-12-31']) is treated the same way, for a second, concrete reason:where('col >=', null)does not compile tocol >= NULL— CI3's_wh()rewrites it through its IS-NULL branch into the malformedcol > IS NULL— so "no bound supplied" has to mean no clause emitted at all. "Unchanged" here scopes to request-borne values, which is every value reachable through the API —FilterRequest::values()is a bareexplode(), so both bounds always arrive as strings and a null bound cannot occur. A caller that constructsFilterdirectly with a null bound does see a flip in direction: that pair used to emit the narrowfield BETWEEN '' AND 'upper', matching essentially no rows, and now emits no clause at all — wide. Both outcomes are correct for "no bound supplied"; a fork constructingFilteritself is the only caller positioned to notice. - Tests:
tests/Unit/Domains/Support/Repository/Specification/FilterRangeOperatorTest.phpgains aBETWEENsection (shape-level — the inclusive pair, the fail-open guard including both null-bound cases, and that neither bound ever reaches the key).tests/Integration/Domains/Support/Repository/Specification/FilterRangeOperatorSqlTest.phpgains aBETWEENsection against compiled SQL over a DECIMAL column, including the regression test for the exact... OR '1'='1'/ comment-terminator payloads that used to break out of the literal. Newtests/Integration/Domains/Cms/Blog/Article/ServiceInDatesFilterTest.php(11 tests) exercises the real, only live consumer end to end — a DATETIME column with a NULL-dated fixture row, and the composed bypass: a hostileinDatespayload proves the forcedis_published = 1scope from#624now survives alongside it.tests/Unit/Rest/Cms/Controllers/CmsStorefrontRowScopeTest.phpgains 4 tests pinning that same composed guarantee at the controller/ListRequest level.
- Why.
[4.121.0] feat(rest/storefront-config): expose the free-shipping banner threshold as a new
shippingsection (Advisable-com/ecommercen#643)- Why.
ESHOP.TRANS_COST_LIMIT— the "free shipping over €X" promo-banner figure a merchant edits in admin — was not reachable by a headless storefront.GET /rest/storefront-configexposed onlylistingandloyalty, so a headless client had no option but to hardcode the number at build time and let it drift. Measured on the WeCare tenant: the legacy storefront rendered 65.00 from the registry while a headless app hardcoded 49, promising free shipping €16 earlier than the merchant actually gives it, with no way to read the real figure off the wire. - The change. A third curated section,
shipping, carrying exactly one key:freeShippingThreshold(float, in the shop currency). Read live from the registry so an admin edit applies with no rebuild. Purely additive —listingandloyaltyare untouched, no existing key changes name, type or shape, and the endpoint stays guest, read-only and global. - DISPLAY VALUE ONLY — the point a consumer most needs to get right. It must never be quoted to a customer as a price, and never used to compute, predict or validate a shipping cost. The thresholds that decide what a customer is actually charged are the per-transporter and per-country rows in
transporters_options_pricing— a separate store that merely shares theTRANS_COST_LIMITname — applied byPOST /rest/checkout/shippingandPOST /rest/checkout/totals. A cart clearing the banner threshold can still be charged shipping, because the chosen courier sets its own limit. This mirrors the warning already carried onAdvisable\Domains\Checkout\ShippingCalculator, which is structurally unable to read the registry key (it has noRegistrycollaborator) and stays that way. - DECISION — a scalar scoped to the literal
'GR', not a per-country map and explicitly notconfig('default_country').Registry::value()'s third argument is thelangcolumn, which this key uses to hold a country code (an existing platform quirk, not corrected here). The admin UI both writes and reads only theGRrow (ecommercen/settings/controllers/Adv_settings.php:58and:119), whileInitialSeedseeds one row peravailable_countriesentry — so every non-GR row is frozen at its seed default and never reflects a merchant edit. A per-country map would publish stale seed data as merchant intent; adefault_countrylookup would serve that frozen value on a non-GR shop instead of the merchant's actual edit. - DECISION —
0means no threshold configured, and the resolver fails closed to it.Registry::value()has no default parameter and returns null for a missing key, so the value is cast at the boundary exactly asLoyaltyConfigResolverdoes. No default threshold is ever invented: a fabricated number here would be a wrong customer-facing money claim, which is the whole defect being fixed. A client receiving0should show no free-shipping promise at all. - DECISION — raw value, no currency conversion. Legacy views wrapped
trans_cost_limit()inapplyCurrency(); the settled precedent for this contract isloyalty.rewardCashPerUnit— expose the raw number and state "in the shop currency" in the OpenAPI description. - NOT ADDED, recorded so its absence is not read as an oversight: a second key
freeCodThresholdwas considered and dropped.DELIVERY_COST_MIN_FREEexists only as a per-transporter, per-countrytransporters_options_pricingrow — there is noESHOP.DELIVERY_COST_MIN_FREEregistry key anywhere — so there is no global display value to expose, and synthesizing one would have re-created the display-versus-charged confusion above. - Also corrected two stale comment references to
trans_cost_limit(), which lives atecommercen/helpers/cart_helper.php:98-102(comment text only, no logic change), since the new resolver's docblock cross-references that exact block. - Tests:
tests/Unit/Domains/StorefrontConfig/Shipping/ShippingConfigResolverTest.php(new — float cast, fail-closed0.0, explicit-zero, single-key shape, and an argument assertion pinning the('ESHOP', 'TRANS_COST_LIMIT', 'GR')read);StorefrontConfigProviderTestwhole-payload contract assertion extended to the third section and kept strict;tests/Integration/Domains/StorefrontConfig/ContainerTest.phpgains a registration guard (a dropped->arg('$registry', null)fails at container compile) and a live contract assertion.
- Why.
[4.121.0] feat(rest/product): server-side price bounds on product reads —
filter[priceGte],filter[priceGt],filter[priceLte],filter[priceLt](Advisable-com/ecommercen#642)- Why.
filter[price]onGET /rest/product/productis an exact match, so a headless storefront could not express a price range — or even "priced above zero" — server-side. It had to fetch a page and filter client-side, which does not merely cost a round trip: a price facet computed over a page-limited result set on a 1,000-product category is simply wrong. The endpoint already allowedsort=price, so the column was orderable but not boundable. - The change. Four new filter keys, all targeting
shop_product.price, in two inclusive/exclusive pairs:priceGte(price >= v),priceGt(price > v),priceLte(price <= v),priceLt(price < v). Any one may be sent alone; a lower and an upper bound together define a range. Five keys coexist on one column because the comparison type lives on the key, not on the column — which is also why the exactpricekey is untouched. - Why both an inclusive and an exclusive pair. The pairs differ only at the boundary value, which is exactly where the interesting query lives.
filter[priceGt]=0is the supported way to ask for only non-zero-priced products — the real use case, hiding the zero-priced gift SKUs from a recommendation strip. With only the inclusive pair a client would have to writepriceGte=0.0001, encoding the column'sdecimal(11,4)scale into every consumer and breaking silently if that scale ever changed. - DECISION —
filter[price]was NOT flipped toBetween. That was the obvious-looking alternative and it is a trap twice over. It would have been a no-op:Product\Servicenever read$filter->typeat all, building everyFilterwith the constructor's'='default. And it is silently breaking:Filter'sBETWEENbranch requirescount($value) === 2, so a single-valuedfilter[price]=9.99would emit no clause, no error and return the entire catalogue.filter[price]therefore keeps its exact semantics completely unchanged, pinned by its own regression test. - DECISION — NULL-priced rows are EXCLUDED once any bound is supplied.
shop_product.priceis genuinely NULLable (decimal(11,4) DEFAULT NULL, no migration alters it) andNULL >= xis NULL, never TRUE, so the row drops with no clause of its own — the effect legacy gets fromprice > 0. NoOR price IS NULLis added. Asserted deliberately rather than inherited, so a future reader knows it was chosen. - DECISION — a non-numeric bound is dropped for THAT KEY ONLY. Guarded with
is_numeric()inProduct\Service; every other filter, including a bound on the other side, still applies and the response is still200. There is no 4xx path for a malformed filter value anywhere on this platform — an unrecognised key is already dropped silently, andGenerateListRequest's throws are developer-error paths whose own comment notes that a client-facing throw "would 500". Dropping only the offending key also avoids theBETWEENfailure mode, which drops the whole predicate and widens the read. - NOT a visibility change, and specifically not a reversal of
#613. No price predicate is added to any visibility scope or forced filter,ProductVisibilityScopeis untouched, and zero-priced products remain fully readable by a storefront caller by default — they are predominantly gift SKUs a storefront must render. This is filter expressiveness;priceGt=0is the client's per-request opt-in. - NOT legacy browse-gate parity — and
priceGt=0makes that easier to misread, so it is worth stating. Legacy's gate isactive = 1 AND soft_delete = 0 AND price > 0 AND (stock > 0 OR negative_stock = 1). The price leg is now expressible; the stock leg is not, and this branch does not change that:shop_producthas nostockcolumn (stock lives onproduct_codes.stock, one-to-many),/rest/product/productexposes no stock filter,/rest/product/product-code's ownfilter[stock]is alsoExactrather than a bound, and anORspanning two tables cannot be expressed in a vocabulary that ANDs every declared key (see the comment atGenerateListRequest.php:258-263). - NOT the legacy price facet either. Legacy price-range browsing filters
shop_prices_view.final_price— the post-discount price — notshop_product.price(ecommercen/eshop/controllers/Adv_product_categories.php:1157-1164, gated on registryOTHER/ENABLE_PRODUCT_PRICE_RANGES). Bounds over the raw price column are therefore a new capability, not a port: a client reproducing the legacy facet with these keys will disagree with legacy on every discounted product. A post-discount bound needs a different underlying column and is not in this change. - Shared platform surface, additive.
FilterRequestTypegainsGte/Gt/Lte/Lt;Filter::apply()gains matching'>='/'>'/'<='/'<'branches. All four pass the bound aswhere()'s second argument with the operator on the key, so CI3 escapes it and appends it as a separate literal — the safe shapeFilterByDateChangedSincealready uses. The unsafeBETWEENbranch (which concatenates its values into the condition string, where_compile_wh()passes an already-quoted literal through untouched) is deliberately left alone; it is tracked as#645. All 107 domain services that map filter types carry adefault => '='arm, so the new cases are inert for every domain that has not declared them. - Tests:
tests/Unit/Domains/Support/Repository/Specification/FilterRangeOperatorTest.php(new — call-shape guard for all four operators, plus'='regression),tests/Integration/Domains/Support/Repository/Specification/FilterRangeOperatorSqlTest.php(new — compiled-SQL escaping proof per operator against a real connection),tests/Integration/Domains/Product/Product/ServicePriceBoundFilterTest.php(new — the endpoint semantics: boundary pairs, NULL exclusion, non-numeric drop,filter[price]regression) and a#642guard added totests/Unit/Rest/Product/Controllers/ProductScopeTest.php(a client bound survives the storefront scope and the scope still forces exactlyactive+softDelete).FilterRequestType::Betweenhad zero coverage before this, so these are the first tests in the area.
- Why.
[4.121.0] test(domains/support): add a positive invariant requiring every relation onto a scope-bearing repository to declare a
visibilityScope, closing the blind spot the existing allow-list inventory guard has always had (Advisable-com/ecommercen#641)- The new test.
RelationConfigurationTest::test_every_relation_onto_a_scope_bearing_repository_declares_a_visibility_scope()is new: every relation targeting the Product repository must now declare a non-nullvisibilityScope, unless explicitly exempted with a stated reason. Today that's 17 relations onto Product — 10 scoped, 7 deliberately exempt (each backend-only, or inProductCode.product's case a pinned, already-justified exception for cart/order VAT rendering). A second new test,test_the_invariant_reports_a_relation_whose_visibility_scope_went_missing(), proves the guard actually fails on a missing scope rather than assuming it — it feeds the detection routine a hand-built fixture reproducing the real hazard (a fork's staleLine.productscopy) alongside relations that must not be flagged. - Why this is a second, complementary guard, not a duplicate. The existing
test_only_the_reviewed_relations_declare_a_visibility_scope()is an allow-list: it collects only the relations that DO declare a scope and pins that exact sorted set. A relation that loses its scope contributes nothing to the collected set — the set is simply unchanged, and the allow-list test stays green. That's the gap this delivery closes: the allow-list catches an unreviewed addition, the new positive invariant catches a removal. Neither subsumes the other; both stay. - Why a fork sees this failure and upstream doesn't. The relation walk now also covers
custom/Domainswhen that directory exists — it doesn't in the main repo, so this is inert here, but in a fork it puts the fork's ownCustom\…\RepositoryConfiguratorclasses inside the invariant for the first time. Those are aliased over their upstream counterparts incustom/Domains/container.php, so they're the classes the DI container actually serves. If a fork's copy of a configurator is a pre-#637/#613snapshot, it constructs fine, autowires fine, boots the container fine, and returnsvisibilityScope === null— exactly the shape#637,#639and#640each warned about in their own "Check for overrides" notes. This guard turning red on a fork is therefore not test noise: it means that fork is a real, live leak of hidden or unpublished rows to guests. - How to fix it. Re-sync the fork's copy of the flagged configurator against the current
src/version — pass the relevant scope class into the configurator's constructor, and handrelationScope()to theRelation'svisibilityScope:argument as a named argument (Relation::__constructputs$visibilityScopetenth; a positional tenth argument lands silently in$pivotTableinstead). Do not add the relation tovisibilityScopeExemptions()to make the test pass — that list exists only for relations with no client-reachable path, each with its own stated justification, and a stale copy of a relation upstream scoped for a reason is not that. Exempting it re-opens the hole this guard exists to catch and silences the alarm along with it. - Known limitation, recorded in-test. The exemption list's six "backend-only owner" entries are snapshots of
rest_policies.phpat review time — the guard does not re-read that config, so an exemption goes stale if its owning endpoint is later opened to guests, the way#612did forLine. The escalation path if that's ever suspected is deriving the requirement fromrest_policies.phpdirectly rather than trusting the hard-coded list. - No production code, no migration, no REST contract change, no new config key. Test-only.
- The new test.
[4.121.0] fix(rest/cms): scope
Article.categories,Article.tags,BlogCategory.children/.parent(recursive) andBlogCategory.articlesfor storefront callers, closing the most severe depth-bypass#624left open on the blog endpoints (Advisable-com/ecommercen#640)- Why.
#624scoped thecms/blog/article,cms/blog/categoryandcms/blog/tagendpoints themselves and denied?sort=categories.active/?sort=tags.active/?sort=children.active, but — the same#637lesson the#639fragment restates — an endpoint's forced filter scopes only that endpoint, so the five relations above kept walking straight back into hidden rows.BlogCategory.articleswas the severe member, and was fixed first:GET /rest/cms/blog/category?with=articles.translationsis guest-readable andindex()embedsarticleson every row, so one unauthenticated request returned theblog_muirows of every unpublished article — the complete pre-publication body of every draft, bulk-enumerable — becauseblog.is_publishedisNOT NULL DEFAULT 0.Article.categories/.tagsandBlogCategory.children/.parentcarried the matching hole for inactive taxonomy. - The fix, four new stateless scope classes.
BlogCategoryVisibilityScope(blog_categories.is_active = 1) is declared onBlogCategory.childrenandBlogCategory.parent(both recursive — one declaration covers every depth including the?with=children*form) and onArticle.categories;BlogTagVisibilityScope(blog_tags.is_active = 1) is declared onArticle.tags;ArticleVisibilityScope(blog.is_published = 1) is declared onBlogCategory.articles. A per-endpointrelationsallow-list could not have closed any of these:RelationFilterMiddlewarematches only the first?with=hop, so a chain like?with=categories.articles.translationsor?with=articles.categoriesis untouched by anything registered on the entry endpoint. - Two taxonomy scope classes, not one parameterised by table — deliberately, and this is the one asymmetry to read carefully before assuming the two behave alike.
blog_categories.is_activeandblog_tags.is_activeare bothint(2) DEFAULT NULL, so this delivery makes a real decision — forcing= 1withholds a NULL-flagged row from storefront callers along with an explicitly-0one — but the justification differs, and#624's own changelog entry already recorded the two tables separately for the same reason: tags are straight parity with legacy —Adv_blog_tags_model::getTagsFront()already selectsis_active = 1, so an inactive or NULL-flagged tag was already invisible on the legacy storefront — while blog categories are a deliberate tightening: the legacy blog-category storefront never filtersis_activeat all (Adv_blog_category_model.php'sgetCategoriesFront()and every one of its five legacy call sites passlangonly), so inactive and NULL-flagged categories are reachable on the legacy storefront today and stop being served over REST here. NULL is treated as not-visible on both, which matches legacy wherever it tests the flag at all (is_active = 1, never!= 0). One shared class would have collapsed two independently-ratified justifications into a single hedged docblock; each class states its own instead. - Own-flag, not an ancestor chain — measured at zero for this tree.
BlogCategoryVisibilityScopetests each row's own flag rather than every ancestor's. Across 14 tenant databases there are zero active-but-unreachableblog_categoriesrows: the tree is essentially flat (max depth 2 in only 2 of 14 tenants, no nesting at all in the other 12), and the single inactive parent that has children has zero active children, so both rules agree even there. - No sort denial added on
BlogCategory.articles, and the audit is recorded rather than assumed.BlogCategorypublishes fourarticlesrelation sorts (id,date,hits,authorId) and none of them exposes a visibility flag — all four go inert as an ordering oracle the moment the relation is scoped, since a row that is no longer returned cannot be ordered by. Denying an already-inert sort would be over-denial, the same call#624made oncms/documentandcms/blog/tag. ArticleVisibilityScopelives at theArticledomain root rather than nested underBlog\Category, and is registered public and autowired specifically so#625's Product\Category configurator can resolve it cross-module — it is the article scope#625has been blocked on. Do not move or narrow its visibility without checking#625first.- Tests:
CommentVisibilityScopeTestupdated forArticleConfigurator's new constructor parameters;RelationConfigurationTest's declared-visibility-scope inventory extended to cover all five relations here.
- Why.
[4.121.0] fix(rest/cms): scope
Page.parentandPage.children(recursive) for storefront callers, closing the depth-bypass#624left open onGET /rest/cms/page(Advisable-com/ecommercen#639)- Why.
#624scoped thecms/pageendpoint itself — forcingfilter[isPublished]=1, gatingshow()— and denied?sort=children.isPublished, but recorded both fixes as endpoint-local: an endpoint's forced filter scopes the endpoint it is registered on and nothing else (the#637lesson), so?with=childrenand?with=children.translationskept walking straight back into unpublished rows.categories.is_publishedisNOT NULL DEFAULT 0, so every unpublished page ever created was reachable through those two hops, and becausePageMuiResourceemitscategories_mui.fulltextwith noisBackend()gate,?with=children.translationsreturned the complete pre-publication BODY of every unpublished page — not merely its existence — bulk-enumerable in one guest request sinceindex()embedschildrenon every row.Page.parentcarried the identical hole in the other direction: an unpublished ancestor readable from a published child. - The fix. A new
PageVisibilityScope(categories.is_published = 1) is declared on bothPage.parentandPage.childreninCms\Page\Repository\RepositoryConfigurator. One declaration per relation covers every nesting depth automatically, including the recursive?with=children*form — the relation-name match strips the*marker, so the walk cost is no longer attacker-chosen. A per-endpointrelationsallow-list could not have closed this:RelationFilterMiddlewarematches only the first?with=hop. - Own-flag, not an ancestor chain — and measured, not assumed. The scope tests each row's own
is_publishedflag, not whether every ancestor up the tree is also published, so a visible child under a hidden parent would remain visible in principle. Across 14 tenant databases there are zero unpublishedcategoriesrows that have children — a real measurement, not a vacuous one, since the page tree is genuinely nested in this data (two tenants reach depth 3, one with 119 of 125 rows nested). If a visible-child-under-hidden-parent row ever appears, the ancestor-chain treatmentProduct\Category\CategoryVisibilityScopeuses is what this would have to grow into. - Accepted parity delta, not a straight port. Legacy is internally inconsistent here:
Adv_categories_model::get_content($slug)gatesis_published = 1, so legacy never renders an unpublished page BODY on any path, whileget_childs()does not gate it and is live storefront code for both sibling and child navigation. So REST returning unpublished page bodies throughchildren.translationswas more permissive than every legacy path, not at parity with one — closing it is a correction, not a new restriction relative to some legacy baseline. The genuine cost is narrower: REST now also stops listing unpublished nodes in a page tree where legacy'sget_childs()still lists them (just without their bodies). A client using those nodes for an editorial-preview navigation must move that to a backend token. - No sort denial needed here beyond what
#624already shipped —?sort=children.isPublishedwas already denied for storefront callers; this delivery closes the row set that denial's oracle was standing in for. - Tests:
tests/Unit/Domains/Support/Repository/RelationConfigurationTest.php's declared-visibility-scope inventory is extended to coverPage.parent/Page.children.
- Why.
[4.121.0] fix(rest/product): declare
ProductVisibilityScopeon nine relations that walk back into the catalogue — eight guest-reachable, one (Wishlist.product) customer-reachable — closing the depth-bypass#613left open on every guest- and customer-reachable path (Advisable-com/ecommercen#637)- Why.
#613gaveProductrow-visibility scoping —active = 1,soft_delete = 0— but aRelation::$visibilityScopedoes not cascade across relation definitions, so itsLine.productsdeclaration protected exactly one path back into the catalogue.#613's own changelog entry named the gap and enumerated it under#618: several other relations still walked straight into the unscoped catalogue. Nine of those returned inactive and soft-deleted products to a caller with no legitimate reason to see them — eight to a completely unauthenticated guest, one (Wishlist.product) to any authenticated customer — the very rows#613had just removed fromGET /rest/product/productitself, still reachable one relation hop away. This declares the same scope on all nine. - Not a claim that the Product relation surface is now complete.
Download.product,ProductList\ProductLp.product,ProductMeta.product,Related.productandRelated.relatedProduct,WaitingList.product, andProductCode.productremain unscoped and stay tracked under#618. Six of the seven are genuinely backend-only — no storefront exposure.WaitingListadditionally declares'store' => ['auth' => 'customer']inrest_policies.php, but that's a write serving no?with=embed, so the read conclusion is unchanged.ProductList\ProductLp.productwas re-derived graph-wise, not by policy inheritance: no configurator anywhere declares a relation ontoProductList\ProductLp\Repository\Repository, so it has no inbound relation edges and its only exposure is its own backend endpoint — reachability is a property of the relation graph, not of the owning endpoint's policy, which is the exact error this issue exists to correct. The seventh,ProductCode.product, is not backend-only and is the one member of this list a storefront caller can actually reach: guest and unconditional, throughCart::CART_RELATIONS's hard-codeditems.productCode.productwalk on every cart render (Cart::classdefaults'auth' => 'guest') with no?with=involved; guest depth-2 through theproductCodesrelationProduct\Repository\RepositoryConfiguratordeclares (Productindex/show/item are guest with norelationsallow-list); and customer depth-3 throughOrder's'auth' => 'any'default ontobasket.productCode.product. It is nonetheless correctly left unscoped, deliberately rather than by oversight:CART_RELATIONSnestsvatunderproduct, so scoping it would null the exact objectCartTotalsCalculatorreads for VAT resolution (product.vat, the#563PRICING_RELATIONSsuperset) and blank checkout line rendering. Cart and order lines are transaction records that must keep rendering a product the caller already transacted on regardless of its current catalogue visibility — the opposite of a wishlist, a pre-purchase catalogue bookmark, which does take the scope. This delivery closes the guest- and customer-reachable paths that should close, not the whole surface. - The nine relations closed, each in a
RepositoryConfigurator.phpundersrc/Domains/:Cms\Blog\Article\Repository→productsEvent\Event\Repository→productsCms\Video\Repository→productsProduct\Promo\Repository→productsProduct\Variation\Value\Repository→productsProduct\Variation\Repository→productProduct\Review\Repository→productProduct\PriceTracking\Repository→productProduct\Wishlist\Repository→product
- Nine direct endpoints. Eight are guest-readable on index/show/item:
/rest/cms/blog/article?with=products,/rest/event/event?with=products,/rest/cms/video?with=products,/rest/product/promo?with=products,/rest/product/variation?with=product,/rest/product/variation-value?with=products,/rest/product/review?with=product,/rest/product/price-tracking?with=product. The ninth,/rest/product/wishlist?with=product, is reachable only by an authenticated customer —index/showare'auth' => 'any'inrest_policies.php— with a narrower blast radius: theWishlistcontroller already restricts non-backend callers to their own rows (#418), so a customer could see a hidden product only on their own wishlist. - Nested paths — the whole reason the rule lives on the relation definition rather than per endpoint.
#613already scopedProductitself, so the same fix closes/rest/product/product?with=events.products,?with=videos.products,?with=variationValues.products,?with=articles.products,/rest/product/vendor?with=videos.products, and the depth-3/rest/product/product?with=vendor.videos.products. None of those could have been closed by a per-endpointrelationsallow-list:RelationFilterMiddlewarematches only the first?with=hop, which on every path above isevents,videos,variationValues,articlesorvendor— neverproducts. - Surfacing shape follows the relation type, unchanged from how a missing product already renders: on the five
MANY_TO_MANYproductscollections a hidden product simply drops out of the array (shorter list, possibly empty, no key removed); on the fourBELONGS_TOproductembeds (Variation.product,Review.product,PriceTracking.product,Wishlist.product) a hidden product serializes asnull— the same shape a row with noproduct_idalready produces. The owning row itself is not filtered: a review of a now-hidden product is still returned, withproduct: null. - Backend callers are entirely unaffected, on every one of the nine, at every nesting depth — the scope is suppressed centrally by the single
Relation::VISIBILITY_EXEMPT_ALLgrantHandlesRestfulActionsapplies whenever the requestResourceContextis backend. That central grant is exactly whyvisibilityScopeis the correct slot here rather than the unsuppressiblescope: an admin moderation, restock or trash-recovery screen embedding a product through any of these nine relations must keep seeing inactive and soft-deleted rows, and folding the rule intoscopewould have blinded those screens with no way to opt out. price > 0is deliberately NOT scoped, unchanged from the#613decision. Zero-priced products are predominantly gift SKUs: measured across real tenants, 619 of pharm16's 1,070 zero-priced rows sit in a gift pool (88% of that tenant's 703-product pool), 1,241 of discount's 2,496, 494 of demo's 521. "We don't sell zero-priced products unless they're gifts, shown as gift options" is a checkout constraint enforced by the gift engine, so hiding the rows here would break the exception rather than implement the rule — a headless storefront still needs them to render a gift option's name and image.active = 1already removes zero-priced debris;shop_product.priceis NULLable, so a price clause would additionally drop every NULL-priced row. This was owner-ratified on#613and carries a regression test (test_a_zero_priced_active_product_is_still_returned()) asserting a zero-priced active product is still served through all nine relations, for a non-backend caller (guest or authenticated customer).- Not done here, deliberately: row-level scoping of the owning entities. A review, price-tracking row, variation value or wishlist entry that belongs to a hidden product is still listed in full — only the embedded
product/productsrelation is scoped. Tracked separately under#624,#625,#626and#627. - Tests: new
tests/Unit/Domains/Product/Product/ProductRelationVisibilityScopeTest.php(9 test methods; 8 run against all nine relations via ascopedRelations()data provider — 72 cases — covering the declaration, the exact staged clauses, named-argument placement, guest vs. backend row sets, a named-relation exemption, the gift-SKU guard, and a no-price-clause guard; the ninth runs the six nested?with=paths above through a real loader at depth).tests/Unit/Domains/Support/Repository/RelationConfigurationTest.php'stest_only_the_reviewed_relations_declare_a_visibility_scope()inventory guard is extended from the five entries#588/#613/#616left it at to the full fourteen, tagging each with its originating issue so the interleaved sort order stays traceable.tests/Unit/Domains/Cms/Blog/Comment/CommentVisibilityScopeTest.phpis updated forArticleConfigurator's new constructor parameter.
- Why.
[4.121.0] fix(rest/product): emit
isSensitiveto storefront callers onProductCategoryResource(Advisable-com/ecommercen#636)- Why.
CategoryResourcewithheldisSensitivefrom guest and customer callers behind theisBackend()gate, while legacy treats the underlying flag as load-bearing across three independent consumption channels, not one chain: (1) analytics suppression — the category page publishes it asisSensitiveCategory(Adv_product_categories.php:385), consumed only byapplication/views/production/google_header.php:72andgoogle_footer.php:47to gate remarketing/analytics tags on sensitive categories; (2) menu rendering — a separate key,is_sensitive, is selected and carried by the menu-tree builder (Adv_product_category_model.php:1308,:1331,:1384) and read by both storefront themes (application/views/default/components/library/header/main_menu.php:34,:50andapplication/views/main/components/header/main_menu.php:435,:451); and (3) row exclusion — the child-listing query excludes sensitive rows server-side (Adv_product_category_model.php:957), a row-scoping question this fix deliberately does not address (see Notes below). REST was the outlier: it declaredisSensitiveas an allowed filter and sort while withholding the value itself, and a headless storefront built against REST alone had no way to reproduce legacy's sensitive-category presentation. The issue itself presented a tempting but wrong alternative reading — the shape (hidden yet filterable/sortable) matches the#618family exactly, so a developer working#625could reasonably reach forwithDeniedFilter()here and harden the regression instead of fixing it. Verified against legacy and rejected: three independent, live consumption channels make the flag load-bearing, not vestigial. - The change.
isSensitivemoves out of theisBackend()block into the always-emitted base payload inCategory/Resource.php::resource(), cast(bool)as before. A comment at the emission site records the two theme paths so a future reader working the#618family does not "re-close" it.ProductCategoryResource'sOA\Schemaalready declaredisSensitiveunconditionally (only the runtime gate withheld it), so no OpenAPI annotation change was needed. - NO
withDeniedFilter()/withDeniedSort()is added forisSensitive, and that is deliberate. The#618family denies a filter or sort over a column a Resource withholds, because an allowed filter/sort over a hidden column is a content oracle — the response leaks the value indirectly even though the field itself is invisible. OnceisSensitiveis legitimately part of the public payload there is no withheld-column left to oracle over: filtering or sorting on a field the caller can already read in the same response is ordinary API surface, not a leak. It remains filterable (src/Domains/Product/Category/ListRequest.php:24) and sortable (:99), which was already true and is now simply consistent with the field being visible. A future reader must not read this endpoint as unfinished and add a denial here. - The four sibling fields do NOT share one answer — each was checked against legacy individually, because the issue explicitly warned against assuming they match
isSensitivejust because they sit in the sameisBackend()block:order— legacy carries it as a named key in the same menu-tree payload, so it is a parity gap too, but no storefront template dereferences it (it is the menu's ordering contract, not rendered data), and it is already both filterable and sortable so a client can order by it without reading it. Emitting it is a defensible separate change; bundling it here would widen the payload diff with no demonstrated consumer. Not emitted.sliderId,menuSliderId,extraSliderId— genuine withheld columns, not regressions. Legacy resolves each one server-side and publishes only the resolved slider object, never the raw id (Adv_product_categories.php:668-678,:447-480, andAdv_product_category_model.php:1631-1651—getTopLevelSliders()— formenuSliderId);menuSliderIdis not even selected into the menu tree. REST withholding the id is already correct. Stay backend-only, unchanged.
- Tests:
tests/Unit/Rest/Support/Resources/ScopeFilteringTest.php—isSensitivemoves fromCATEGORY_BACKEND_ONLYtoCATEGORY_PUBLIC, pinning the exact split.tests/Unit/Rest/Product/Resources/Category/ResourceTest.phpgains storefront-context coverage that did not exist before (every prior test in that file ran inbackendcontext only, which is why nothing caught the regression): two new tests assertisSensitiveis present and correctly cast for bothSCOPE_PUBLICandSCOPE_CUSTOMER, and thatorder,sliderId,menuSliderIdandextraSliderIdstay absent in both.
- Why.
[4.121.0] feat(transporters): support BoxNow mass voucher printing (Advisable-com/ecommercen#632)
- The gap. BoxNow had single-voucher printing but not batch printing.
$config['transportersSupportingBatchVoucherPrint']inapplication/config/app.phpdid not listBOXNOW, and the admin order-list "Μαζική εκτύπωση vouchers BoxNow" bulk action renders only for transporters on that list — so operators printed BoxNow shipping labels one order at a time regardless of how many orders they selected. - What changes.
BOXNOWis added totransportersSupportingBatchVoucherPrint. Selecting orders in the admin order list and running the BoxNow mass-print action now returns a single PDF containing one shipping label per selected order that has a voucher (gtcode).AdvPrintVoucher::printBatch()gains aBOXNOWcase delegating to newprintBatchBoxNowVoucher(), which collects the selected orders'gtcodes and calls newTransporters\BoxNow\BoxNow::getLabels(array $parcelIds): ?string.getLabels()calls the carrier'sPOST /labels:searchendpoint, which returns the multi-label PDF directly — no client-side PDF merging. - Two new BoxNow settings control the sheet layout, both read by
getLabels()fromBoxNowConfigrather than passed in by the caller, since layout is a per-transporter setting, not a caller concern:- Paper size —
A4(default) orA6— reg_keyPAPER_SIZE. - Labels per page —
1,2, or4(default4) — reg_keyLABELS_PER_PAGE. Both are declared inBoxNowConfig::getFieldConfiguration(), read inBoxNowConfig::initialize(), backed by newBoxNowHelper::DEFAULT_PAPER_SIZE/DEFAULT_LABELS_PER_PAGEconstants andpaperSizes()/labelsPerPage()option lists, and exposed as two new dropdowns onapplication/views/admin/transporters/settings/boxNowSettings.php. The view reaches the option lists through two newtransporters_helperfunctions rather than callingBoxNowHelperstatically, matching thegetTransporterDeliveryOptionTypes()/getParcelSizeDropDown()dropdowns already in that file. The same fields feed the SaaS provisioning wizard, which reads the transporter's field configuration the same way.
- Paper size —
- Locale. New key
eshop.admin.transporters.labelsPerPagein all 8 locale files. The paper-size label reuses the existingeshop.admin.transporters.paperSizekey rather than adding a duplicate. - Upgrade-safe default. An install that has never opened the BoxNow settings screen still prints correctly, so no admin visit is required after upgrade. Two mechanisms are needed because two different states have to be covered.
BoxNowConfig::$paperSize/$labelsPerPageare declared with theBoxNowHelperdefaults (A4/4), which covers a transporter with no stored settings at all —__construct()callsinitialize()only when settings exist, so nothing inside it would run. Andinitialize()falls back with?:on top of that, which covers a row that is stored but empty:BaseTransporterConfig::filterObject()'s default argument fires only when the row is absent, never when itsreg_valueis'', andsaveBOXNOWSettings()writes''for any key missing from the POST — as happens on a fork whose own copy of the settings view lacks the two new dropdowns. - Values are validated on write.
validateSettingsBOXNOW()appliesin_list[A4,A6]andin_list[1,2,4]alongsidetrim, so an out-of-list value cannot be stored through the admin form and reach the carrier aspaperSize: ''orperPage: 0. The config passes stored values through unmodified on read. Both dropdowns printform_error()like the eight fields above them: these two rules are the only ones in this form that can fail, andsaveBOXNOWSettings()runs only whenform_validation->run()passes, so without the error output a rejected value would discard the whole submit — client id and secret included — with no signal on the re-rendered page. - BoxNow requests can now be given a timeout.
BoxNow::buildOptions()has always read$this->config->timeoutand applied it asconnect_timeoutandtimeoutwhen greater than zero, but no config declared that property, soBaseTransporterConfig::__get()returnednulland the branch was unreachable — every BoxNow request ran with Guzzle's unbounded default.BoxNowConfig::$timeoutnow declares it, which makes the guard live. It defaults to0, so behaviour is unchanged out of the box and no existing call gains a timeout it did not have. Raising that one value is the whole seam:BoxNowsets no per-request timeout of its own, so the value bounds every call including the batch/labels:searchone, whose duration is the only one that scales with the size of the selection. The property is a code-level default only — not read ininitialize(), no reg_key, nogetFieldConfiguration()entry — so it appears in neither the admin form nor the SaaS wizard. - Deliberately one request, no chunking. The whole selection's
gtcodes are sent to/labels:searchin a single call. BoxNow's API documentation is silent on any maximum parcel count for that endpoint, but a live deployment has printed 200 orders in a single call successfully, so the endpoint comfortably handles realistic batch sizes. Unlike ACS (chunks of 10) and Center (chunks of 20), no chunking is applied here: 200 is a tested floor rather than a known ceiling, and adding a PDF-merge step before the real limit is known would be speculative — the existingmergePdfs()helper hardcodes ACS label geometry and is unusable for A4 anyway. - Unchanged. Single-voucher BoxNow printing —
AdvPrintVoucher::printBoxNowVoucher()— is untouched.
- The gap. BoxNow had single-voucher printing but not batch printing.
[4.121.0] fix(admin/orders): resolve the smart-point validation label to its defined language key (Advisable-com/ecommercen#631)
- The typo.
ecommercen/eshop/controllers/Adv_orders_admin.php:690registered the smart-point validation rule with the singular keyeshop.admin.order.select.smart.point—set_rules('smartPointJsonData', t('eshop.admin.order.select.smart.point'), 'trim|required|callback_smartPointJsonDataCheck'). The key is defined only in the plural,eshop.admin.order.select.smart.points, in all 8ecommercen/language/<lang>/adv_advisable_lang.phpfiles (chinese/english/french/german/greek/russian/spanish at line 449, italian at 450), so the lookup always missed. An admin hitting this validation saw the raw dotted key rendered as the field label instead of the translated string (e.g. english'Select a smart point', greek'Επιλέξτε ένα smart point'), andsystem/core/Lang.phploggedCould not find the language line "eshop.admin.order.select.smart.point"once per occurrence. Admin-facing and cosmetic — no data, money, or contract impact. Observed live on a 4.120 client the first time an admin hit that validation. - The fix. One character: the call site now asks for the plural key,
t('eshop.admin.order.select.smart.points'). That is the entire diff — one line, one file. No language file was touched. - Why the call site moved rather than the language files. The plural key's value is already singular prose in every language (e.g.
'Select a smart point', not'Select smart points') — only the key name is pluralised — so the call site reads as the typo, not the translations. Adding a ninth, singular key to all 8 files would leave two keys with identical meaning, which is the worse outcome. - Reachability. The rule is registered only when
sameaddressisORDER_ADDRESS_BILLING/ORDER_ADDRESS_SHIPPINGandsmartPointJsonDatais non-empty (Adv_orders_admin.php:686-691) — i.e. on a real admin smart-point selection, on the order add / edit / repeat flows. - Tests: none added. There is no PHPUnit harness covering this controller's validation-rule label registration —
tests/Legacy/Eshop/AdvOrdersAdminHooksTest.phpcovers hooks, not label resolution — and standing one up for a one-character key correction is disproportionate.
- The typo.
[4.121.0] fix(captcha): register a proper
captchaCheckvalidation message and field label (Advisable-com/ecommercen#630)- The leak, not a blank message.
GoogleRecaptchaTrait::setCaptchaValidationRule()registeredset_rules('g-recaptcha-response', '', 'trim|required|callback_captchaCheck')— an empty field label and noset_message()call anywhere in the trait, and noform_validation_captchaChecklanguage line anywhere in the tree.Form_validation::set_rules()defaults an empty label to the field name (system/libraries/Form_validation.php:159-160), so{field}resolved to the raw slugg-recaptcha-response, not a blank.CI_Form_validation::_get_error_message()(system/libraries/Form_validation.php:658-672) falls through per-field errors →set_message()→lang->line('form_validation_captchaCheck')→lang->line('captchaCheck', false)→ and finally returnslang->line('form_validation_error_message_not_set') . '(captchaCheck)'. That last key is defined, in all 8application/language/*/form_validation_lang.php:32, so a visitor who failed the captcha saw a developer-facing diagnostic naming that raw field slug — in Greek,Δεν είναι δυνατή η πρόσβαση σε ένα μήνυμα σφάλματος που αντιστοιχεί στο πεδίο g-recaptcha-response.(captchaCheck). The original report described this as an empty message; it was actually a leaked internal string. - A second symptom from the same empty label. The empty label also degraded the
requiredmessage on the same rule: a missingg-recaptcha-responserenderedform_validation_required(e.g.application/language/greek/form_validation_lang.php:4,'Το πεδίο {field} είναι υποχρεωτικό.') naming the raw field slugg-recaptcha-response— an internal input name shown to a visitor — instead of a human-readable label. - Log noise.
system/core/Lang.php:119-129loggedCould not find the language line "form_validation_captchaCheck"once per failed captcha attempt. - The fix.
ecommercen/core/GoogleRecaptchaTrait.phpnow calls$this->form_validation->set_message('captchaCheck', t('captcha.validation.error'))immediately beforeset_rules(), and passest('captcha.field.label')as the field label instead of''. Theset_message()call sits next toset_rules()in the same method deliberately, so the message travels with the rule and a future edit can't drop one without the other. Two new keys —captcha.validation.errorandcaptcha.field.label— were added toecommercen/language/<lang>/adv_theme_lang.phpin all 8 language directories (chinese, english, french, german, greek, italian, russian, spanish), next to the existingwaiting_list.error_captcha. - Why
set_message()over a language line. Mirrors what the siblingecommercen/core/CodeigniterCaptchaTrait.phpalready does for its own captcha rule, but sources the string fromt()instead of a hardcoded literal. Keeping the message next to the rule (rather than adding the missingform_validation_captchaCheckline) means it can't be lost again by a language file nobody updated. - Affected surfaces. Three call sites use this trait:
ecommercen/forms/controllers/Adv_forms.php(contact form and return form) andecommercen/eshop/controllers/Adv_waiting_list.php(waiting list). The waiting list was not user-facing broken — it treatsform_error('g-recaptcha-response')as a boolean and substitutes its ownt('waiting_list.error_captcha')— so its only prior symptom was the log noise. The contact and return forms are the user-facing half of this fix. - Tests: none added.
tests/Unit/Core/GoogleRecaptchaTraitTest.phpand itsFakeFormValidationsupport fake are being introduced by the in-flightfeature/586-recaptcha-v3-hybrid(#586), and neither path exists ondevelopyet — a parallel harness here would be a guaranteed add/add conflict, so coverage is deliberately left to that branch's merge.
- The leak, not a blank message.
[4.121.0] fix(rest/slider): deny
filter[audienceId]for storefront callers on GET /rest/slider/slide (Advisable-com/ecommercen#628)- The question. #628 asked whether
filter[audienceId]remained informative to a storefront (guest/customer) caller onGET /rest/slider/slide//itemafter #615 shippedSlideVisibilityFilter. It does — #615 does not neutralise it, because the two controls act at different layers. - The leak.
filter[audienceId]is folded into the SQLWHEREand into theCOUNT(*)atDomains\Slider\Slide\Service::match()/count()(src/Domains/Slider/Slide/Service.php:56-58), whileSlideVisibilityFilteris a POST-FETCH PHP pass over already-counted rows (src/Rest/Slider/Controllers/Slide.php:208-230). That splits into two channels: (1)?filter[id]=X&filter[audienceId]=Nmakespagination.totala 1-bit oracle over the true audience id for every slide, including ones the caller can never see, andFilterRequest::values()IN-lists a comma-separated value so a caller can binary-search rather than enumerate; (2) for a slide targeted at an audience but not membership-restricted,isAudienceVisible()still returns true, so empty-vs-nonempty on the row set reconstructs the exactaudienceIdvalueSlideResourcewithholds from every non-backend caller. - The fix.
Slide::enforceStorefrontSlideScope()— a protected method following theBlogComment::enforceStorefrontCommentScope()override-seam convention — early-returns onResourceContext::isBackend()and otherwise callswithDeniedFilter('audienceId'). It runs immediately beforebuildListRequest()in bothindex()anditem(), becausebuildListRequest()is what appliesdeniedFilterKeysand the constructor is too early (resourceContextis not yet set). Backend callers are entirely unaffected; the key stays declared inDomains\Slider\Slide\ListRequest::setAllowedFilters(). - What was explicitly NOT changed, and why. No
withDeniedSort('audienceId')— it is not a declared sort (setAllowedSorts()publishes onlyid,priority,title.{locale}), and denying an undeclared key would failtests/Unit/Rest/Support/Controllers/DeniedKeysAreDeclaredTest.phpfor no security benefit. No change fordateStart/dateEnd— neither is a declared filter or sort on this ListRequest, so there is nothing to deny; a documented no-op.show()is not touched — it fetches by primary key outside the filter pipeline and already gates per row viaisSlideVisible(). The declarativeScopesStorefrontRowstrait was deliberately not used — it is built around a forced row flag, and there is no server-chosenaudienceIdvalue to force here;StorefrontRowScopeWiringTestforbids denying a key that is also force-filtered. - What remains open, by design. The residual
pagination.totalEXISTENCE oracle —?filter[id]=Xalone still returnstotal: 1for a slide the caller cannot see — is a separate, wider-blast-radius concern (recomputing pagination post-visibility touches a shared contract every list endpoint relies on) and is intentionally not addressed here. - Tests: added to the existing
tests/Unit/Rest/Slider/Controllers/SlideScopeTest.phpsuite (now 26 tests) — guest/customer/backend coverage ofindex()anditem()proving the filter is dropped for storefront callers and kept for backend, plus regression coverage that requestedsortis unaffected for every context. Authentication alone is exercised as non-privileging (a customer context is asserted separately from guest), echoing #615's original defect shape.
- The question. #628 asked whether
[4.121.0] fix(rest/product): scope GET /rest/product/category and its four relations for storefront callers — unpublished categories and draft blog articles are no longer guest-readable (Advisable-com/ecommercen#625)
- Why — the three-fact defect.
ProductCategoryResourcehid the flag (publishedwas simply omitted from the projection), nothing removed the rows (every category, published or not, was returned), and the flag stayed client-filterable (Category\ListRequestdeclaredpublisheda freely settable allowed filter). Combined,?filter[published]=0asked for exactly the rows the projection was withholding, on a route that isauth => guestonindex/show/item. - Endpoint half.
filter[published]is now server-forced to1for storefront callers onindexanditem, so a supplied value is ignored rather than ANDed.show()gets its own per-row 404 for a hidden category — required, not belt-and-braces, becauseshow()fetches by primary key outside the filter pipeline, so a forced filter provably cannot reach it. - Relation half.
children,parent— including the recursive?with=children*/?with=parent*forms — andrelativeCategoriesnow carry the #588 ancestor-chain visibility rule viaCategoryVisibilityScope(reused, not reinvented).articlescarriesblog.is_published = 1viaArticleVisibilityScope, reused cross-module from #640, so the product-category → article edge andBlogCategory.articlesexpress one definition of a visible article rather than two that drift. ?with=articles.translationswas the severe path.blog.is_publishedisNOT NULL DEFAULT 0andBlogArticleMuiResourceemitsdescriptionwith noisBackend()gate, so a single unauthenticated request returned the full pre-publication body of every draft article linked to any product category — bulk-enumerable in one call, sinceindex()embeds the relation on every row it returns.- The endpoint rule is legacy parity, not a tightening. Every storefront read in
Adv_product_category_modelalready constrains the flag: the root menu (:265), the anchored child tree (:374,:377,:394), the child listing (:957), the vendor and sitemap trees (:1436,:1444) and the product-join listing (:1905,:1936) all gatepublished = 1. REST was the outlier. ?with=tagGroupsis deliberately left unscoped —Product\Tag\Category\Repository(shop_product_group_tags) carries no visibility flag at all, so there is no rule to express; attaching the category scope there would be a hard SQL error, not a silent mis-filter, since the closure namesshop_product_category.id, a table absent from that relation's FROM.- No sort denial accompanies either half:
?sort=publishedgoes constant under the forced filter and?sort=children.publishedgoes inert the momentchildrenis scoped in this same change, so both would be over-denial rather than a fix — the same call#624made correctly oncms/document/cms/blog/tag. Both sort keys stay declared for backend callers. - Backend callers are entirely unaffected, on the endpoint and on all four relations, via the central
Relation::VISIBILITY_EXEMPT_ALLgrantHandlesRestfulActionsapplies whenever the requestResourceContextis backend — the admin catalogue tree, where an unpublished branch gets published, keeps seeing every row and may still filter/sort bypublishedfreely. - Tests:
tests/Unit/Domains/Support/Repository/RelationConfigurationTest.php's declared-scope inventory gains the fourProduct\Category\Repository\RepositoryConfiguratorentries (articles,children,parent,relativeCategories).
- Why — the three-fact defect.
[4.121.0] fix(rest/cms): scope five guest-readable CMS endpoints for storefront callers — hidden pages, blog articles, documents, blog categories and blog tags are no longer guest-readable (Advisable-com/ecommercen#624)
Why. Five guest-readable endpoints shared the identical three-fact defect: the Resource hid the visibility flag, nothing removed the rows, and the flag stayed client-filterable — so an unauthenticated caller could ask for exactly the hidden rows.
Endpoint Forced key → column /rest/cms/pageisPublished→categories.is_published/rest/cms/blog/articlepublished→blog.is_published/rest/cms/documentactive→documents.active/rest/cms/blog/categoryactive→blog_categories.is_active/rest/cms/blog/tagactive→blog_tags.is_activeOne unauthenticated request each —
?filter[isPublished]=0&with=translations,?filter[published]=0&with=translations,?filter[active]=0&with=translations— harvested the withheld set in bulk.The exposure was the full unpublished record, not merely row existence. The Mui resources emit the body with no
isBackend()gate:Page/MuiResource.php:40serializescontentfromcategories_mui.fulltext,Blog/Article/MuiResource.php:36serializescontentfromblog_mui.description, andDocument/MuiResource.php:29serializes a working/files/documents/URL for the attached PDF. Andcategories.is_published/blog.is_publishedare bothNOT NULL DEFAULT 0, so every draft ever created was guest-visible by default — this was never a narrow edge case.The fix, three changes per endpoint.
index()/item()server-force the visible value for that endpoint's key viawithMandatoryFilter(), so a supplied value is ignored rather than ANDed — the repository ANDs same-column specs, so combining would return an empty set and disguise the fix as an empty list.show()gates per row and returns 404 for a hidden row, because it fetches by primary key outside the filter pipeline, where the forced filter can never reach. The whole scope is skipped for backend callers viaisBackend(), on every action, so an admin listing stays entirely unfiltered andshow()keeps returning hidden rows.Three sort denials, all storefront-only, closing live ordering oracles:
withDeniedSort('isPublished')oncms/page(closing?sort=children.isPublishedover the still-unscopedchildrenself-relation — an interim measure, tracked under#639, until that relation itself is scoped),withDeniedSort('active')oncms/blog/article(closing both?sort=categories.activeand?sort=tags.active—Article.categories/.tagsare unscoped relations, and scoping the category/tag endpoints does nothing for them, the#637lesson), andwithDeniedSort('active')oncms/blog/category(closing?sort=children.active).cms/documentandcms/blog/tagget no denial: their own?sort=activegoes constant under the forced filter (every row a storefront can receive already has the flag set) and neither declares a relation sort over a withheld column, so a denial there would be over-denial rather than a fix.The denied keys are the bare field names, not dotted paths — worth spelling out because it looks like a typo otherwise.
GenerateListRequest::parseRelationSort()matches a denial on the field segment, at the root and at every relation depth, so one bareactive(orisPublished) closes both the root sort and every relation-sort form at once; a dottedchildren.activewould silently no-op and failDeniedKeysAreDeclaredTest, which resolves a denied key against declared key names. The accepted side effect: the inert root?sort=isPublished/?sort=activeis dropped for storefront callers too, since both spellings resolve to the one field — harmless, because under the forced filter every row a storefront can receive already has that flag set, so the ordering is by a constant.The legacy logged-in-customer draft preview is gone, deliberately, not by parity.
ecommercen/blog/controllers/Adv_blog.php:230-233served an unpublished article to any logged-in customer. REST does not reproduce that:isBackend()is the only gate on all five endpoints, so a customer token now gets exactly the 404 a guest gets. Preserving the legacy affordance would have reproduced the#615defect shape, whereisAuthenticated()is satisfied by any customer token on a guest-policy route — making "customer preview" mean "every customer reads every draft". A client storefront using "log in and open the article URL" as a draft-preview link will start seeing 404s and must move to an admin-token call.NULL-flagged taxonomy rows are now withheld — parity for tags, a deliberate tightening for categories.
blog_categories.is_activeandblog_tags.is_activeareint(2) DEFAULT NULL— genuinely nullable, unlike the other three flags in this batch — and forcing= 1withholds a NULL-flagged row from storefront callers. Forblog_tagsthis matches legacy: its storefront listing selectsis_active = 1(Adv_blog_tags_model::getTagsFront(),ecommercen/blog/models/Adv_blog_tags_model.php:24-27), so a NULL-flagged tag was already invisible there — not a parity regression. Forblog_categoriesit is not parity — legacy never filters onis_activeat all:Adv_blog_category_model.php's only two references to the column (:202,:236) emit it into the returned array rather than filtering by it,getCategoryBySlug()/getCategoryData()filter on slug and lang only, and everygetCategoriesFront()call site inecommercen/blog/controllers/Adv_blog.php(:135,:306,:366,:579,:1009) passes onlylang. Legacy's blog-category storefront listing and detail page show inactive and NULL-flagged categories today, so/rest/cms/blog/categoryforcingactive = 1is a deliberate tightening beyond legacy — the same class of divergence as the dropped customer draft preview above — not a restatement of existing behaviour. It is still the right behaviour, since the flag exists to hide rows and legacy simply never enforced it on the storefront; the size of the affected NULL population is unmeasured for both tables, so treat it as unknown rather than assumed small.The blog publication-date window is deliberately not ported. Legacy applies
blog_date <= todayto its listings (never to its own detail route), butFilterRequestTypehas no<=operator, the only expressible workaround would forcefilter[inDates]as aBetweenand discard the client's own value — destroying archive-by-month browsing — andblog.blog_dateis nullable, so a naive window would silently drop every NULL-dated article.filter[inDates]stays available as an opt-in, unchanged.Two commented-out scaffolding stubs deleted from
Advisable\Domains\Cms\Page\ListRequest— anisPublishedentry in$defaultFiltersand anorderentry in$defaultSorts. Both were born commented in the commit that created the file and were never switched on; uncommenting the filter would have been the wrong fix for three mechanical reasons:$defaultFiltersis a static property with no request context, so it can't consultisBackend()and would blind the admin listing too; its entries carrykey = null, so a client?filter[isPublished]=0would AND with the default into an empty set instead of being overridden; and$filter['value'] ?: nullcoerces a falsy0, so it cannot express "flag = 0" regardless. The correct mechanism is the per-request, context-aware one in the controller.Still open. The five endpoints scope their own rows, but not the rows reached through a relation hop, and two of those hops still return hidden bodies to an unauthenticated caller.
?with=children.translationson/rest/cms/pagereturns thecategories_muibodies of unpublished pages, tracked under#639;?with=articles.translationson/rest/cms/blog/categoryreturns theblog_muibodies of unpublished articles, tracked under#640. Both are zero-auth and bulk-enumerable in a single request, becauseindex()embeds the named relation on every row it returns — the same#637lesson as above: a forced filter scopes the endpoint it is registered on and nothing else, and a relation needs its ownRelation::$visibilityScope. What this release closes on those two paths is the ordering oracle over them (thechildren.isPublishedandchildren.activesort denials above) — not the row set.Tests: new
tests/Unit/Rest/Cms/Controllers/CmsStorefrontRowScopeTest.php(43 test methods; 19 run against all five endpoints via a sharedendpoints()data provider — one class rather than five near-identical copies, since the shared three-part mechanism is the thing under test — and 24 are endpoint-specific, covering the Page/BlogArticle/BlogCategory sort denials at index/item/show and the harmlessness of the accepted root-sort side effect); and newtests/Unit/Domains/Cms/Page/ListRequestDefaultsTest.php(3 tests, pinning$defaultFilters/$defaultSortsas empty by reflection on the class defaults, and thatisPublishedstays a declared filter/sort for backend callers).
[4.121.0] fix(rest/cms): stop the guest blog-comment API leaking commenter emails and unmoderated comments (Advisable-com/ecommercen#616)
- Why.
GET /rest/cms/blog/comment(index,/item,/{id}) is open to anonymous callers, and it was returning every commenter's email address alongside the comment's moderation status — and serving pending and rejected comments together with approved ones. The same rows were reachable a second way, throughGET /rest/cms/blog/article?with=commentsand recursively via?with=comments.children. - What made it more than a disclosure. The email column was also exposed as a partial (
LIKE) filter and as an allowed sort —filter[email],sort=emailandsort=children.emailon the comment endpoint,sort=comments.emailon the article endpoint. A LIKE probe plus an ordering oracle is a blind-prefix enumeration primitive over the wholeblog_comments.emailcolumn, so an anonymous caller could harvest addresses belonging to comments it was never shown at all. Denying the filter alone would have been cosmetic: ordering by a withheld column still leaks its collation, one page at a time. - Serving rejected comments is the larger half. On one real tenant 4,195 of 4,932 comments are rejected (475 approved, 262 pending), and rejected blog content is typically spam or abuse — so this was an anonymous read of a shop's moderation reject pile, not a footnote to the email column. Approved rows exist on every tenant checked, so scoping to them does not blank the storefront.
- The change, for storefront callers only — guest and authenticated customer;
ResourceContext::isBackend()is the only exemption, and a null context denies.emailandstatusare omitted fromBlogCommentResourceat every nesting depth (BaseResourcepropagates the context intochildrenandparent).filter[status]is server-forced toapprovedviawithMandatoryFilter(), so a supplied value is ignored rather than ANDed with the forced one — the repository ANDs same-column specs, so keeping both would have returned no rows and disguised the fix as an empty list.filter[email]and everyemailsort form are denied.show()gets a per-row 404, because it fetches by primary key outside the filter pipeline and the forced filter cannot reach it. - The relation rows are scoped with
Relation::$visibilityScope, not by widening the existingscope(the#588mechanism, whose one prior consumer isProduct.categories). Two reasons: it is exempted for backend reads throughHandlesRestfulActions::applyRelationVisibilityExemptions(), which the always-onscopeis not — and an admin must see pending/rejected comments, since moderating them is the point — and it is applied by the shared relation loader on every load path, so it holds at every depth of an embed including the recursive reply tree. It is declared oncomments(article) and onchildrenandparent(comment), because a visibility scope is per-relation and does not cascade fromcommentsintochildren; scoping only the article relation would have left?with=comments.childrenand the comment endpoint's own?with=children/?with=parentopen. The article'scommentsrelation now carries both slots on purpose:scopekeeps the structuralparent_comment_id IS NULLinvariant,visibilityScopecarries the suppressible approved-only rule. - The approved value is the string
'approved', established from legacy, not guessed.blog_comments.statusisenum('approved','pending','rejected') DEFAULT 'pending'; the legacy storefront read filters on that literal (Adv_blog_comments_model::getBlogComments():155) and the admin moderation write persists it (Adv_blog_comments_admin::getStatus():168-178). A numeric1— plausible because the admin URL passes numeric status params — would have hidden every comment on every blog. - Not a parity loss. The legacy storefront renders commenter name and body only and has never rendered an email address or a status (
application/views/main/layouts/blog/blog_post.php:105-106), and it reads comments through a hardstatus = 'approved'filter. REST was exposing strictly more than the system it replaces, so closing it regresses no shipped behaviour. - New shared seam.
GenerateListRequest::denySort()andHandlesRestfulActions::withDeniedSort()— the read-ordering counterparts of the existingdenyFilter()/withDeniedFilter()pair (#452), which had no sort equivalent. Opt-in and empty by default, so every other controller is unchanged. - Deliberately out of scope. The
blogCommentsflag inapplication/config/rest_features.phpis discovery-only and gates nothing (onlybuilderis in theguardedmap), so it is untouched. Separately, the legacy per-row admin approve button passes1intogetStatus(), whoseswitchmatches only string cases, so it falls through todefaultand writes'pending'— a pre-existing legacy defect, not touched or worked around here. - Tests: new
tests/Unit/Rest/Cms/Controllers/BlogCommentScopeTest.php(28 tests) andtests/Unit/Rest/Cms/Controllers/BlogArticleCommentVisibilityTest.php(10); newtests/Unit/Domains/Cms/Blog/Comment/CommentVisibilityScopeTest.phpandtests/Unit/Domains/Support/Request/QueryListBuilder/GenerateListRequestDeniedSortTest.php;tests/Unit/Rest/Cms/Resources/Blog/Comment/ResourceTest.phpextended with the public/customer/backend and nested-relation cases; the#588inventory guard intests/Unit/Domains/Support/Repository/RelationConfigurationTest.phpupdated to record the three new visibility-scoped relations deliberately rather than let them slip in.
- Why.
[4.121.0] fix(rest/customer): withhold customer PII from anyone but an admin or the customer themselves, closing an unauthenticated leak through the slider endpoints (Advisable-com/ecommercen#615)
- The defect.
GET /rest/slider/slide?with=audience.customersandGET /rest/slider/slider?with=slides.audience.customersreturned full customer PII —mail,address,city,region,postal,county,country,birthdate,gender,landphone,mobilephone, the wholesendto*shipping block, andcompanyName/companyAfm/companyDoy/profession/companyAddress— to a completely unauthenticated caller. It bypassed two policies at once:Audience::classrequires backend +ADMIN/MARKETING,Customer::classrequires backend +ADMIN/ORDERS. Verified against real tenant data rather than reasoned about: on the largest shipped tenant 89 slides carry anaudience_idacross 15 audiences and 677,807shop_customer_audiencerows, so the repro returned bulk PII. - Root cause — four ungated layers on a guest-readable chain.
Slide,SliderandGroupreads areauth => 'guest'.SlideResourceembeddedaudienceunconditionally, even though theaudienceIdscalar beside it was already admin-only — theisBackend()block gated the scalars and not the relation.AudienceResourceembeddedcustomersunconditionally.CustomerResourcehad no context gate on its base block at all. AndRelationFilterMiddlewaredid nothing, because neither slider policy declared arelationskey. A third chain existed too:Group.sliders→Slider.slides→Slide.audience, also guest. - The primary fix, and why it is at the serialization layer.
CustomerResourcenow emits its personal fields only when the caller is backend or is the very customer the row belongs to.idstays ungated — a relation embed needs it for object identity and it is not personal data. A null context denies. This is the durable control rather than a policy allow-list becauseBaseResourcepropagates theResourceContextto every nesting depth (addResourceToData()/addCollectionToData()both callsetContext()), so the gate holds on every present and future path to customer data, whereas the middleware only ever inspects the first?with=hop. isAuthenticated()would not have been enough — the single most important detail here. On a guest-policy route an ordinary customer token resolves to scopecustomer, so it satisfiesisAuthenticated()and would still have received the whole PII dump for every other member of an audience. The self-match is therefore an identity comparison, and it is deliberately type-robust:ResourceContext::getUserId()is typed?stringwhile the row id is aninton a hydrated entity and a numeric string straight from the CI3 driver, so a bare===would silently never match and the gate would fail closed on the/rest/customer/meself-fetch. Both sides are proven numeric and compared as ints, the shapeOrder::show()/Wishlist::show()already use.dateRegistered,langandcountryare inside the withheld set, deliberately.countryis part of the postal address the issue reports as leaked, so splitting it frompostal/citywould leave the address half-open;dateRegisteredis account-lifecycle metadata about an identified person;langis that person's own preference. None has a storefront consumer for anybody but the signed-in customer.countryDetails(the#478object) follows the same gate as the scalar it elaborates.totalPointsis tightened, not left alone. It was gated onisAuthenticated()— the same too-loose gate this issue is about — so one customer could read another customer's loyalty balance through the same chain. It now follows the backend-or-self rule, which keeps both consumers its own docblock named (/rest/customer/me, admin) verbatim.- Nothing legitimate regresses. Every consumer of
CustomerResource/CustomerCollectionwas enumerated before shipping:Cms\ContactEmail(backend-only),Order(auth => 'any', andOrder::show()/enforceCustomerScope()already lock a customer caller to their own rows),Product\Wishlist(same shape),Plus\Audience(backend +ADMIN/MARKETING), and theCustomercontroller itself.'any'does requireisAuthenticated, so on Order and Wishlist the embedded customer is the caller and the self-match keeps?with=customerworking unchanged.GET /rest/customer/meand every admin customer read are untouched. - Two relations go admin-only (defence in depth).
SlideResource.audiencenow follows its own already-admin-onlyaudienceIdscalar —audienceIdis withheld from the response and audience targeting is applied server-side bySlideVisibilityFilter, so no storefront consumer loses anything. Withheld is not unknowable, though, and this gate does not make it so:filter[audienceId]remains an allowed guest filter andpagination.totalis counted from the SQL-filtered set before visibility filtering, so?filter[id]=X&filter[audienceId]=Nstill discloses the slide-to-audience mapping to an anonymous caller. That oracle predates this change and is tracked as#628— it is not closed here; this closes all three chains at once.AudienceResource.customersis now backend-only: audience membership is a marketing-admin concern with no storefront use, and gating it also stops the roster being enumerated id-by-id from a guest route. - Policy allow-lists (
Slide::class,Slider::class), explicitly defence in depth. Both now declare scope-keyedrelationsmaps (the#551mechanism) with the mandatory'default' => [].?with=audienceis denied to guest and customer callers on/rest/slider/slide, so the audience is never eager-loaded there and the query cost stops being attacker-chosen. This does not close?with=slides.audience.customersonSlider, and the tests say so out loud:RelationFilterMiddlewarematches only the first hop (WithParser::topLevelName()), andslidesmust stay allowed for a storefront to render a slider at all. The resource gates above are the control. - Second defect in the same area —
/rest/slider/slidehad no slide visibility.GET /rest/slider/sliderhas dropped expired/not-yet-active slides and hidden audience-targeted slides the caller may not see since v1.22 (#483), butSlider::applySlideVisibility()lived in the Slider controller only andSlide::index/show/itemwere bareparent::calls — so the rows the slider withheld were served straight from the bare endpoint. The existingSlideVisibilityFiltercollaborator is now injected into theSlidecontroller (no new mechanism, no re-implemented predicates).index()/item()filter the result;show()returns 404 for a slide the caller may not see, because it fetches by primary key outside the list filter pipeline — the v1.26Bundle::show()template. Filtering is post-fetch, so a page can return fewer rows thanlimitwhilepaginationstill counts the unfiltered set. Unlike the slider path the requestedsortorder is preserved: only the filter's visibility decision is used, never its ordering, so this endpoint's documentedsortparameter is not silently overridden by a security fix. - Tests: new
tests/Unit/Rest/Customer/Resources/Customer/PiiScopeTest.php(the gate, the type-robustness matrix, and the reported chain end to end),tests/Unit/Rest/Middleware/SliderRelationPolicyTest.php(allow-lists bound to the live config, incl. pinning'default' => []),tests/Unit/Rest/Slider/Controllers/SlideScopeTest.php(filter wiring + theshow()404).ScopeFilteringTest's Customer section and twoCustomerResourcecountry tests were updated — the former had codified the ungated payload as expected behaviour.
- The defect.
[4.121.0] fix(rest/transporter): stop
?with=settingsserving courier integration credentials to guests (Advisable-com/ecommercen#614)- The exposure.
GET /rest/transporter?with=settingsreturned the wholetransporters_settingstable — courier integration credentials — to a completely unauthenticated caller, as did/rest/transporter/{id}and/rest/transporter/item(and every locale-prefixed twin). Live, not theoretical: populated credentials were found on 4 of 10 real tenants, under key names includingPASSWORD,CLIENTSECRET,APIKEY,SECURITY_VALUE,CREDENTIALVALUE,PWD,UPWD,CPWD,SECD,UID,USERandCLIENTID. - Why it was reachable. Four things lined up.
Transporter::classopensindex/show/itemwith['auth' => 'guest'](application/config/rest_policies.php) so a headless checkout can list couriers before the visitor has an account. Thesettingsrelation is declared unscoped inDomains\Transporter\Transporter\Repository\RepositoryConfigurator.Rest\Transporter\Resources\Setting\ResourceemittedtransporterId,regKeyandregValuewith noResourceContextgate whatsoever. AndRelationFilterMiddlewarereturned early without filtering, because the policy declared norelationskey. The relation embed therefore walked straight around theSettingendpoint's own policy, which is backend plusAUTH_ROLE_ADMIN. - The fix — a resource gate, which is the durable half.
TransporterSettingResourcenow emitsregKeyandregValueonly whenResourceContext::isBackend(). This is deliberately the primary control rather than a policy tweak:BaseResource::addResourceToData()and::addCollectionToData()propagate theResourceContextinto every embedded resource and collection, so the gate holds at every nesting depth and on every path, present and future — including the second-hop?with=transporter.settingsroute off a sibling sub-endpoint, which no allow-list can see. A null context counts as non-backend (deny by default), matching every sibling gate insrc/Rest.transporterIdis still emitted: it is the id the caller already supplied and is public onTransporterResourceanyway. regKeyis withheld alongsideregValue. The key names are the credential inventory — they enumerate which courier integrations a shop has configured and which secret exists for each — and nothing consumes them from a storefront context, so there was no reason to keep half the object.- The fix — a relations allow-list, which is defence in depth.
Transporter::classnow declares a scope-keyedrelationsmap: storefront callers (publicandcustomer) gettranslationsonly;backendkeeps all eight;'default' => []. This is explicitly not the control, becauseRelationFilterMiddlewarematchesWithParser::topLevelName()and therefore only ever inspects the first?with=hop. It is the thirdrelationsentry in the file, afterCustomer(#551) andLine(#612). - All seven ADMIN-only sub-resources are denied, not just
settings.CountyAvailability,OptionPricing,PostAvailability,PostPricing,Pricing,PublicMappingandSettingare each backend +AUTH_ROLE_ADMINendpoints in their own right, so every one of them was reachable as a relation from a guest scope, and every one of them is now closed.postAvailabilitiesis included:ShippingCalculatorconsultsPostAvailabilityRepositoryserver-side to decide a courier's inclusion and never emits the availability table as data, so postcode serviceability reaches the storefront throughPOST /rest/checkout/shipping, never through this embed. - No storefront regression — the consumers were enumerated, not assumed. Nothing in the repo requests any relation on
/rest/transporter: zero hits forrest/transporteranywhere inassets/, zero?with=transporter hits inecommercen/orapplication/, and no controller test exercising?with=on the endpoint. The rendered storefront's courier selector POSTs to the legacy/{lang}/api/transporters/getAvailableTransporters; the headless checkout gets its priced list fromPOST /rest/checkout/shipping(a plain array, noTransporterResource—src/Rest/Checkout/contains no Resource classes at all), its pickup points fromGET /rest/transporter/{id}/smart-point, and its external rates from thedhl-rates/asap-servicesendpoints. Each shapes its own flat payload.translations— the localized courier name — is the one relation with storefront value, and it stays open, v1.4?with=translations[el]scoping included. - Unchanged: reads stay guest, writes stay backend +
AUTH_ROLE_ADMIN, every flatTransporterResourcefield, all filters, sorts and pagination. Backend callers see no difference at all. - Tests: new
tests/Unit/Rest/Middleware/TransporterRelationPolicyTest.php(34 tests, bound to the live config so weakening the policy fails the suite) and a rewrittentests/Unit/Rest/Transporter/Resources/Setting/ResourceTest.php(6 tests, covering backend / customer / public / null-context and the nested-depth case);Transporterrows added toPolicyResolverIntegrationTest.
- The exposure.
[4.121.0] fix(rest/product): scope product reads for storefront callers — inactive and soft-deleted rows are no longer guest-readable (Advisable-com/ecommercen#613)
- Why.
GET /rest/product/productis guest-readable and applied no row-visibility scoping whatsoever.ProductResourcehides theactiveandsoftDeletefields from storefront responses, but nothing removed the rows — so an unauthenticated caller received inactive and soft-deleted products by default, with everything else about them (name, price, images, stock, vendor, category links) intact. Worse, the hidden rows were directly requestable:activeandsoftDeletewere inallowedFilters, so?filter[active]=0and?filter[softDelete]=1asked for precisely the withheld catalogue. And they were reachable through relations:?with=lines.productswalked back into the catalogue unscoped. This is the reference implementation for the class of endpoints tracked under#618. - The change —
index()/item(). A protectedenforceStorefrontProductScope()server-forcesfilter[active]=1andfilter[softDelete]=0for storefront callers viaHandlesRestfulActions::withMandatoryFilter(), the v1.26Bundletemplate. A client-supplied value for either key is replaced, not combined — the repository ANDs same-column specs, so combining would return an empty set instead of the scoped catalogue. Both keys stay declared inallowedFiltersandallowedSorts, so backend loses nothing. show()is gated per row.show()fetches by primary key, outside the filter pipeline, so a mandatory filter cannot reach it; it fetches, checksactive === 1 && soft_delete === 0, and 404s otherwise (theBundle::show()shape). Fail-closed: a null flag reads as "not visible".- DECISION —
show()deliberately 404s an inactive product, even though the legacy detail page renders one.ecommercen/eshop/controllers/Adv_products.phpfilters onlysoft_delete = 0, so legacy will render an inactive product's page. Copying that here would be more permissive than legacy, not parity with it, for two verified reasons. (1)shop_product.idis a sequentialauto_increment— pharm16 runs min 1 to max 585,132 across 53,476 rows — so an unscoped fetch-by-id is itself a catalogue-enumeration primitive: walk the ids and you have the private catalogue, no filter needed. (2) Legacy's looseness is slug-addressed whileshow()is id-addressed; a storefront resolves a product page by slug throughitem(), so the visitor-facing behaviour for an inactive product is set by theindex/itemscope regardless of whatshow()does. The honest lever for a tenant that wants an inactive product to keep a live page is to keep itactive = 1and hide it another way — not an open id endpoint. - DECISION —
price > 0is deliberately NOT ported. Legacy's line/brand listing forcesprice > 0(Adv_vendors::baseWhere()), and that clause is not part of this scope. Zero-priced products are predominantly gift SKUs. Measured across real tenant databases (soft_delete = 0 AND price <= 0, intersected with the gift poolgift_choices.option_type = 1): pharm16 1,070 zero-priced of which 619 are in a gift pool — 88% of that tenant's 703-product pool; discount 2,496 / 1,241; demo 521 / 494; pharmacypoint 1,116 / 630; joy 1,033 / 399; livy 242 / 146. The business rule is "we do not sell zero-priced products unless they are gifts, shown as gift options" — a checkout constraint, enforced by the gift engine (src/Domains/Checkout/Gift/GiftMatcher.php) — so hiding the catalogue rows would break the exception rather than implement the rule: a headless storefront needs those rows to render a gift option's name and image.active = 1already handles zero-priced debris (evripidis: 11,085 zero-priced rows, only 64 active, none gift-linked), andshop_product.priceis NULLable, so aprice > 0clause would additionally drop every NULL-priced row. - The relation path —
Line.productsgets aRelation::$visibilityScope. NewAdvisable\Domains\Product\Product\ProductVisibilityScopesupplies a closure forcingshop_product.active = 1andshop_product.soft_delete = 0, attached to theproductsrelation inProduct\Line\Repository\RepositoryConfigurator— the#588mechanism, and the piece deferred out of#612. This is what closes?with=lines.products, and it is why arelationsallow-list could not:RelationFilterMiddlewarematches only the first?with=hop (WithParser::topLevelName()), so?with=lines.productshas top-level namelinesand walks straight past any allow-list onProduct. AvisibilityScopeinstead applies at the loader, at every nesting depth and for non-REST consumers too. It is declared as$visibilityScope(suppressible), not$scope(always-on), precisely so admins can still see past it. - Backend callers are unaffected, and that is the mechanism that answers "how does an admin still get them".
enforceStorefrontProductScope()returns early underResourceContext::isBackend(),show()'s gate is skipped for backend, and the relation scope is suppressed centrally byHandlesRestfulActions::applyRelationVisibilityExemptions()grantingRelation::VISIBILITY_EXEMPT_ALL. An admin listing carries no filters at all, both keys stay filterable and sortable,show()returns inactive and soft-deleted rows, and?with=lines.productsstays unscoped at any depth. - Not done here, deliberately: storefront sort denial on
active/softDelete. The shared seam for it (HandlesRestfulActions::withDeniedSort()/GenerateListRequest::denySort()) lands with#616and must not be duplicated locally. Deferring is safe because of the forced filters: every row a storefront can now receive already hasactive = 1andsoft_delete = 0, so ordering by either column orders by a constant and partitions nothing. The pinning test istest_a_storefront_sort_on_active_cannot_partition_the_scoped_set()— if anyone relaxes the forced filters, that is where the sort becomes an oracle again and the denial becomes load-bearing. - Tests:
tests/Unit/Rest/Product/Controllers/ProductScopeTest.phpgrows a#613section (33 tests in the file total) andtests/Unit/Domains/Product/Product/ProductVisibilityScopeTest.phpis new (8 tests, covering the closure's clauses, the liveLine.productswiring, and the nestedlines.productsload with and without an exemption). The gift-pool regression guard istest_a_zero_priced_active_product_is_still_served_to_a_guest().RelationConfigurationTest's visibility-scope guard is updated from "categories is the only one" to the reviewed two-entry list.
- Why.
[4.121.0] feat(rest/product): open Line read endpoints to guest, with a relations allow-list withholding
products(Advisable-com/ecommercen#612)- Why.
GET /rest/product/line,/rest/product/line/itemand/rest/product/line/{id}(plus their locale-prefixed twins) required a backend token withADMINorPRODUCTS, so an entire content type was unreachable to the storefront even though it is fully modelled, routed and serialised. The legacy storefront serves the same content to anonymous visitors:vendors/{brand}/{line}resolves to a line listing viaAdv_vendors::baseVendor(), which looks the second URL segment up inshop_linescoped byvendor_id, then hands off to::lines_list(). A headless storefront had no way to serve that route, so links to brand-line pages had to point at the production website instead of staying inside the app. - The change. The
Line::classrow inapplication/config/rest_policies.phpgains amethodsblock openingindex/show/itemto guest, mirroring the already-guestVendor::classsibling and following the#484Badge precedent.store/update/destroyare unchanged — still backend +ADMIN/PRODUCTS. Resource fields, filters and sorts are all untouched. Resolving a line needs bothfilter[vendorId]andfilter[slug.{locale}], because line slugs are unique per vendor rather than globally. - No row scoping, because there is nothing to scope on.
shop_lineandshop_line_muicarry no published/active/status column (verified againstdatabase/initial/initial.sqland every subsequent migration), so — unlikeProductorCategory— every row is public content, which is exactly how legacy treats it (Adv_lines_model::getMuiRecordsfilters onlangonly, andAdv_vendors::vendors()lists all of a vendor's lines). ?with=productsis denied to storefront callers — defence-in-depth, not a closed leak. The policy now declares a scope-keyedrelationsallow-list — the second entry in the file, afterCustomer(#551).Line.productsis an unscoped many-to-many ontoProductwith noscopeand noRelation::$visibilityScope, and the Product repository applies no default active/soft-delete scope, so a guest embed through this endpoint would have returned inactive and soft-deleted products, where the legacy line listing forcesactive=1/soft_delete=0/price>0(Adv_vendors::baseWhere()). Guest and customer callers gettranslationsandvendor; backend callers keep the full set. A denied relation is stripped silently and the request still returns 200, exactly as an unknown relation would be. But the allow-list only stops the Line endpoint from being a further route to that data:Product::classis itself guest-readable, declares norelationsallow-list, and relation loading recurses across entity boundaries, soGET /rest/product/product?with=lines.productsreaches the same inactive/soft-deleted rows withLine's policy never consulted — and more directly, a guest can already request them viaGET /rest/product/product?filter[active]=0, since the Product endpoint applies no row scoping at all. Both gaps are pre-existing and unchanged by this branch (ProductResourceserialiseslinesondeveloptoo, andProduct::class's policy is untouched here); they're now tracked as#613. The allow-list is still the correct shape for the dayProductgets scoped — it just doesn't close the exposure by itself.- Bug found and fixed on the way in.
RelationFilterMiddlewarematched allow-list entries withexplode(',', $with)thenexplode('.', $segment)[0], which mis-reads the v1.4 per-relation language grammar in two ways:?with=translations[el]yields the top-level nametranslations[el], which matches nothing and gets dropped; and?with=translations[el,en]splits on the comma inside the brackets intotranslations[el+en], so the relation is lost and any surviving sibling is re-imploded around the wreckage. The middleware now splits and names segments throughWithParser, which owns that grammar, so a bracketed relation is matched by its name and re-emitted verbatim. No shipped endpoint was affected —Customerwas the only policy with an allow-list and no client sends a bracketed?with=country— but every future allow-list, starting with this one, would have hit it. - Second middleware hardening.
?with[]=xarrives as an array, which the middleware passed straight into a string-typed call — aTypeErrorthe dispatcher turns into a 500. It was unreachable in practice whileCustomerwas the only policy with an allow-list (it needs a customer token, and no client sends that shape), but declaring one on a guest-readable endpoint would have made it triggerable by any anonymous caller. A non-stringwithis now dropped, matching howWithParser::parse()already treats it. - Tests: new
tests/Unit/Rest/Middleware/LineGuestReadPolicyTest.php(19 tests) binding to the live config;Linerows added toPolicyResolverIntegrationTest;splitRelations()/topLevelName()coverage added toWithParserTest.
- Why.
[4.121.0] fix(admin/orders): restore guest/registered customer icon distinction in the admin orders list (Advisable-com/ecommercen#611)
- The gap. Commit
4a52f9b00("feat(GiftCardOrders): introduce gift card order management and history views") unified thecustomers_admin/guest_historyandcustomers_admin/quick_historylinks into a singlecustomers_admin/order_historyaction, but collapsed the icon conditional along with it — leaving one hard-codedglyphicon-eye-close. Since 4.119.x every row in the admin orders listing has rendered the guest icon regardless ofis_guest, so admins could no longer tell registered customers from guests at a glance. Reported by two client sites (easy-pharmacy, acropolispharmacy) — Freshdesk #23493. - The fix.
application/views/admin/orders/list.phpkeeps the unifiedorder_historylink (that consolidation was correct) and restores the icon conditional keyed on$row->is_guest:glyphicon-eye-open(registered) vsglyphicon-eye-close(guest), with titles from the existingeshop.admin.panel.customers.registered/.guestlang keys. No backend change needed —is_guestis already selected byAdv_order_model::getOrdersListAdminResults(). Mirrors the never-broken pattern inapplication/views/admin/customers/list.php.
- The gap. Commit
[4.121.0] fix(eshop): coerce
order_serialto string at its four write/read seams so it always matches itsvarcharcolumn (Advisable-com/ecommercen#609)- The bug.
Adv_order_model::createSerial()(ecommercen/eshop/models/Adv_order_model.php) returned a PHP int whenever theordersPrefixconfig item is empty — and empty is the shipped default (application/config/app.php:260), so this is the stock configuration, not a per-client quirk. The serial originates as$this->db->insert_id()inprocessOrder()and, on the no-prefix/no-collision path, came back unchanged as an int, then flowed asorder_serialinto 26 serial-keyed DB operations on the order-placement leg. CodeIgniter 3's query builder does not quote a PHP int, so the emitted SQL readWHERE order_serial = 747320againstshop_order.order_serial, avarchar(255)indexed column. MySQL resolves an int-vs-varchar comparison numerically, coercing the column and making theorder_serialindex unusable for that predicate — confirmed via productionEXPLAIN, whereorder_serialsat inpossible_keysbut was never the chosenkey, and the optimizer instead drove the join from a full scan ofshop_customer(371,481 rows). - Customer impact. The "redirecting to bank" page rendered incomplete and then timed out with a Cloudflare 503 after ~30s (reported on the Eurobank/CardLink redirect path). The blast radius is wider than one gateway: 17 of the 19 payways
Adv_checkout::run()dispatches are affected — 6get_records_customer()SELECTs, 9getOrder()SELECTs, and 11set_status()UPDATEs — which includes_delivery,_bank_transferandpaidAtStore, so cash-on-delivery, bank-transfer and pay-at-store orders were affected too, not just bank redirects. The 11 UPDATEs are the worse case: under InnoDB's default REPEATABLE READ, a full-scan UPDATE takes row locks on every row it scans, amplifying lock contention under concurrent checkout. Only the order-placement leg is affected — payment response/callback legs rebuild their payload from a DB read, and mysqli returns column values as strings, so those were always correctly quoted. - The fix. Confined to
ecommercen/eshop/models/Adv_order_model.php(+26 lines, no existing line altered), four coercion points:createSerial()casts$insertIdto(string)as its first statement (the root fix, alone sufficient for all 26 call sites);set_status()gets a null-safe(string)coercion of$orderSerial, guarded on!== nullsoWHERE order_serial IS NULLdoesn't becomeWHERE order_serial = '';getOrder()writes back the(string)cast inside its existing guard block, deliberately without trimming;get_records_customer_base()gets anisset()-guarded(string)coercion of$conditions['order_serial']so anullstill rendersIS NULL. Casting only at the 26 call sites was rejected at triage — it re-breaks the moment a new payway is added. - No migration and no patcher: stored
shop_order.order_serialvalues were already strings in the column: the int existed only in-process, betweeninsert_id()and the query builder. - REST is unaffected: the modern REST/Domain checkout never calls
createSerial()—PlaceOrderService.php:382builds its serial asstr_pad((string) $order->id, 6, '0', STR_PAD_LEFT), already a string. - Tests:
tests/Legacy/Eshop/AdvOrderModelProcessOrderCommitTest.phpgains 3 tests assertingcreateSerial()returns astringon all three branches (no-prefix, prefix, collision); its query-builder double gained an optionalarray $collisionCounts = []third parameter so the collision branch is reachable at all. File is now 13 tests / 38 assertions (was 10 / 31);tests/Legacy/Eshopoverall is green at 400 tests / 1239 assertions.
- The bug.
[4.121.0] fix(order): stop writing
shop_order.cart_contentsfrom the REST order-create path (Advisable-com/ecommercen#605)- The bug.
PlaceOrderService::placeOrder()(src/Domains/Checkout/PlaceOrderService.php) built a serialized cart snapshot and assigned it to$orderData['cart_contents']on every REST order placement, then handed that array throughOrderWriteService::create()→Order\WriteData→ the write repository's INSERT.shop_order.cart_contentsis not a real column: no Phinx migration and no statement indatabase/initial/initial.sqlcreates it. Every REST order placement therefore failed with SQL error 1054 (Unknown column 'cart_contents' in 'field list') on any environment provisioned from what the repo ships — surfaced as a CodeIgniter HTML error page rather than a JSON error response, i.e. every REST checkout was broken out of the box. - Not a null-emission bug.
BaseWriteRepository::insert()already stripsnullvalues from a payload before building the INSERT, so anullcart_contentswas never the problem. The removed code built and assigned an always-non-null serialized snapshot string; that non-null value is what named the column and broke the INSERT. - The fix.
PlaceOrderServiceno longer builds or assignscart_contents— the assignment plus thebuildCartContentsSnapshot()/decodeCartItemOptions()helpers that existed only to build it are removed (112 lines).Order\WriteDatadrops thecartContentsconstructor property, itsfromArray()read and itstoArray()map entry, so no REST write path can emit the column any more.Order\Repository\Entity's@property string|null $cart_contentsannotation is removed too — it described a column that was never actually there. - The column is deliberately not created. No migration ships, and none is planned. A full inventory of every
cart_contentsreference acrosssrc/,ecommercen/andapplication/found zero SELECTs anywhere — nothing reads the column back. Legacy checkout doesn't persist it either:Adv_order_modelbuilds the same value only in memory andunset()s it before its own INSERT. - Legacy untouched. No file under
ecommercen/changed — the payway handlers keep reading$checkoutData['cart_contents']from the in-memory order array exactly as before. - Tests:
tests/Unit/Checkout/PlaceOrderServiceTest.php's cart-contents test is replaced bytest_order_insert_payload_carries_no_cart_contents_column(), asserting on the array that reaches the write repository (not the payloadPlaceOrderServicehands the write service), so the guarantee survives a future refactor that re-adds the field toWriteData.tests/Integration/Domains/Order/Order/ServiceTest.phpgains a database-backed test that first assertsshop_order.cart_contentsgenuinely does not exist in the schema, then confirmscreate()succeeds when handed a non-nullcart_contentsvalue in the payload (the key is dropped before the INSERT, rather than failing).
- The bug.
[4.121.0] fix(customer): route change-password write through the model's overridable hashing seam (Advisable-com/ecommercen#603)
- The bug.
POST /rest/customer/me/password(src/Rest/Customer/Controllers/Customer.php) verified the customer's current password throughAdv_customer_model's overridable hashing seam —checkCustomer()→checkPassword()— but wrote the new password using a private copy of the default hashing scheme inlined in the controller itself,generateEncryptedPassword(). On a stock tenant the two schemes happened to coincide, so the bug was invisible. On any tenant that overrides the model's hashing — which the seam exists for — the endpoint read with one scheme and wrote with another, so the customer's stored hash could never again match their own tenant's verifier. The failure was silent: the endpoint still returned200 {"message":"Password changed successfully."}, andforgot-passwordwas the only way back into the account. - The fix.
generateEncryptedPassword()is deleted from the controller.Adv_customer_model(ecommercen/eshop/models/Adv_customer_model.php) gains a new public method,changePassword(int $customerId, string $newPassword): bool, placed after the existingresetPassword(). It hashes via the model's ownprotected generateEncryptedValues()seam and writes via the bareupdateCustomer(), returning whether a row actually changed. The controller now makes a single$ci->customer_model->changePassword(...)call. Request parsing, validation, status codes and response bodies are unchanged. - Tests: new
tests/Legacy/Eshop/AdvCustomerModelChangePasswordTest.php(4 tests) asserting the persisted value is exactly what the stubbed hashing seam produced.
- The bug.
[4.121.0] refactor(cart): resolve the cart's
[productId => qty]map once, batched, instead of one product-code query per cart line (#596)- Why. Two byte-identical private methods —
GiftMatcher::cartProductQuantities()andAdvisable\Rest\Cart\Controllers\Cart::cartProductQuantities()— each turned the cart'sproduct_code_id-keyed lines into the[productId => totalQty]map the gift engine and both gift presenters consume, and each did it with oneProductCodeRepository::get()per cart line. An N-line cart therefore cost N queries on the order-placement path and another N on every/rest/cartrender, and the duplication meant a fix to either copy silently missed the other. - One shared collaborator. New
Advisable\Domains\Cart\CartProductQuantityResolver(src/Domains/Cart/CartProductQuantityResolver.php), with a singleresolve(CartItemEntity[]): array<int,int>. Both call sites delegate to it and the duplicated loop exists in neither any more. It sits besideCartTotalsCalculator/CartWeightCalculatorinDomains\Cartand is registered under// Business servicesinsrc/Domains/Cart/container.php;CartWeightCalculatoris the precedent — a small single-purpose collaborator injecting onlyProductCodeRepositoryand shared by a domain service and a REST controller alike. - Query count is now CONSTANT in the cart's line count, not linear. Two passes: the parent product id is read straight off the already-hydrated
productCoderelation where the caller loaded one (Cart::CART_RELATIONSon the render path,CartTotalsCalculator::PRICING_RELATIONSon the checkout paths), mirroringCartWeightCalculator::resolveUnitWeight(); anything left over is fetched in one batched read viamatch(new Filter('id', $ids, 'IN')), the'IN'operator being the branch that reacheswhere_in()(src/Domains/Support/Repository/Specification/Filter.php:23-26). Because every current caller hydratesproductCode, the practical cost on both paths is zero product-code queries; an unhydrated caller pays exactly one, whatever the cart's length. - Immediate effect. All three
GiftMatcher::match()call sites benefit today: order placement (PlaceOrderService.php:99), everyGET /rest/cartrender — a storefront re-renders the cart on each add/update/remove — andPOST /rest/checkout/totals, the most frequency-sensitive of the three, since headless storefronts call it on every transporter selection and on a debounced destination/address change./totalsreaches the same seam unconditionally viaquoteGifts()(src/Rest/Checkout/Controllers/Checkout.php:702), so this path is live now, not prospective. - No behaviour change anywhere. The skip semantics are preserved verbatim: a line whose product code no longer resolves, or whose code carries no
product_id, contributes nothing and does not abort; lines sharing a product SUM; the(int)casts onproduct_code_idandqtyare kept. Map keys stay in first-seen cart order. - An empty id list is guarded explicitly.
Filterhands an empty array straight towhere_in(), which emits noWHEREclause — so a batch with nothing to look up would have selected the entireproduct_codestable. The resolver skips the query outright in that case (which is also the empty-cart case), and a test pins it. - Tests. New
tests/Unit/Cart/CartProductQuantityResolverTest.php(aggregation, both skip branches, string-column coercion, the empty-list guard, the hydrated fast path, a mixed cart, and the shape of theINfilter itself). Newtests/Unit/Rest/Cart/Controllers/CartGiftQuantityMapTest.php— the first test of any kind forsrc/Rest/Cart/Controllers/Cart.php— pinning thatbuildCartResponse()resolves the map through the shared collaborator and hands the identical map toCartGiftPresenterandCartGiftNearMissPresenter.GiftMatcherTestkeeps all seven existing cases and gains a call-count pin; it wires the REAL resolver around a mockedProductCodeRepositoryrather than mocking the resolver, since a bare mock would return an empty map and quietly vacate every assertion in the file. - No REST contract change —
GET /rest/cartreturns the same payload, with the samegiftsandgiftsNearMissblocks. No DB migration, no config keys, no language keys. - Out of scope, deliberately. A request-scoped identity map on
BaseRepository(a platform-level change that needs its own argument), and the third per-lineProductCodeRepository::get()of the same shape atsrc/Domains/Checkout/OrderBasketBuilder.php:288.
- Why. Two byte-identical private methods —
[4.121.0] fix(cart): compute
cart_total_vat()from the parsed cart so the coupon gate matches the charge (#594)- The bug — two disagreeing expressions of one number.
Adv_coupons_model::isValidCoupon()weighs an opaque$cartTotalagainst a coupon'stotal_cart_from/total_cart_to(Adv_coupons_model.php:431-439), and its callers disagreed about what that number was.AdvCartResource(the cart widget) passedbaseParseCartContents()'scart_total_vat— bundle-aware and rule-13-aware.Adv_order::checkCoupon(),Adv_order::previewOrderCouponData()andAdvApiPreviewCouponpassed thecart_total_vat()helper, which was blind to both. The helper re-fetched its own live data viagetLiveProductsParsed()and summedfinal_price * quantityagainst the raw cart quantity, so it missedapplyBundlePricingToCartLiveData()(Adv_order_model.php:543) and the rule-13$paidQtyreduction (:580-585). Both gaps push the same way, so the helper always returned a total greater than or equal to the parsed one. Net effect: on atotal_cart_fromcoupon, checkout granted a discount the cart widget rejected — the money-losing direction — and on atotal_cart_tocoupon it did the reverse. Divergence needed only one ofPRODUCT_BUNDLES.ENABLEDor a single live rule-13 gift (that adjustment path runs unconditionally, with no feature flag). - The fix.
cart_total_vat()(ecommercen/helpers/cart_helper.php) now delegates toAdv_order_model::baseParseCartContents()and returns itscart.cart_total_vat, so there is one expression of the gross items-only subtotal instead of two. The helper loadseshop/order_modelitself rather than trusting the caller: the model is not autoloaded, and the helper is reached fromapplication/views/production/google_header.php, which renders on every storefront page under controllers that never load it. - The result is memoised per request — a function-local
staticarray, keyed onmd5(serialize($cart->contents()) . '|' . serialize($vatProbe)). This is a requirement, not an optimisation:baseParseCartContents()is strictly more expensive than the old helper body (twoproduct_parser_model->get()passes plus the rule-13 gift fan-out), and the helper can fire more than once per render. BecauseAdvCartResourcealready callsbaseParseCartContents()on the cart render, that page now does less total work than before.pscachewas deliberately not used: it is persistent and cross-request, and its key derivation hashes only the method name and arguments —baseParseCartContents()takes none, so every session would collide on one key and read another customer's cart total. - The memo key includes a
vat()probe, not just the cart.final_priceis VAT-derived (Adv_product_parser_model.php:131,:475), and theVatForOrdersingleton is reconfigured in place mid-request byFactories::vatForOrder()—Adv_order::checkCoupon()callssetVatForOrder()four lines before it reads this helper. A cart-only key would have served the pre-mutation total to the post-mutation read.AdvVatForOrderexposes no getter for any of its four protected properties, so the key probes the observable behaviour ofvat()over[0, 6, 13, 24]instead. That is also strictly more correct than reading state would have been: it captures a fork whoseinvoiceVat()/receiptVat()override keys on something the core properties do not express. - Admin order editor — coupon basis corrected.
Adv_orders_admin::coupon_in_cart()seeded$cart['cart_total_vat']from the postedcartTotal(Adv_orders_admin.php:1459before this change; now:1466), but gatedisValidCoupon()oncartItemsTotal(:1470before; now:1477). The two start identical inapplication/views/admin/footer_js.php:2559-2560and then diverge: onlycartTotalis mutated into a display grand total (+= deliveryCost:2573,+= transferCost:2580,+= giftPackagingCost:2604,-= couponDiscount:2612). So the threshold gate andcalculateCart()'s discount basis were different numbers, and — becausecouponDiscounthad already been subtracted in the browser — recalculating deducted the discount a second time. That seed line now reads$orderData['cartItemsTotal'], the gross items-only subtotal and the exact analogue ofbaseParseCartContents()'scart_total_vat. TheisValidCoupon()gate was already correct and is unchanged. - Deleted the dead
cart_total()helper. Zero callers repo-wide; it duplicated the old, wrongcart_total_vat()body. - Tests. New
tests/Legacy/Cart/CartHelperTest.phppins delegation (the right key off the right method), once-only invocation across repeat calls, cart-change invalidation (both a quantity bump and a same-quantity product swap — the latter is why the key is the serialized contents and nottotal_items()),VatForOrder-state invalidation with a byte-identical cart, and the removal ofcart_total(). It swaps the CI super-object viaGetInstanceRegistry::set()and installs a real reflection-builtVatForOrderintoSingleton::$instances, restoring both intearDown(). Verified non-vacuous by mutation: disabling the memo, dropping the VAT probe from the key, and readingcart_totalinstead ofcart_total_vateach fail a distinct test.
- The bug — two disagreeing expressions of one number.
[4.121.0] feat(feeds): gate per-feed XML price modifiers behind an Advisable-only master switch (Advisable-com/ecommercen#592)
- Why. Any merchant admin with settings access could set per-feed XML price modifiers, which change the prices actually published to price-comparison engines and marketplaces (Skroutz, Google, Facebook/Meta, BestPrice, Glami, eMAG, Public Marketplace and the rest). There was no way to withhold that capability from a shop. It is now gated behind a switch only Advisable staff can reach.
- The switch. New registry key
XML_FEEDS.CUSTOM_PRICE_ENABLED, surfaced as checkboxxmlPricingEnabledonsettings/only_advisable(the Advisable-staff-only page), following the same panel pattern as the existingenableFilterCategoryGroupTagstoggle. New lang keyseshop.admin.only_for_advisable.enable_xml_pricing.{header,label}in all 8 locales (chinese, english, french, german, greek, italian, russian, spanish). - The mechanism — the interesting decision here. Flipping the switch ON→OFF resets all 14 per-feed
XML_FEEDS.CUSTOM_PRICE_<FEED>flags tofalse, and that reset is the enforcement. Every existing consumer — the 13 feed controllers, theAdvUpdatePublic2PSupplierjob,getEnabledCustomPriceXmlFeeds()and everything downstream of it — already gates on those per-feed flags and stops on its own. So no consumer was touched: the change is confined to the settings controller, two settings views, the seeder and the lang files, rather than threading a second flag through 14 read sites. Implementation inecommercen/settings/controllers/Adv_settings.php(~lines 2014-2039); the reset fires only on a genuine ON→OFF transition (the prior value is read with?? 1, so an absent row counts as ON), never on deploy and never on an OFF→OFF save. - What the reset deliberately preserves. The 14
CUSTOM_PRICE_<FEED>_MODIFIERpercentages andshop_product_feed_lp.price_modifierare left untouched, so a shop's configured values are still there if the switch is turned back on. - The settings page. While the gate is off, the "Custom τιμή" and "Ποσοστό αλλαγής Τιμής (%)" fields are hidden in all 14 blocks of
settings/xml_feeds_settings(Public 2P included — that page renders a 14th block with no feed controller of its own). Hidden server-side with a PHPif, not CSS or JS, so the inputs are genuinely absent from the POST. - Fixed — the null-wipe this needed to avoid. Precisely because those inputs stop POSTing, an unguarded save of that page would write the absence back to the database:
(bool)nullover all 14CUSTOM_PRICE_*flags,nullover all 14_MODIFIERvalues, and nulls pushed into per-productshop_product_feed_lp.price_modifierviaupdateXmlFeedCustomPriceModifier()— destroying the very percentages the design promises to preserve. All three writes are now skipped while the gate is off (Adv_settings.php:1670and:1705). - Upgrade behaviour — no migration, deliberately. Reads use
?? 1, so existing installs keep publishing custom prices exactly as before on deploy. OnlyInitialSeedwrites an explicit0, and only on a genuinely fresh install (gated on the absence of aSEEDER.INITIAL_SEED_RANrow, snapshotted at the top ofrun()), so new shops start gated while re-running the seeder against a live shop is a no-op. That guard is not cosmetic: this is the one registry insert in that file whose seeded value is the opposite of its read-time default, so an unguarded re-run would silently disable custom XML pricing platform-wide.??rather than?:is load-bearing —?:would treat an explicitly-OFF switch as absent and flip it back ON.
[4.121.0] feat(captcha): layer invisible reCAPTCHA v3 scoring on top of v2, with v2 as a step-up challenge (#586)
- What. Contact form, return form, and the waiting-list AJAX flow now mint a reCAPTCHA v3 token and verify its score server-side. A score below a configured threshold reveals the existing v2 widget in place (no page reload for the waiting list) as a step-up challenge, exactly as v2 already required. A site with no v3 keys configured is unaffected — it keeps today's exact v2 behaviour.
- Ships inert.
ecommercen/core/Adv_front_controller.phpstill uses the no-opCaptchaTrait. v3 activates only for a client that overrides inGoogleRecaptchaTraitand fills in both v3 keys — there is no separate on/off toggle. - New service.
src/Recaptcha/GoogleRecaptchaService.php(+ readonly DTOsrc/Recaptcha/RecaptchaResult.php) — a Guzzlesiteverifyclient with explicittimeout/connect_timeout, structured logging on therecaptchalog channel, and fail-closed handling of transport failures and malformed JSON. - Per-form v3 actions (
contact_form,return_form,waiting_list) are verified server-side against what Google returns. An action mismatch — or a response with no action at all — fails closed rather than being accepted, so a token minted on a cheap public page can't be replayed against a protected form. An action mismatch does not offer the v2 step-up: it signals misconfiguration or attack, not a borderline human. - New admin config (Settings → Google, registry group
GOOGLE):RECAPTCHA_V3_KEY— plaintext.RECAPTCHA_V3_SECRET— encrypted; saving it now requires a workingAPP_ENCRYPTION_KEY. Saving with a blank/broken encryption key is now refused with a visible admin error instead of silently storing a corrupt value (previously read back as0, which looked like a saved-but-wrong secret).RECAPTCHA_V3_THRESHOLD— numeric,0..1, default0.5; a non-numeric stored value falls back to0.5.
- Merged with #630. #630's
set_message('captchaCheck', t('captcha.validation.error'))andt('captcha.field.label')field label now apply to all three rule-binding branches this feature introduced — the v2 fallback, the v2 step-up retry, and the v3 hidden fieldg-recaptcha-v3-response. The v3 field carries the same label as the v2 widget: to a shopper both are just captcha verification, and which one a request got is an implementation detail of the score check. #630 deliberately shipped without tests becausetests/Unit/Core/GoogleRecaptchaTraitTest.phpandFakeFormValidationonly exist on this branch; that coverage is added here (FakeFormValidation::set_message()/labelFor()plus a data-provider test pinning the message and label on each branch). - Language keys added to all 8 locales:
settings.admin.google.recaptchaV3Secret.encryptionError(adv_advisable_lang.php) andwaiting_list.error_captcha_step_up(adv_theme_lang.php).
[4.121.0] fix(gift-cards): make gift card order acceptance idempotent to stop double coupon issuance (Advisable-com/ecommercen#583)
- The bug.
AdvGiftCardOrdersModel::acceptGiftCard()(ecommercen/gift_cards/models/AdvGiftCardOrdersModel.php) had no status guard: it unconditionally inserted a coupon and marked the orderCompleted, regardless of the order's current state. A duplicate accept — a payment-gateway webhook retry, a refreshed/re-POSTed bank return URL, or an admin double-clicking Accept — minted a second fully redeemable coupon for the full face value and orphaned the first by overwritingcoupon_idon the order. The orphan is structurally invisible to redemption:isValidCoupon()→getCouponsRecordByCode()(ecommercen/coupons/models/Adv_coupons_model.php) resolves by coupon CODE with a bareWHERE coupon = ?and never joinsgift_card_orders. Because the old code also resetemail_sent/sms_senton every call, a delivery-cron tick landing between two accepts could leave the customer holding two live full-value gift-card codes. Only XPay and Piraeus guarded against this, and only at the caller — Viva, PayPal Advanced, the other bank return URLs, the reconciliation job, and the admin path did not. - The fix. The model now claims the order before issuing the coupon: a single conditional
UPDATE … WHERE id = ? AND gift_card_status IN (…)both checks and writes, so two concurrent accepts serialise on the row lock and the loser matches zero rows.acceptGiftCard()now returnsbool(false= already handled, nothing written). A read-then-writeif ($status === Pending)was deliberately rejected — two retries could both readPendingbefore either writes. - Source-state policy. Automated callers (legacy + REST Viva webhooks, the bank return URLs via
AdvGiftCardPage::successView(), the XPay hook, all threeAdvCancelPendingGiftCardsbranches) may accept aPendingorder only. The admin manual accept uses a newacceptGiftCardManually(), which additionally allowsCanceled— staff confirming a bank transfer that arrived after the cron already cancelled the order. No caller may re-accept aCompletedorder. The existing XPay/Piraeus caller-level guards were kept — they decide which outcome is rendered (a Completed re-POST must still show success), not whether the write is safe. - Admin API.
POST /admin/giftCards/acceptGiftCard/{orderId}now answers409 Conflictwith{"error": "This gift card order is already completed."}when the order is already Completed, and skips the accept post-actions; 404/500 behaviour is unchanged. No public REST/API contract changed. The REST Viva webhook twin (src/Rest/Webhooks/Controllers/Webhook.php:336-348) did gain the same Pending-only guard and aninfolog on a refused accept, but it still answers200on both paths — the log is observability only. - Secondary fix. A
Canceled → Completedadmin accept now clearscanceled_at— without it the order would resolve as Completed by status but vanish from the admin Completed filter (applyStatusFilter()requirescompleted_at IS NOT NULL AND canceled_at IS NULL) and appear under Canceled instead. Consistency cleanup, not a correctness fix.gift_card_statusis now written as the raw enum value instead of relying onSpatie\Enum\Enum::__toString(); the stored value was identical before and after (MySQL coerced the stringified value the same as the raw int), so there was no data-corruption bug and nothing to remediate in existing rows — this just matches every other write in the model instead of leaving one path dependent on implicit stringification. - Tests: new
tests/Legacy/GiftCards/AdvGiftCardOrdersModelTest.php(10 tests, 40 assertions) covering duplicate accept on Completed/Canceled via both the automated and admin paths, thecanceled_atclear, the admin-filter resolution, claim-before-issue ordering, andNotFoundExceptionpropagation. Two existing spies widenedvoid → bool.
- The bug.
[4.121.0] fix(admin): preserve the merchant's saved payment-method order on the settings redisplay (Advisable-com/ecommercen#553)
application/views/admin/settings/payment_settings.phpredisplayed the storefront and gift-card payways panels in the fixedallPayWays()sequence instead of the merchant's saved order —array_intersect_key()returns entries in its first operand's order, and that operand was the fixed sequence, not the saved list. Both panels now build the selection witharray_merge($payway_methods_keys, $selected_payways_match), matching the already-correct admin panel already using that form on the same page.- Because the settings page is a single form and
Adv_settings.php:1067writes the payway order back unconditionally on any successful save, saving any unrelated field on this page silently persisted the wrong order — customer-visible at checkout on the next visit. Both affected panels are fixed; the admin payways panel was never affected and is unchanged.
[4.121.0] chore(eshop): move the product-page hit-counter write after the read-heavy render path (Advisable-com/ecommercen#549)
- The change.
Adv_products::indexExtras()(ecommercen/eshop/controllers/Adv_products.php) calledupdateProductHits()as its 2nd statement, ahead of the review/related-products/recommendations/blog-article render pipeline that makes up the rest of the method. It now callsupdateProductHits()as the method's last statement, afterrenderProductBlogArticles(). Pure reorder — a single line moved, nothing added or removed. - Scope. This is a reorder within
indexExtras()only. Deferral viadeferred_task/post_systemwas explicitly ruled out of scope:DeferredTaskRunneris designed for lossy work (in-memory only, lost on worker kill, exceptions swallowed to a log), and spending real correctness for a benefit that is currently zero is the wrong trade. - Out of scope, filed separately.
Adv_product_model::updateHits()is a non-atomicSELECT *-then-UPDATElost-update race — real, but that is issue #667 and is untouched here.application/libraries/Pscache.phpis untouched — that is issue #666. - Verification.
indexExtras()has no earlyreturn/exit/redirect/throw/show_404between the old and new call sites (verified by grep over the method body, zero matches), so the reorder cannot cause the increment to be skipped on any path that used to run it.
- The change.
[4.121.0] fix(customer): enforce MessageChannel enum on customer-message-history write path (Advisable-com/ecommercen#497)
- The gap.
POST /rest/customer/customer-message-historyandPOST .../{id}(backend-only,ADMIN/MARKETING) letmessageType— the delivery-channel column — accept any string up to itsvarchar(50)cap.src/Domains/Customer/CustomerMessageHistory/Validator.phpenforced required/non-empty, positive-integeruserId, andmb_strlenlength caps (#502/#506), but never checked membership in theMessageChannelenum that has nominally governed the column since #468 — so a backend client could write'sms','PUSH', or an arbitrary category string straight into the channel column, bypassing a contract the enum's own OpenAPI documentation (#501) already advertised. - The fix.
validateForCreate()andvalidateForUpdate()each gain oneelseif (MessageChannel::tryFrom($data->messageType) === null), ordered after the existing required/non-empty and length checks — so an overlength value still reports the length error, not the enum error. On update the check sits inside the existing$data->messageType !== nullpresence gate, so a partial update omittingmessageTypeis unaffected. A newprivate messageTypeChannelError()derives the message fromarray_column(MessageChannel::cases(), 'value')rather than a hardcoded list, so the error text widens automatically the day #496 adds a third case. - Strict casing, deliberately.
tryFrom()runs on the raw value — only'EMAIL'and'SMS'pass;'email'is rejected. All three legacy writers (Adv_mailer,sms_helper,shopmodule_helper) emit the uppercase enum value, andpatches/BackfillCustomerMessageHistoryMessageType.phpalready normalized the column to uppercase, so accepting mixed case would re-open exactly what that backfill closed. - No read-path or contract change.
ListRequest'smessageTypefilter is untouched — historical rows stay queryable regardless of what they hold. No OpenAPI diff:WriteData.phpalready documented theEMAIL/SMSset (#501); this release only makes the write path enforce what the contract already advertised. - Tests:
ValidatorTest.phpgained 7 cases (create accepts'SMS', rejects lowercase/'PUSH'/an arbitrary category string, error names both channels; update accepts'SMS', rejects lowercase, allowsmessageTypeomitted); one existing test was corrected rather than relaxed (test_validate_for_create_allows_max_length_boundaries→..._type_boundary, since no valid channel value can approach the 50-char cap, so it's now reachable only as a rejection).Integration/.../ServiceTest.phphad 5 write-path fixtures de-inverted (channel now inmessageType, category intype— the #501 field-swap correction) plus 4 paired assertions updated; the direct-insert seeds that bypass the Validator were deliberately left alone.
- The gap.
[4.121.0] test(session): cover the MY_Session route-exclusion guard and the bot-detector fail-open branch (Advisable-com/ecommercen#245)
- What was missing.
MY_Session::isRouteExcluded()(application/libraries/Session/MY_Session.php:71-87) andnormalizeRouteElements()(:89-94) had zero test coverage. Both guards sit in the critical path of every single HTTP request that reaches PHP —MY_Session::__construct()short-circuits beforeparent::__construct()when either returnstrue(MY_Session.php:14-16), so a regression here would silently either create sessions for crawlers or skip sessions for real users. - What was already covered.
tests/Legacy/Session/MySessionBotDetectionTest.phpalready carried 8 passing tests forisBotSession(), added 2026-06-18 during the #285 cache-wiring work. The issue body's claim that this file was untested was stale — this delivery does not duplicate that coverage. - What was added. 9 new tests across 2 files, 402 added lines:
tests/Legacy/Session/MySessionRouteExclusionTest.php(new) — 8 tests forisRouteExcluded()/normalizeRouteElements().tests/Legacy/Session/MySessionBotDetectionTest.php(+1 test) —test_parser_failure_fails_open_and_returns_false, covering thecatch (\Throwable)fail-open atMY_Session.php:43-46.
- Why the fail-open test is not a duplicate of the existing test 4. The pre-existing
test_cache_unavailable_fallback_does_not_throw_and_returns_falseexercisesbotDetectorCache()'s own catch (MY_Session.php:62-68), which returnsnulland lets the parse succeed uncached — the production catch insideisBotSession()(:43-46) was never reached. The new test reaches it by overridingbotDetectorCache()to return a\DeviceDetector\Cache\CacheInterfacewhosefetch()throws, and asserts a Googlebot UA (normallytrue) comes backfalse— proving fail-open rather than a coincidental browser-UAfalse. - Test harness notes. Route stubbing goes through the swappable
GetInstanceRegistryintests/bootstrap.php; a concrete instance set viaGetInstanceRegistry::set()takes precedence over the real-CI factory registered attests/bootstrap_ci.php:79, andtearDown()restores it withset(null).MY_Sessionis never constructed normally (its constructor boots real session machinery) — tests use an anonymous subclass whose constructor does not callparent::__construct(). - Verification.
vendor/bin/phpunit --testsuite Legacy --filter 'MySession'→ OK, 17 tests / 31 assertions. Full--testsuite Legacy→ OK, 797 tests / 1977 assertions, 8 skipped (all pre-existing: Windows platform guards plus two documented issue-#34 placeholders).
- What was missing.
[4.121.0] refactor(mail): remove the orphan ask_us_email template and its preview entry (Advisable-com/ecommercen#97)
- Why.
ask_us_emailhad no dispatch path anywhere in the platform: no layout key inapplication/config/emailViews.json, noEMAIL_SUBJECTSregistry key, and no method inapplication/models/Adv_mailer.phpcalling it. Its only code reference was one entry in the hard-coded$email_viewspreview list inecommercen/settings/controllers/AdvEmailViewer.php::index(). Its content was a strict subset ofcontact_email.php(name/email/message vs. surname/name/tel/email/message), and itswidgets.contact.*lang keys stay in use bycontact_email.php, so no language file changed. - What was removed — three edits, landed together:
- Deleted
application/views/main/mail/ask_us_email.php. - Deleted
application/views/default/mail/ask_us_email.php(thedefault/mail/copy was doubly dead — that directory is unreachable at runtime; see below). - Removed the
'ask_us_email',entry (the first element) fromAdvEmailViewer::$email_viewsinecommercen/settings/controllers/AdvEmailViewer.php::index()— the list drops from 21 entries to 20, and its line range shifts from 166-188 to 166-187.
- Deleted
- Why both halves had to land together. The admin preview page (
application/views/admin/settings/email_views.php) loops$email_viewsand calls$this->load->view($this->client_views . '/mail/' . $email_view, $data, true)for every entry. Deleting the template files while leaving the list entry in place would have fataled the entire admin email-preview page with CodeIgniter's "Unable to load the requested file" — so the array edit and the file deletions are one atomic change, not two. - No upstream behaviour change. Nothing dispatched
ask_us_emailbefore this change, so no live mail flow is affected — this is dead-code removal, not a functional change. AdvEmailViewer::sampleEmailData()was deliberately left untouched — itsfull_name,email, andmessagekeys were consumed only by this template among main-repo mail views, but they remain so a fork that re-adds the template still gets a working preview.
- Why.
[4.121.0] fix(orders/admin): stop the order edit form from rejecting the transporter it already shows as selected
- Bug.
assets/admin/js/order/AdminOrderTransporters.vueopensmounted()withawait this.initTransporters(). That runs while nothing is selected yet, so it publisheswindow.orderDataStore.transporterDatawith an emptyid. Page-ready inapplication/views/admin/footer_js.phpthen firesnotifyTransporters(throughupdateVueTransporterAttributes()) andproduct-item-added, but both listeners are only registered after thatawait, so neither event has a subscriber yet and both are dropped. When the request finally resolves,selectedTransporteris assigned from theclonedTransporterprop — the<select>renders the order's carrier correctly — butsetWindowTransporterData()is never called again, so the store keeps the emptyid. - Symptom. Opening an existing order at
orders_admin/edit/<id>and pressing Επεξεργασία without touching anything failed the submit-time guard infooter_js.php(admin.validation.transporter.required— "Το πεδίο Μεταφορική είναι υποχρεωτικό."), even though the dropdown visibly showed the carrier. The submit was blocked in the browser, so it never reachedAdv_orders_admin::validation()— itstransport_idrule was never the one complaining. The same stale store also lefttransferCostanddeliveryCostat0, so the displayed totals under-reported shipping. Both symptoms cleared as soon as the operator touched the transporter dropdown or any product line, since each of those re-triggerscalculateCosts()— which is why the bug reads as intermittent. - Fix. Once the bus listeners are bound and
selectedTransporterhas been set fromclonedTransporter, runcalculateCosts()a single time when a transporter is already selected. That re-publishestransporterDatawith the real id and the real transfer/delivery costs before the operator can submit. - Scope. Order edit and repeat only — the two views that pass
cloned-transporter(application/views/admin/orders/edit.php,application/views/admin/orders/repeat.php). Order create is unaffected:clonedTransporteris empty there, the guard short-circuits, no extra request is issued, and selection still flows through the existingonChangeTransporter()path. Client-side only; no PHP, no API and no contract change.
- Bug.
[4.121.0] fix(blog/admin): stop the article edit form from wiping the selected Builder block on every save
- Bug.
application/views/admin/blog/blogs/update.phpbuilt the Builder dropdown's field name with a malformed interpolation —"builder_block_id_$langAbbr}"instead of"builder_block_id_{$langAbbr}". PHP terminates the simple$langAbbrvariable at the}, so the brace was emitted literally and the select rendered asname="builder_block_id_el}".Adv_blog_admin::getMuiUpdatePost()readsbuilder_block_id_{$langAbbr}(builder_block_id_el), found nothing in the POST, and fell through its?: null, soAdv_base_model::updateOrInsertMui()wroteblog_mui.builder_block_id = NULLon every save from the article edit screen. - Symptom. A Builder block could only ever be attached at article creation (
create.phpalways had the correct name) and was silently detached again by the first edit-save — the article reverted to "no Builder". Nothing about it was publish-specific:is_publishedonly ever reaches the master table, but toggling Published is a save like any other, so the loss was commonly first noticed at publish time. - Fix. Restore the
{}around the interpolated variable on both theform_dropdown()name argument and theset_value()key. Theset_value()lookup key now matches the rendered field name too, so a re-displayed form after a validation failure keeps the operator's pending selection instead of falling back to the stored value. - Scope. View-only, one line.
Adv_blog_adminandAdv_blog_modelwere always correct and are untouched. Introduced in6fb7ef5837(refactor(blog): modernize blog administration logic and view templates) when$l_abbrwas renamed to$langAbbr; present onmasteranddevelopsince, so every fork carrying that commit is affected.
- Bug.
Notes
[4.121.0] Why the gift-card leg is filtered on save too, honestly stated. Unlike
PAYWAY_EXTERNAL, nothing gates order placement on thePAYWAY_GIFT_CARDS_EXTERNALkey —PlaceOrderService::externalPayways()readsPAYWAY_EXTERNALonly. So this filter does not close an orphaned-order path the way the payments-panel filter does; it exists to keep the saved selection consistent with the panel's own available pool, nothing more.[4.121.0] Behavioural change — out-of-pool saved selections are removed on save, not hidden on display. A
METHODS.PAYWAY_EXTERNALselection containing a key outsidegetExternalPayWays()still appears in the selected box, correctly labelled, until the next save of the payment-settings form removes it — it is not hidden and not silently dropped from view; it is removed on save (of either external panel's field, since a save re-derives both selections). No live deployment is affected today —METHODS.PAYWAY_EXTERNALis empty everywhere, since #672 (which introduced it) has not shipped in a release yet — but state the behaviour for the record: a stale or forged selection survives on screen for one round-trip, then does not survive a save.[4.121.0] Fixed a display bug found while narrowing these panels. The selected-side label lookup on both external panels used the narrowed pool (
getExternalPayWays()/getExternalGiftCardPayWays()) instead ofallPayWays(), so a key saved before the narrowing (e.g. apiraeusvalue already in the DB) resolved no label.array_flip()on the registry list yields integer positions, not names, soform_multiselectrendered the option text as a bare digit —<option value="piraeus">0</option>instead of the payway's actual name. Fixed by resolving the selected-side labels againstallPayWays()on both external panels, independently of which pool narrows the available side.[4.121.0]
piraeusis intentionally not in the pool. #674 (same unreleased batch) added working per-channel Piraeus POS credentials (PIRAEUSBANK_EXTERNAL,PIRAEUSBANK_GIFTCARDS_EXTERNAL) and wiredregisterPiraeus()to read them — those credentials remain valid and in place.piraeussimply is not selectable on either external panel until it has been verified end-to-end through an external frontend, per the capability-list rule above. A deployment expecting REST/headless Piraeus on a nuxt/velora/mobile channel will not get it from this pool alone; onboarding Piraeus on an external channel needs both #674's credentials configured and this pool updated to includepiraeusonce verified.[4.121.0] Check for overrides:
ecommercen/settings/controllers/Adv_settings.php::payment_settings()— a client fork overriding this method will not pick up the display narrowing on either external panel (external_payway_methods_default,externalGiftCardPaywaysDefault), the correctedallPayWays()-based label lookup for the selected side, or either of the two save-time filters (PAYWAY_EXTERNALagainstgetExternalPayWays(),PAYWAY_GIFT_CARDS_EXTERNALagainst the newgetExternalGiftCardPayWays()) — and will keep offering/accepting all 20 payways on its external panels.[4.121.0] UI Update:
application/views/admin/settings/payment_settings.php— a fork carrying its own wholesale copy of this view (already established as a real pattern by #553 and #672's own notes) will not pick up the removal of thegetVivaEnabledPaymentMethods()union on the external payments panel's available side, and will keep offering viva sub-method keys that cannot function on an external channel. The panel's<select>markup itself is otherwise unchanged — only the PHP expression feeding its options.[4.121.0] Check for overrides:
- Behavioural break for anyone already taking Piraeus through REST/headless. The moment this lands,
registerPiraeus()readsPIRAEUSBANK_EXTERNAL, which is empty on every existing deployment — nothing is inherited fromPIRAEUSBANK. Thepiraeusadapter simply stops being registered until an admin fills in the new credential block. Because #673 made/rest/checkout/payment-methodsfail closed, the payway then silently disappears from the payment list and from placement — no error, just a missing payway. This is the single most likely support ticket to come out of this change. application/views/admin/settings/payment_settings.phpis client-overridable, and the consequence on an un-reconciled fork is worse than missing inputs. A fork carrying its own copy of this view will not get the new credential blocks on merge, so it renders no inputs for the two new POS terminals. Butecommercen/settings/controllers/Adv_settings.php:1114-1127savesPIRAEUSBANK_EXTERNALunconditionally on every payment-settings save — there is no guard on that write. So every save on the fork's un-reconciled form posts empty values for those fields and nulls the entire external credential group, not merely leaves it undocumented — the external POS is impossible to configure on that fork until the view is reconciled, even by an admin editing the database directly, since the next save wipes it again. This is consistent with the file's established behaviour rather than a new defect: the gift-card external block is guarded byGIFT_CARDS.ENABLED(skipped on save when gift cards are off), but the unconditional save for the checkout-side blocks mirrors the pre-existing checkout block's own pattern — this addition follows the same unconditional-save convention the file already used for its non-gift-card blocks. Treat this as a reconciliation duty for the fork maintainer, not an upstream defect. Note that issues #553 and #672 both recently changed this same file, so a fork reconciling it has several changes to fold in at once, not just this one.- The two new helper functions are
function_exists-guarded (getPiraeusExternalBankSettings,getPiraeusGiftCardExternalBankSettingsinecommercen/helpers/registry_helper.php) — a fork that has already defined a same-named helper of its own would silently win over the upstream one. No such name exists upstream today, so this is a latent risk rather than a live one.
- Behavioural break for anyone already taking Piraeus through REST/headless. The moment this lands,
[4.121.0] Merchant onboarding, not purely a code deploy: Piraeus must register the callback URL against the new POS terminal bank-side, and the credentials themselves are provisioned per terminal by Piraeus. Enabling the external/mobile channel therefore requires a merchant-side onboarding step in addition to filling in the new admin credential block.
[4.121.0] Check for overrides:
src/Domains/Checkout/PlaceOrderService.php— a fork that overrides this service will not get the payway gate, so its external frontends would keep accepting a deselected payway with no refusal.- REST contract: any fork or client reading
GET /rest/checkout/payment-methodsmust handle an emptypaymentMethods[], and any client posting toPOST /rest/checkout/place-ordermust handle a422carryingerror.code = payway_not_available.
[4.121.0]
src/Domains/Checkout/container.phpgained->arg('$registry', null)onPlaceOrderService(the same lazy\RegistryseamGiftPackagingResolveralready used) — this makes explicit what autowiring already infers, since the constructor's?Registry $registry = nullis nullable, optional, and last. A fork that autowiresPlaceOrderService(this codebase's convention) needs no change; only a fork that disables autowiring and hand-builds a positional argument list would need to add the argument.[4.121.0] Check for overrides: both edited views are client-overridable and a fork carrying its own copy of either will silently miss the new panels on merge — no error, just no external-frontend payway UI in that fork's admin, while the backend half above still happily persists
METHODS.PAYWAY_EXTERNAL/METHODS.PAYWAY_GIFT_CARDS_EXTERNALif anything ever posts to those fields. Issue #553 already established that at least one client fork ships its ownpayment_settings.phpoverride, so this is not hypothetical.application/views/admin/settings/payment_settings.phpapplication/views/admin/footer_js.php- New public surface a fork must reconcile when merging:
- Two POST fields:
external_payway_methods[],external_gift_card_payways[] - Two registry keys:
METHODS.PAYWAY_EXTERNAL,METHODS.PAYWAY_GIFT_CARDS_EXTERNAL - Sixteen new element ids — eight per panel:
multiselect4,multiselect4_to,multiselect4_{rightAll,rightSelected,leftSelected,leftAll,move_up,move_down}, and the same set formultiselect5. The crlcu multiselect plugin binds purely by id, so a fork that already added its own extra panel usingmultiselect4/multiselect5ids would collide with this one — silently breaking a panel with no error. - The two new
footer_js.phpinits must carrysort: false, or the saved payway order gets re-sorted in the UI on every move.
- Two POST fields:
[4.121.0] UI Update: admin → product categories. Deleting a category now succeeds when it has no children and no products, matching the rule the delete action has always claimed to enforce. A category with children, or with products, is still refused. Nothing else about the screen changes.
[4.121.0] Check for overrides: this isn't a removed/renamed/re-signatured method —
hasProducts()keeps its signature; only its body became correct — but the same shadowing risk applies from the other direction, so check anyway.hasProducts(),canDeleteRecord(),hasChildren()anddelete_record()are allpublicon the upstreamAdv_product_category_model, which every fork reaches through the thinProduct_category_model extends Adv_product_category_modelsubclass atapplication/modules/eshop/models/Product_category_model.php. A fork that has not touched this inherits the fix automatically — no action needed. A fork that independently patchedhasProducts()in its own subclass now carries a redundant override that shadows the fixed base method, and should remove it. Verified, not hypothetical: the Evripidis fork (clients/evripidis) overrideshasProducts()atapplication/modules/eshop/models/Product_category_model.php:216, and that override is byte-identical to this fix —->where('category_id', $id)and all. Dropping it is risk-free, not merely advisable: there is no behaviour to regress. The override's own docblock already says "Reported upstream as Advisable-com/ecommercen#669; drop this override once the fix lands upstream."[4.121.0] Check for overrides:
Adv_home::getStreamVideos()(ecommercen/eshop/controllers/Adv_home.php:404) — a fork that copied this method carries the quoted'default'arm; its homepage still 500s on any non-BUNNY/unset provider.[4.121.0] Check for overrides:
Adv_reels::createProvider()(ecommercen/eshop/controllers/Adv_reels.php:255) — signature frozen atprotected ... : ?object. A fork returning a non-null fallback object, or one that also overrode__construct(), still passesnullintoVideoManager.[4.121.0] Check for overrides:
Adv_reels::__construct()(ecommercen/eshop/controllers/Adv_reels.php:29) — a fork overriding the constructor and keepingnew VideoManager($this->createProvider())stays broken on both routes even with the showcase off.[4.121.0] Check for overrides:
Adv_reels::buildPaginatedVideos()(ecommercen/eshop/controllers/Adv_reels.php:81) — a fork override keeping the single-condition bail dereferences a null$videoManageratgetUrls().[4.121.0] Check for overrides:
AdvUploadVideoToStream::executeCommand()(ecommercen/job/libraries/AdvUploadVideoToStream.php:31) — a quoted-arm copy throws instead of no-op-ing.[4.121.0] Check for overrides:
AdvDeleteVideoToStream::executeCommand()(ecommercen/job/libraries/AdvDeleteVideoToStream.php:31) — same quoted-arm risk, throwing job.[4.121.0] Check for overrides:
AdvCheckVideoStatusStream::executeCommand()(ecommercen/job/libraries/AdvCheckVideoStatusStream.php:31) — same quoted-arm risk, throwing job.[4.121.0] Check for overrides:
Adv_reels::$videoManagerchanged type fromVideoManagerto?VideoManager— a fork subclass reading the property unconditionally now needs a null check.[4.121.0] Check for overrides: this delivery widens which forks the
#641invariant fires on, in a shape no previous delivery could reach, and the failure it produces is not test noise. A fork that copied a wholesrc/Domains/**/Repository/directory for a registered target — todayProduct\Product,Cms\Blog\ArticleorCms\Page— and rewrote only the namespace root now has that copy's relations inside the invariant. Those relations were previously skipped outright, so a fork that was green before this can turn red on it without the fork changing at all. When it does, the red is a live leak, not a false positive: forCms\Pagea pre-#639copy means unpublished CMS pages reachable by guests onGET /rest/cms/page, with?with=children.translationsreturning thecategories_muibody, meta fields and slug beneath an unpublished node.[4.121.0] How to fix it: re-sync the flagged configurator against the current
src/version — take the scope class named in the failure message as a constructor dependency and hand itsrelationScope()to each affectedRelation'svisibilityScope:argument as a named argument ($visibilityScopeisRelation::__construct's tenth parameter, so a positional argument is not a safe substitute). Every hop needs its own declaration; avisibilityScopedoes not cascade across relation definitions. Do not add the relation tovisibilityScopeExemptions()to make the suite pass — that list is only for relations with no client-reachable path, each carrying its own stated justification, and the paths above are guest paths. And do not revert the target lookup to a rawisset()— that would silently re-open every namespace-local target this delivery fixed, for every fork, not just yours.[4.121.0] Supersedes a bound recorded by
#658.#658's fragment records that a fork copying the wholesrc/Domains/Cms/Page/Repository/directory has its page relations outside the invariant, and asks fork owners to verify those relations by hand "until#661lands".#661has landed: those relations are now checked, and the manual verification that note asks for is no longer needed.#658's fragment is left as written. Both fragments are still inunreleased/and assemble into the same version, which is the case this family has already settled:#655left#653's fragment untouched from inside that same unreleased batch, and the whole visibility-scope family is still unreleased. A fragment is the point-in-time record of one delivery — so read the two together, this entry last.[4.121.0] Check for overrides: a fork that has copied or overridden
tests/Unit/Domains/Support/Repository/RelationConfigurationTest.phpitself will not pick up the normalised target lookup, keeps the blind spot for every namespace-local copy it holds, and stays silently unprotected against exactly the hazard above — re-sync that test file against upstream rather than keeping a divergent copy.[4.121.0] No
php migrator.php migrate, nocomposer install, no frontend build needed for this change.[4.121.0] Check for overrides: this delivery widens which forks the
#641invariant fires on, and the failure it produces is not test noise.RelationConfigurationTest's walk coverscustom/Domainswhen that directory exists, so a fork carrying its own copy ofCms\Blog\Article'sRepositoryConfigurator— aliased over the upstream class incustom/Domains/container.php, and therefore the class the DI container actually serves — now has that copy inside the invariant for itstagshop too. A pre-#640snapshot constructs fine, autowires fine, boots the container fine, and returnsvisibilityScope === null. When this guard turns red on a fork it means that fork is serving INACTIVE BLOG TAGS to unauthenticated callers.Cms\Blog\Article.tagsembeds on the guest-readableGET /rest/cms/blog/article, so?with=tagsreaches them directly on every row of anindex()page, and any longer chain reaching a tag reaches them at depth — which a per-endpointrelationsallow-list cannot cover, sinceRelationFilterMiddlewareinspects only the first?with=hop.#624scoped thecms/blog/tagendpoint and denied?sort=tags.active; neither reaches a relation — a forced filter scopes only the endpoint it is registered on, and denying a sort removes the enumeration primitive rather than the disclosure.[4.121.0] How to fix it: re-sync the fork's copy of
Cms\Blog\Article\Repository\RepositoryConfiguratoragainst the currentsrc/version — injectBlogTagVisibilityScopeinto the configurator's constructor and hand itsrelationScope()totagsas a namedvisibilityScope:argument ($visibilityScopeisRelation::__construct's tenth parameter, so a positional argument is not a safe substitute). The scope class is stateless — one WHERE clause, no tree walk, no query of its own, no DB dependency to inject — because tags are a flat vocabulary with no parent/child column. Do not add the hop tovisibilityScopeExemptions()to make the suite pass: that list is only for relations with no client-reachable path, each carrying its own stated justification, and/rest/cms/blog/articleis a guest path. Exempting it would re-open the hole and silence the alarm along with it.[4.121.0] Check for overrides — this configurator now carries THREE injected scopes, and a re-sync must restore all three.
Cms\Blog\Article's constructor takesProductVisibilityScope,BlogCategoryVisibilityScopeandBlogTagVisibilityScope, and itscommentshop additionally uses the staticCommentVisibilityScope. Every one of those hops is now inside the invariant through a different registry entry, and they are different classes with different rules —products(#637),categories(#640),comments(#616) andtags(#640). A fork that restores only the hop its red test names will go red again on the next one. Thearticles-family hops are the most severe of the set: they leak the complete pre-publication body of every unpublished draft (blog.is_publishedisNOT NULL DEFAULT 0) in one unauthenticated request.[4.121.0] Check for overrides: a fork that has copied or overridden
tests/Unit/Domains/Support/Repository/RelationConfigurationTest.phpitself will not pick up the new registry entry, the coverage pin or the blog-tag failing-case test, and stays silently unprotected against exactly the hazard above — re-sync that test file against upstream rather than keeping a divergent copy. The registry is now complete at seven entries, so a fork re-syncing this file gets the whole family at once rather than one entry per upstream release.[4.121.0] Check for overrides: this delivery widens which forks the
#641invariant fires on, and the failure it produces is not test noise.RelationConfigurationTest's walk coverscustom/Domainswhen that directory exists, so a fork carrying its own copy ofCms\Blog\Category's (orCms\Blog\Article's)RepositoryConfigurator— aliased over the upstream class incustom/Domains/container.php, and therefore the class the DI container actually serves — now has that copy inside the invariant. A pre-#640snapshot constructs fine, autowires fine, boots the container fine, and returnsvisibilityScope === null. When this guard turns red on a fork it means that fork is serving INACTIVE BLOG CATEGORIES, and theirblog_categories_muibodies, to unauthenticated callers.rest_policies.php:227-234givesBlogCategoryindex/show/itemauth => 'guest', so?with=childrenand?with=parentreach the taxonomy directly on every row of anindex()page, and?with=children.translationshands back theblog_categories_muirows (name, slug, meta, description) beneath an inactive node.?with=children*additionally makes the recursive descent's cost attacker-chosen, because the recursion marker is stripped before matching.#624scoped thecms/blog/categoryendpoint and denied?sort=children.active; neither reaches a relation — a forced filter scopes only the endpoint it is registered on, and denying a sort removes the enumeration primitive rather than the disclosure.[4.121.0] How to fix it: re-sync the fork's copy of
Cms\Blog\Category\Repository\RepositoryConfiguratoragainst the currentsrc/version — injectBlogCategoryVisibilityScopeinto the configurator's constructor and hand itsrelationScope()to each ofchildrenandparentas a namedvisibilityScope:argument ($visibilityScopeisRelation::__construct's tenth parameter, so a positional argument is not a safe substitute). Both hops need their own declaration; avisibilityScopedoes not cascade across relation definitions, and the recursive descent then re-applies it at every depth. The scope class is stateless — no DB dependency, no tree walk, nothing to memoise — which is exactly what the#640flatness measurement bought. Do not add either hop tovisibilityScopeExemptions()to make the suite pass: that list is only for relations with no client-reachable path, each carrying its own stated justification, and/rest/cms/blog/categoryis a guest path. Exempting it would re-open the hole and silence the alarm along with it.[4.121.0] Check for overrides — the fork's
articleshop is a separate and more severe exposure on the same configurator.BlogCategory.articlesis scoped byArticleVisibilityScope, not by this entry's scope, and it is already inside the invariant via the Article registry entry (#655). A fork whose copy lost that argument leaks the complete pre-publication body of every unpublished draft (blog.is_publishedisNOT NULL DEFAULT 0) in one unauthenticated request. Re-syncing the configurator must restore both injected scopes — the module's constructor takes two, and they are different classes.[4.121.0] Check for overrides: a fork that has copied or overridden
tests/Unit/Domains/Support/Repository/RelationConfigurationTest.phpitself will not pick up the new registry entry, the coverage pin or the blog-category failing-case test, and stays silently unprotected against exactly the hazard above — re-sync that test file against upstream rather than keeping a divergent copy.[4.121.0] Check for overrides: this delivery widens which forks the
#641invariant fires on, and the failure it produces is not test noise.RelationConfigurationTest's walk coverscustom/Domainswhen that directory exists, so a fork carrying its own copy ofCms\Page'sRepositoryConfigurator— aliased over the upstream class incustom/Domains/container.php, and therefore the class the DI container actually serves — now has that copy inside the invariant provided the copy still names the upstreamAdvisable\Domains\Cms\Page\Repository\Repositoryas the relation target. A pre-#639snapshot constructs fine, autowires fine, boots the container fine, and returnsvisibilityScope === null. When this guard turns red on a fork it means that fork is a live leak: unpublished CMS pages reachable by guests onGET /rest/cms/page,?with=children.translationsreturning thecategories_muibody, meta fields and slug beneath an unpublished node, and?with=children*making the walk's cost attacker-chosen becauseWithParserdoes not strip the recursion marker.[4.121.0] How to fix it: re-sync the fork's copy of
Cms\Page\Repository\RepositoryConfiguratoragainst the currentsrc/version — takePageVisibilityScopeas a constructor dependency and hand itsrelationScope()to each of the twoRelations'visibilityScope:argument as a named argument ($visibilityScopeisRelation::__construct's tenth parameter, so a positional argument is not a safe substitute). Both hops need their own declaration; avisibilityScopedoes not cascade across relation definitions. Do not add either relation tovisibilityScopeExemptions()to make the suite pass: that list is only for relations with no client-reachable path, each carrying its own stated justification, and/rest/cms/pageis a guest path. Exempting it would re-open the hole and silence the alarm along with it.[4.121.0] Check for overrides — a bound worth knowing before trusting a green run. A fork that copies the whole
src/Domains/Cms/Page/Repository/directory, rather than the configurator alone, makes the configurator's bareRepository::classresolve into the fork's own namespace. Those relations are then not checked by this invariant at all (Advisable-com/ecommercen#661), and the blind floor stays quiet because upstream's own two relations still match. A green suite on such a fork is not evidence the fork's page relations are scoped — verify them by reading the copied configurator until#661lands.[4.121.0] Check for overrides: a fork that has copied or overridden
tests/Unit/Domains/Support/Repository/RelationConfigurationTest.phpitself will not pick up the new registry entry, the coverage pin or the Page failing-case test, and stays silently unprotected against exactly the hazard above — re-sync that test file against upstream rather than keeping a divergent copy.[4.121.0] Check for overrides: this delivery widens which forks the
#641invariant fires on, and the failure it produces is not test noise.RelationConfigurationTest's walk coverscustom/Domainswhen that directory exists, so a fork carrying its own copy ofCms\Blog\Comment's (orCms\Blog\Article's)RepositoryConfigurator— aliased over the upstream class incustom/Domains/container.php, and therefore the class the DI container actually serves — now has that copy inside the invariant. A pre-#616snapshot constructs fine, autowires fine, boots the container fine, and returnsvisibilityScope === null. When this guard turns red on a fork it means that fork is leaking its MODERATION QUEUE.CommentVisibilityScopenarrows toblog_comments.status = 'approved', so the stale copy serves the pending and rejected rows — the spam, abuse and not-yet-vetted backlog that exists precisely because nobody has reviewed it — to unauthenticated callers. Both doors are guest-open:rest_policies.php:254-259givesBlogCommentindex/show/itemauth => 'guest', so?with=childrenand?with=parentreach it directly on every row of anindex()page, and:218-224does the same forBlogArticle, so?with=comments.childrenreaches it one hop away.?with=children*additionally makes the recursive descent's cost attacker-chosen, because the recursion marker is stripped before matching.[4.121.0] How to fix it: re-sync the fork's copy of
Cms\Blog\Comment\Repository\RepositoryConfiguratoragainst the currentsrc/version — handCommentVisibilityScope::relationScope()to each ofparentandchildrenas a namedvisibilityScope:argument ($visibilityScopeisRelation::__construct's tenth parameter, so a positional argument is not a safe substitute). Both hops need their own declaration; avisibilityScopedoes not cascade across relation definitions, and the recursive descent then re-applies it at every depth viaOneToManyLoader::loadRecursiveChildren(). Unlike every other scope in the tree this one needs no constructor injection at all — it is a static call, so the configurator stays dependency-free. Do not add either hop tovisibilityScopeExemptions()to make the suite pass: that list is only for relations with no client-reachable path, each carrying its own stated justification, and/rest/cms/blog/commentis a guest path. Exempting it would re-open the hole and silence the alarm along with it.[4.121.0] Check for overrides — do not "simplify" the status rule into
$scope.Article.commentsis the one relation in the tree where both slots are populated, and that pairing is the point rather than an accident (#616):scopeisparent_comment_id IS NULL, an always-on structural invariant so nested replies are not duplicated at the top level, whilevisibilityScopeis the approved-only visibility rule that backend callers are exempt from throughRelation::VISIBILITY_EXEMPT_ALL— because moderating the pending and rejected rows is the entire admin use case. Folding the status rule into$scopewould blind the admin blog view; folding theparent_comment_idrule into$visibilityScopewould let an admin read the reply tree twice.[4.121.0] Check for overrides: a fork that has copied or overridden
tests/Unit/Domains/Support/Repository/RelationConfigurationTest.phpitself will not pick up the new registry entry, the coverage pin or the blog-comment failing-case test, and stays silently unprotected against exactly the hazard above — re-sync that test file against upstream rather than keeping a divergent copy.[4.121.0] Check for overrides: this delivery widens which forks the
#641invariant fires on, and the failure it produces is not test noise.RelationConfigurationTest's walk coverscustom/Domainswhen that directory exists, so a fork carrying its own copy ofProduct\Category'sRepositoryConfigurator— aliased over the upstream class incustom/Domains/container.php, and therefore the class the DI container actually serves — now has that copy inside the invariant. A pre-#625snapshot constructs fine, autowires fine, boots the container fine, and returnsvisibilityScope === null. When this guard turns red on a fork it means that fork is a live leak: on the guest-readableGET /rest/product/category,?with=children.translationsreturns theshop_product_category_muirows — name, slug, meta and fulltext — of categories the caller must not be able to navigate to, on every row of anindex()page, so the hidden set is bulk-enumerable in one unauthenticated request;?with=relativeCategories.translationsis the same disclosure reached sideways; and?with=children*makes the walk's cost attacker-chosen. The affected population is not marginal — categories hidden by the ancestor-chain rule but not by their own flag are 1.2% of wecare, 3.7% of smile and 42% of pharm16.[4.121.0] How to fix it: re-sync the fork's copy of
Product\Category\Repository\RepositoryConfiguratoragainst the currentsrc/version — takeCategoryVisibilityScopeas a constructor dependency and hand itsrelationScope()to each ofparent,childrenandrelativeCategoriesas a namedvisibilityScope:argument ($visibilityScopeisRelation::__construct's tenth parameter, so a positional argument is not a safe substitute). All three hops need their own declaration; avisibilityScopedoes not cascade across relation definitions. Do not add any of them tovisibilityScopeExemptions()to make the suite pass: that list is only for relations with no client-reachable path, each carrying its own stated justification, and/rest/product/categoryis a guest path. Exempting it would re-open the hole and silence the alarm along with it.[4.121.0] Check for overrides —
tagGroupsmust stay unscoped. A fork "completing the set" by attachingCategoryVisibilityScopetotagGroupswould not merely be wrong, it would be a hard SQL error: that relation targetsshop_product_group_tags, which carries no visibility flag, and the closure namesshop_product_category.id, a table absent from the relation'sFROM. The invariant does not ask for it —tagGroupsresolves toProduct\Tag\Category\Repository\Repository, which is not a registry key.[4.121.0] Check for overrides: a fork that has copied or overridden
tests/Unit/Domains/Support/Repository/RelationConfigurationTest.phpitself will not pick up the new registry entry, the coverage pin or the product-category failing-case test, and stays silently unprotected against exactly the hazard above — re-sync that test file against upstream rather than keeping a divergent copy.[4.121.0] Check for overrides: this delivery widens which forks the
#641invariant fires on, and the failure it produces is not test noise.RelationConfigurationTest's walk coverscustom/Domainswhen that directory exists, so a fork carrying its own copy ofCms\Blog\Author,Cms\Blog\Category,Product\CategoryorProduct\Product'sRepositoryConfigurator— aliased over the upstream class incustom/Domains/container.php, and therefore the class the DI container actually serves — now has that copy inside the invariant. A pre-#653/#640/#625snapshot constructs fine, autowires fine, boots the container fine, and returnsvisibilityScope === null. When this guard turns red on a fork it means that fork is a live leak: unpublished blog articles reachable by guests,?with=articles.translationsreturning the complete pre-publication body of every draft (blog.is_publishedisNOT NULL DEFAULT 0), bulk-enumerable in one unauthenticated request.[4.121.0] How to fix it: re-sync the fork's copy of the flagged configurator against the current
src/version — takeArticleVisibilityScopeas a constructor dependency and hand itsrelationScope()to theRelation'svisibilityScope:argument as a named argument ($visibilityScopeisRelation::__construct's tenth parameter, so a positional argument is not a safe substitute). Do not add the relation tovisibilityScopeExemptions()to make the suite pass: that list is only for relations with no client-reachable path, each carrying its own stated justification, and?with=articles.translationsis a guest path. Exempting it would re-open the hole and silence the alarm along with it.[4.121.0] Check for overrides: a fork that has copied or overridden
tests/Unit/Domains/Support/Repository/RelationConfigurationTest.phpitself will not pick up the new registry entry or the coverage pin, and stays silently unprotected against exactly the hazard above — re-sync that test file against upstream rather than keeping a divergent copy.[4.121.0] Check for overrides — a fork keeping its own copy of either configurator silently loses the scope. Such a copy is autowired by FQCN, constructs fine on the old argument list, returns
visibilityScope === null, and boots green with a passingcheck-container— so it keeps serving unpublished article bodies to guests with no error and no warning. This is the same shape#637/#639/#640each warned about.Cms\Blog\Author's configurator previously had no constructor at all — it gains its first (0 → 1).Product\Product's already had one (takingCategoryVisibilityScopesince#588) and goes 1 → 2. A fork carrying either copy needs the dependency added.- If a fork must keep its own copy, it has to pass
visibilityScope:as a named argument. It isRelation::__construct's tenth parameter, and forProduct.articles— which isMANY_TO_MANY— a positional tenth lands silently in$pivotTable, which is data corruption, not a no-op. - Preferred over patching the fork's copy: drop the forked configurator and rebind
ArticleVisibilityScopefrom the fork's own module container instead (Symfony DI here is last-wins by module load order), so the fork keeps inheriting every relation upstream adds later rather than freezing a copy.
[4.121.0] UI Update: both are collection embeds, so a hidden article simply drops out of the array — the list is shorter and may now be empty; no key is removed and no error is raised. The owning list itself is not filtered and pagination counts do not move.
[4.121.0] Check for overrides: three surfaces a fork may carry its own copy of, and one of them fails silently.
src/Rest/Support/Controllers/HandlesRestfulActions.php—buildListRequest()(protected) now clamps before returning, and two newprotectedseams sit beside it:maxPageSize(): intand the constantsMAX_PAGE_SIZE_CONFIG_KEY/DEFAULT_MAX_PAGE_SIZE. No signature changed and nothing was removed, so an existing override still compiles — but a fork that overridesbuildListRequest(), or whose controller builds its ownListRequestfrom the raw(new $this->listRequestClass())->generate($this->input)construction, silently keeps the unbounded behaviour, with no error and a green boot. That is the one to grep for. A fork wanting a different ceiling should overridemaxPageSize()(it must return a positive int) rather than the build path.application/config/app.php— new keyrest_max_page_size(default1000). This file is client-owned; a fork that does not merge the line still gets the 1000 ceiling from the code-level fallback, so nothing breaks if it is missed — but the ceiling is not tunable for that shop until the key is added.src/Rest/Admin/Controllers/Role.php—index()no longer builds itsListRequestdirectly. A fork overridingRole::index()should adopt$this->buildListRequest()for the same reason.
[4.121.0] UI Update: REST contract change for storefront consumers — behavioural, not structural. No route, response field, schema or status code changed.
?limitabove the ceiling is now clamped for guest and customer callers: the request still returns HTTP200with a normal body and no error of any kind to signal that it was reduced. A headless client that was passing a limit above 1000 (previously honoured literally) will now receive 1000 rows per request and must page through the result set — readingpagination.per_page,pagination.total_pagesandpagination.has_nextfrom the response rather than assuming its requested size was honoured, and never echoing its own requested limit back as the page size.pagination.per_pagereports the clamped value, so it is the reliable way to detect that a clamp occurred. Clients at or below 1000 — which is every known storefront consumer, including the catalogue-facet reader and the one-shot reference-list readers — are unaffected, and a request that omitslimitstill gets 15 exactly as before. Backend and admin clients are exempt and need no change.[4.121.0] Follow-up, deliberately not done here: the
limitquery parameter is documented per controller, not centrally —name: 'limit'appears in 109 files undersrc/Rest/. Only/rest/product/product's description was updated to state the ceiling and the clamping behaviour; of the remaining 108, 107 still read "Pagination limit" (one,Admin\Controllers\User.php, already carried different pre-existing wording — untouched here either way). Editing all of them was out of scope for this change and wants its own pass — or, better, a shared OpenAPI parameter component so the description has one home.[4.121.0] No DB migration, no schema change, no route change, no language-key change. The OpenAPI annotation change above is source-only —
public/openapi*.jsonandpublic/api-versions.jsonregenerate at the release cut, not here. Arest_api_versions.phpentry is recorded under the per-task cadence, framed as a storefront-breaking clamp with the client action spelled out.[4.121.0] Check for overrides:
t()(application/core/MY_Lang.php) — signature unchanged (t(string $line, array $data = []): string). A fork that overridest()keeps its own unguardedvsprintf()and stays vulnerable to this class of fatal; it should delegate toAdvLangFormatter::format()or replicate the guard.[4.121.0] Check for overrides:
ExternalLang::line()(ecommercen/libraries/ExternalLang.php) — signature unchanged (line(string $line, array $params = []): string). Same risk ast()for a fork overriding it.[4.121.0] Check for overrides: any forked
adv_advisable_lang.php/adv_external_lang.php/adv_theme_lang.php(ecommercen/language/<locale>/and any client override) — a fork shipping its own copies of these files carries its own copy of these defects: the bare%, the placeholder-count typos, the missing gift-card SMS key. Forks should re-run the same check against their own language files.[4.121.0] Check for overrides:
Adv_products_admin.phpprice-update success message (ecommercen/eshop/controllers/Adv_products_admin.php:1734) — the call changed fromsprintf(t('...'), $i)tot('...', [$i]). A fork overriding this method and keeping the old double-format shape stays broken.[4.121.0] Check for overrides:
AdvLangFormatteris new and deliberately non-final, withprotectedhelper methods — a fork may extend it.[4.121.0]
giftCard.messageandgiftCard.messageInformCustomerwere deliberately left in place despite having no call site upstream, because a fork may call them. They were not deleted.[4.121.0] Check for overrides:
Advisable\Domains\Support\Service\HandlesNotEmptyFiltersis deleted. A fork thatuses it in aCustom\Service will fatal on a trait-not-found. Replace theusewithAdvisable\Domains\Support\Service\BuildsFilterSpecifications, which providesisNotEmptyFilter()unchanged and replaceshandleNotEmptyFilter($filter, $this->repository)withnotEmptyFilterSpecification($filter)— the$repositoryargument is gone, because all 111 upstream call sites passed$this->repositoryand the trait already holds it.[4.121.0] Check for overrides: every domain
Service'sbuildSpecifications()is gone from the Service classes and is now inherited fromBuildsFilterSpecificationsas aprotectedmethod. A fork carrying its own copy ofbuildSpecifications()breaks in one of two ways, and which one depends on the copy's visibility. In the previous release 110 of the 114 upstream copies wereprivate, so a verbatim fork copy isprivatetoo:- A
privatecopy in a class that EXTENDS an upstreamServiceis a HARD FATAL AT CLASS LOAD — PHP forbids narrowing an inheritedprotectedmethod:Fatal error: Access level to Custom\...\Service::buildSpecifications() must be protected (as in class Advisable\...\Service) or weaker(verified on PHP 8.1.29). This is the likely shape, because the documented client-override pattern is aCustom\subclass plus a DI alias. It fails loudly and immediately — nothing silent about it. Fix: widen the copy toprotected, then delete it and override a seam instead. - A copy in a fork that re-declares the whole Service class and
uses the trait itself is legal, silently wins over the trait's method, and boots green — no error, no warning, a passingcomposer run check-container, and nothing in any test suite notices. This is the drift case, and it is the dangerous one: the fork stops receiving every later upstream change to that loop, including any change to theWithRelations(..., $listRequest->visibilityExemptions)argument, which is what restores full visibility for a privileged (backend) reader while relation visibility scopes stay fail-closed for everyone else. A fork whose copy drifts there breaks relation visibility scoping invisibly. - Triage a fork with one command:
grep -rnE "(private|protected|public) function buildSpecifications" custom/— aprivatehit is the loud fatal above, aprotected/publichit is the silent drift.
- A
[4.121.0] Do not carry a copy of the loop in either shape. Override the narrowest seam that expresses the intent instead:
filterOperator(FilterRequest $filter): ?string— one filter type maps to a different SQL operator (≈3 lines; this is the seam that did not exist before).filterSpecification(FilterRequest $filter): ?Specification— this domain needs a different specification class for some filters. Delegate the tail to$this->operatorFilterSpecification($filter), neverparent::— a trait method is not reachable throughparent::.sortSpecification(SortRequest $sort): ?Specification— same, for a root sort; delegate to$this->columnSortSpecification($sort).additionalSpecifications(ListRequest $listRequest): array— extra specifications between the sorts and pagination (e.g. a conditional default sort).
[4.121.0] Check for overrides: the filter-type → operator mapping is now platform-wide and DI-aliasable. To replace it for a whole client, alias the interface from
custom/Domains/container.php:php$services->set(\Custom\Domains\Support\Request\QueryListBuilder\FilterOperatorMap::class)->public(); $services->alias( \Advisable\Domains\Support\Request\QueryListBuilder\FilterOperatorMapInterface::class, \Custom\Domains\Support\Request\QueryListBuilder\FilterOperatorMap::class )->public();A fork that only needs a different mapping for ONE domain should override that Service's
filterOperator()rather than alias the interface platform-wide.[4.121.0] Check for overrides:
Cms\Builder\ListRequestandCms\Page\ListRequestchanged a declared filter type —titleandbannerImagefromPartialtoExact. A fork carrying its own copy of eitherListRequestwill start emittingLIKEon that key. Upstream, thePartialdeclaration was inert because the Service'sfilterOperator()override forced'='; that override is now gone, so a fork's retainedPartialdeclaration reaches the sharedFilterOperatorMapand correctly resolves to'LIKE'— turning?filter[title]=Herointo a substring search that also matches every title merely CONTAINING "Hero". It is a live REST behaviour change for that fork only, it is silent, and no test upstream can see it. Fix: change the copy's declaration toExact(matching upstream), or, if the fork actually wants substring matching, keepPartialdeliberately and record that as a client customization. Triage:grep -rn "builder_blocks.title\|categories.banner_image" custom/.[4.121.0] Check for overrides:
application/config/container/modules.phpgains a new module,src/Domains/Support/Request/QueryListBuilder/container.php. A client fork that maintains its own copy ofmodules.phpmust add the same line, orFilterOperatorMapInterfacewill not resolve and every Service will fall back to the upstream mapping (correct behaviour, but a client alias would silently never apply).[4.121.0] Check for overrides: none, verified rather than assumed. A fork carrying its own
ListRequest.phpcopy is untouched by this change and keeps declaringvendorCodeasPartial— but on an unmodifiedProduct\Product\Service, the generic filter branch still applies'='regardless of the declared type, so the fork's behaviour is identical before and after this fix.[4.121.0] Why bother fixing a no-op: the shared
FilterOperatorMapresolvesFilterRequestType::Partialto aLIKEoperator platform-wide —Product\Productis the one domain that deliberately overrides it to keep'='for this column. As long asvendorCodestayed declaredPartial, the obvious "make this consistent with its siblings" refactor toProduct\Product\Servicewould not have produced aLIKEscan: the bound guard added for the one-sided comparisons is keyed on "not the exact operator" (Service.php:117,:131-134), so aPartial => 'LIKE'arm would route a non-numeric vendor code into that guard,is_numeric($bound)would fail, and no clause would be emitted at all — the filter silently drops and the read widens to every product, a worse outcome than aLIKEscan. DeclaringExactdefuses that, because the type no longer routes anywhere near that arm.[4.121.0] Check for overrides:
Advisable\Domains\StorefrontConfig\StorefrontConfigProvider::__construct()gained a fourth required argument (VideoShowcaseConfigResolver) — the same break shapeshipping(#643) caused. A fork re-declaring the service in its own container fails at container compile on the next upstream merge; a fork with aCustom\subclass still callingparent::__construct()with the old argument count compiles fine and fails later as a runtimeArgumentCountErroron first instantiation. A fork that has never touchedsrc/Domains/StorefrontConfig/needs no action.[4.121.0] Check for overrides:
application/config/rest_routes.php,rest_policies.php,rest_features.phpandrest_api_versions.phpall changed. A fork carrying its own copy of any of these must merge in the newReelroute block, theReel::classpolicy entry, thevideoShowcasefeature key and the new1.Xentry respectively.[4.121.0] Check for overrides: two new seams, both
protectedand non-final:Advisable\Domains\Cms\Reel\Repository\Repository::rowInvariant()— a fork overriding it must only ever NARROW the row set, never relax it, since the four clauses are the entire security boundary for this endpoint; andAdvisable\Domains\Cms\Reel\Service::{decorate,decorateUrls,providerUrls,videoStream,storedFileUrl,requestLanguage}().[4.121.0] Check for overrides:
Advisable\Domains\Cms\Reel\Repository\Repository::productIdsFor()takes a second parameter (bool $scopeToVisibleProducts = true, fail-closed) and the repository's constructor now also takesProductVisibilityScope. A fork constructing this repository directly, or overridingAdvisable\Domains\Cms\Reel\Service::scopesProductIds(), must keep the default scoped — an override may only ever returnfalsefor more-privileged callers, never fewer.[4.121.0] UI Update: a headless storefront currently reading reels from
GET /rest/cms/videoshould move toGET /rest/cms/reel— the two endpoints project different tables with unrelated content.[4.121.0] Behavioural note vs. the rendered storefront: a reel with no translation in the active language is returned here with
name: null, whereas the legacy rendered storefront inner-joins the MUI table and omits such a reel from the listing entirely.[4.121.0] No DB migration, no schema change, no write endpoint. OpenAPI annotations were added for the new endpoint and the new storefront-config section only; the published specs regenerate at the release cut, not in this branch.
[4.121.0] Check for overrides: a fork carrying its own copy of
Advisable\Domains\Support\Repository\Specification\Filter— or its own Service that hand-builds aBETWEENcondition string instead of routing through this class — is still on the vulnerable shape and must take this change. Such a class is autowired by FQCN and constructs fine: the container boot stays green, nothing warns, and the endpoint keeps serving the injectable SQL. There is no error to notice this by.[4.121.0] Check for overrides: a fork whose tests assert on emitted SQL text — the
BETWEENkeyword, or the number of string literals in a compiledWHEREclause — must re-baseline those assertions. The rows the query returns do not move; only the SQL text producing them does.[4.121.0]
Filterstays non-final. The new bounds helper,betweenBounds(), isprivate, matching its siblingboundValue()— a fork can subclassFilterand overrideapply(), but there is no seam to reach it:Filterimplements only the markerSpecificationinterface, carries no container registration, and is constructed directly at all 85new Filter(...)call sites, every one of which a subclass would have to replace. A fork needing differentBETWEENhandling should expect to carry a patch, not an alias — one more reason to take this change rather than diverge.[4.121.0] No DB migration, no OpenAPI schema change, no route change, no new configuration, no language-key change.
application/config/rest_api_versions.phpcarries its own1.Xentry for this change under the per-task cadence, framed as a security fix with no contract change; that entry is authored and maintained separately from this fragment.[4.121.0] UI Update: REST contract addition for headless storefront consumers, no removals and no breaking change.
GET /rest/storefront-confignow returns a third section alongsidelistingandloyalty:json"shipping": { "freeShippingThreshold": 65.0 }Stop hardcoding the free-shipping banner threshold — read it from here so it tracks what the merchant actually configured. Two things a client must know: the value is a display figure in the shop currency and must never be quoted as, or used to derive, a shipping price (take every payable figure from
POST /rest/checkout/shipping/POST /rest/checkout/totals, which apply the per-transporter, per-country thresholds); and0means no threshold is configured, so the banner should be hidden rather than read as "free shipping on everything". A client that ignores the new section is unaffected.[4.121.0] Check for overrides:
Advisable\Domains\StorefrontConfig\StorefrontConfigProvider::__construct()gained a third required argument (ShippingConfigResolver). A fork must add it, and how the omission surfaces depends on which override mechanism the fork used — the two fail at different times, so check for both:- a fork re-declaring the service in its own
custom/Domains/**/container.phpfails at container compile on the next upstream merge, caught bycomposer run check-containerbefore anything runs; - a fork with a
Custom\subclass whose constructor still callsparent::__construct()with the old two arguments compiles fine and fails later, as a runtimeArgumentCountErroron first instantiation of the provider — still loud, but not caught by the compile check.
A fork that has never touched
src/Domains/StorefrontConfig/needs no action. Note thatdocs/guides/RestApiModules.mddocuments the supported override mechanism as acustom/Domains/**/container.phpalias/registration and does not documentCustom\subclassing of aggregator classes as an extension point, so the second path should be rare.- a fork re-declaring the service in its own
[4.121.0] No DB migration, no schema change, no route change, no auth change, no language-key change, no removed or renamed response field. A
rest_api_versions.phpentry is recorded under the per-task cadence, framed as an addition. OpenAPI annotations were added for the new section; the published specs regenerate at the release cut, not in this branch.[4.121.0] Check for overrides: two shared platform classes changed, and a fork carrying its own copy of either gets a silent wrong answer rather than an error.
src/Domains/Support/Repository/Specification/Filter.php— four newswitchbranches ('>=','>','<=','<') plus a newprivate boundValue()helper. A fork shipping its own copy of this file does not get them, so every one of the four operators falls through to itsdefaultarm, which is'='. The result is inverted, not merely wrong:filter[priceGt]=0becomesprice = 0, so a caller asking to exclude the zero-priced gift SKUs receives exactly and only those SKUs. No error, no warning, a green container boot, a200response.src/Domains/Support/Request/QueryListBuilder/FilterRequestType.php— four new enum cases. A fork with its own copy will fatal onFilterRequestType::Gteetc. the moment itsListRequest(or an upstream one it inherits) references a case its copy lacks — that failure at least announces itself, unlike theFilter.phpone.- Neither class is DI-registered or aliasable — both are plain value/specification classes instantiated directly — so a fork cannot override them with a
Custom\alias. Reconciling means re-applying the branches to its copy, or (better) deleting the copy.
[4.121.0] UI Update: REST contract addition for storefront consumers, no removals and no breaking change.
GET /rest/product/productand/rest/product/product/itemacceptfilter[priceGte],filter[priceGt],filter[priceLte],filter[priceLt];filter[price]behaves exactly as before. A request that sends no bound returns the same rows it always did, including zero-priced and NULL-priced products — nothing is hidden by default. Two behaviours a client must know: a NULL-priced product disappears as soon as any bound is supplied, and a non-numeric bound is ignored silently (the request still returns200with that key's predicate simply absent) — there is no validation error to catch, so validate bounds client-side if a typo must be visible.[4.121.0] Deliberate limitation, recorded so it is not read as an oversight: the non-numeric bound above is dropped rather than rejected. The platform has no 4xx path for filter values, and adding one for these four keys alone would be inconsistent with every other filter on every other endpoint.
[4.121.0] No DB migration, no schema change, no route change, no response-field change, no language-key change. OpenAPI annotations were added (four
OA\Parameterentries onindex()and onitem(), fourx-filtersentries on the tag) — the generated specs regenerate at the release cut, not here. Arest_api_versions.phpentry is recorded under the per-task cadence, framed as an addition.[4.121.0] Out of scope, tracked separately:
#645—Filter::apply()'sBETWEENbranch concatenates both of its client values into a single-argumentwhere(), which CI3 neither escapes nor binds. Untouched here on purpose; the four new branches are the safe shape, so this change does not extend that surface.[4.121.0] Check for overrides: the client-facing surface of this delivery is
tests/Unit/Domains/Support/Repository/RelationConfigurationTest.phpitself. A fork that has overridden or copied this test file will not pick up the new positive-invariant guard and stays silently unprotected against exactly the hazard described above — re-sync the fork's copy of this test file against upstream rather than keeping a divergent one.[4.121.0] Check for overrides — reads no error, gives no warning, and keeps the hole open.
Cms\Blog\Category\Repository\RepositoryConfiguratorgained a new constructor (BlogCategoryVisibilityScope,ArticleVisibilityScope) where it previously took none, andCms\Blog\Article\Repository\RepositoryConfigurator(already constructor-injected since#637) gained two more parameters (BlogCategoryVisibilityScope,BlogTagVisibilityScope); the siblingCms\Page\Repository\RepositoryConfiguratorgained the same shape of new constructor — see the#639fragment. If your fork has already forked any of these three classes, it keeps its own copy. That copy is autowired by FQCN, constructs perfectly fine with the old argument list, and returnsvisibilityScope === null— no exception, no boot warning, a greencheck-container. It simply keeps serving inactive taxonomy and draft article bodies (and, on the sibling class, unpublished pages) to guests. This is the#417fork-fatal shape but worse, because#417at least fatals — here the failure is silent by construction. Injecting the dependency only fixes the path that resolves to the upstream class.- What to do instead of patching the fork's copy. Symfony DI here is last-wins by module load order, so the recommended fix is to drop the forked configurator and rebind the scope service from the fork's own
custom/Cms/container.php, registered after the upstream module — this keeps upstream's configurator, so the fork inherits every relation and scope upstream adds to it later, instead of freezing a copy at the version it was forked from:phpAll four scope classes shipped in this delivery ($services->set(\Custom\Cms\Blog\Category\BlogCategoryVisibilityScope::class); $services->alias( \Advisable\Domains\Cms\Blog\Category\BlogCategoryVisibilityScope::class, \Custom\Cms\Blog\Category\BlogCategoryVisibilityScope::class );PageVisibilityScope,BlogCategoryVisibilityScope,BlogTagVisibilityScope,ArticleVisibilityScope) areprotected table()/column()rather than aprivate const, precisely so a subclass can retarget the table or column without copying the closure body — aprivate constread asself::TABLEinside an inherited method is early-bound, so a subclass redefining the constant would be silently ignored. - If a fork genuinely must keep its own copy of a configurator, it has to add both the constructor dependency and pass
visibilityScope:as a named argument —Relation::__construct's tenth parameter is$visibilityScope, and a positional tenth argument lands silently in$pivotTableinstead. #641is the structural fix for this class of silent loss across the whole platform, not this delivery — and worth knowing its limit going in: itscustom/Domainsscan is an allow-list, so it would not by itself catch a dropped scope on a class a fork still carries.
- What to do instead of patching the fork's copy. Symfony DI here is last-wins by module load order, so the recommended fix is to drop the forked configurator and rebind the scope service from the fork's own
[4.121.0] UI Update: on the four collection embeds here —
Article.categories,Article.tags,BlogCategory.children,BlogCategory.articles— a hidden row simply drops out of the array, so the list is shorter and may now be empty; no key is removed and no error is raised.BlogCategory.parentis the one single embed — a hidden parent now serializes asnull, the same shape a root category (no parent at all) already produced, so a client that already null-checksparentneeds no change. The owning list itself is not filtered by any of these scopes and pagination counts do not move — a published article with a hidden category is still returned as an article, just with a shortercategoriesarray.[4.121.0] Backend callers are unaffected on all five relations — the scope is suppressed centrally by the single
Relation::VISIBILITY_EXEMPT_ALLgrantHandlesRestfulActionsapplies whenever the requestResourceContextis backend, so no per-relation admin wiring was needed and the admin blog tree (where an inactive category gets re-activated, or a draft gets edited) keeps seeing every row.[4.121.0] NULL-flag behaviour — a deliberate decision, not a side effect.
blog_categories.is_activeandblog_tags.is_activeareint(2) DEFAULT NULL, and forcing= 1withholds a NULL-flagged row from storefront callers along with an explicitly-0one. That matches legacy, which testsis_active = 1and never!= 0wherever it consults the flag at all.[4.121.0] Read the two taxonomy tables separately — they are not the same rule wearing two names.
BlogTagVisibilityScopeis straight parity withAdv_blog_tags_model::getTagsFront(), which already selectsis_active = 1on the legacy storefront.BlogCategoryVisibilityScopeis a deliberate tightening: the legacy blog-category storefront never filtersis_activeat all, so inactive and NULL-flagged categories are visible there today and stop being served over REST as of this change. This is why two scope classes ship instead of one parameterised by table — collapsing them would have hidden that one is a restatement of existing behaviour and the other is not.[4.121.0]
ArticleVisibilityScopeis registered public and autowired at theArticledomain root specifically so#625can consume it cross-module (the product-category → article relation#625covers). Do not move it into a narrower namespace or drop its public visibility without checking#625first — that issue is currently blocked on this scope existing where it is.[4.121.0] No DB migration, no OpenAPI schema change, no route change, no response field added or removed. The five relation declarations are the entire behavioural change.
[4.121.0]
application/config/rest_api_versions.phpcarries its own1.Xentry for this change (delivered together with#639as one mechanism over one module) under the per-task cadence. That entry is authored and maintained separately from this fragment.[4.121.0] Check for overrides — reads no error, gives no warning, and keeps the hole open.
Cms\Page\Repository\RepositoryConfiguratorgained a new constructor (PageVisibilityScope $pageVisibilityScope) where it previously took none, and its siblingCms\Blog\Category\Repository\RepositoryConfiguratorgained one too, whileCms\Blog\Article\Repository\RepositoryConfigurator(already constructor-injected since#637) gained two more parameters — see the#640fragment for those two. If your fork has already forked any of these three classes, it keeps its own copy. That copy is autowired by FQCN, constructs perfectly fine with the old argument list, and returnsvisibilityScope === null— no exception, no boot warning, a greencheck-container. It simply keeps serving unpublished pages (and, on the sibling classes, inactive taxonomy and draft article bodies) to guests. This is the#417fork-fatal shape but worse, because#417at least fatals — here the failure is silent by construction. Injecting the dependency only fixes the path that resolves to the upstream class.- What to do instead of patching the fork's copy. Symfony DI here is last-wins by module load order, so the recommended fix is to drop the forked configurator and rebind the scope service from the fork's own
custom/Cms/container.php(or the relevant module's), registered after the upstream module — this keeps upstream's configurator, so the fork inherits every relation and scope upstream adds to it later, instead of freezing a copy at the version it was forked from:php$services->set(\Custom\Cms\Page\PageVisibilityScope::class); $services->alias( \Advisable\Domains\Cms\Page\PageVisibilityScope::class, \Custom\Cms\Page\PageVisibilityScope::class );PageVisibilityScope(and its three siblings shipped alongside it) isprotected table()/column()rather than aprivate const, precisely so a subclass can retarget the table or column without copying the closure body — aprivate constread asself::TABLEinside an inherited method is early-bound, so a subclass redefining the constant would be silently ignored. - If a fork genuinely must keep its own copy of the configurator, it has to add both the constructor dependency and pass
visibilityScope:as a named argument —Relation::__construct's tenth parameter is$visibilityScope, and a positional tenth argument lands silently in$pivotTableinstead. #641is the structural fix for this class of silent loss across the whole platform, not this delivery — and worth knowing its limit going in: itscustom/Domainsscan is an allow-list, so it would not by itself catch a dropped scope on a class a fork still carries.
- What to do instead of patching the fork's copy. Symfony DI here is last-wins by module load order, so the recommended fix is to drop the forked configurator and rebind the scope service from the fork's own
[4.121.0] Check for overrides — this delivery's write-path exemption assumes your
PageREST policy.Cms\Page\WriteService::update()reloadschildrenandparentthrough a server-side relation list and passes[Relation::VISIBILITY_EXEMPT_ALL]unconditionally — it does not consult the caller'sResourceContext. That is correct upstream only becauseapplication/config/rest_policies.phppinsPage::classtoauth => backendwith ADMIN + CMS and overrides onlyindex/show/itemto guest, so a write response is a backend context by construction.rest_policies.phpis a file forks commonly keep a local copy of (see theBadgecase in[4.120.0]), and a diverged copy breaks that premise passively — the fork need not change anything at merge time. If yourPagerow opensupdateto a non-backend caller (any,customer,legacy_guard, or a custom write method inheriting the controller defaults),PUT /rest/cms/page/{id}returns unpublished pages and draft children to that caller behind a 200, with no error and a greencheck-container. The same applies if fork code resolvesAdvisable\Domains\Cms\Page\WriteServicedirectly — it is a public service — and callsupdate()from a guest-reachable action.- What to do. Confirm your
Pagepolicy still keepsupdatebackend-only. If you deliberately open it, do not widen the exemption list: thread the realResourceContextinto the call instead, so the exemption follows the caller rather than being asserted for all of them.
- What to do. Confirm your
[4.121.0] UI Update:
GET /rest/cms/page?with=children(and?with=children*) is a collection embed — a hidden page simply drops out of the array, so the list is shorter and may now be empty; no key is removed and no error is raised.?with=parentis a single embed — a hidden parent now serializes asnull, the same shape a root page (no parent at all) already produced, so a client that already null-checksparentneeds no change. The owning list itself is not filtered by either scope and pagination counts do not move.[4.121.0] Backend callers are unaffected on both relations — the scope is suppressed centrally by the single
Relation::VISIBILITY_EXEMPT_ALLgrantHandlesRestfulActionsapplies whenever the requestResourceContextis backend, so no per-relation admin wiring was needed and the admin CMS page tree (where an unpublished page gets authored and later published) keeps seeing every row.[4.121.0] No DB migration, no OpenAPI schema change, no route change, no response field added or removed. The two relation declarations are the entire behavioural change.
[4.121.0]
application/config/rest_api_versions.phpcarries its own1.Xentry for this change (delivered together with#640as one mechanism over one module) under the per-task cadence. That entry is authored and maintained separately from this fragment.[4.121.0] Check for overrides: all nine
RepositoryConfiguratorconstructor signatures changed from implicit-no-arg to requiring oneProductVisibilityScope:Advisable\Domains\Cms\Blog\Article\Repository\RepositoryConfiguratorAdvisable\Domains\Event\Event\Repository\RepositoryConfiguratorAdvisable\Domains\Cms\Video\Repository\RepositoryConfiguratorAdvisable\Domains\Product\Promo\Repository\RepositoryConfiguratorAdvisable\Domains\Product\Variation\Value\Repository\RepositoryConfiguratorAdvisable\Domains\Product\Variation\Repository\RepositoryConfiguratorAdvisable\Domains\Product\Review\Repository\RepositoryConfiguratorAdvisable\Domains\Product\PriceTracking\Repository\RepositoryConfiguratorAdvisable\Domains\Product\Wishlist\Repository\RepositoryConfigurator
The critical fact, and the single biggest client-merge cost of this delivery: a fork keeping its own copy of any of these nine silently loses the scope rather than failing loudly. The fork's copy is autowired by FQCN and constructs perfectly fine — it just returns a
RelationwithvisibilityScope === null, so that fork keeps serving inactive and soft-deleted products to guests with no error, no warning, and a green container boot. This is the#417fork-fatal shape but worse, because#417at least fatals; here the only symptom is that the security hole stays open. A fork must add both the constructor parameter and thevisibilityScope:named argument to every copied configurator. Grepcustom/Domains/**for copies of those nine class names as a merge checklist item.[4.121.0] Check for overrides: a fork carrying its own copy of
tests/Unit/Domains/Cms/Blog/Comment/CommentVisibilityScopeTest.php— or any test that constructs one of these nine configurators directly — needs the same one-line argument fix. Upstream hit exactly this: that test'stest_article_comments_carries_both_slots()didnew ArticleConfigurator()with no arguments and started throwingArgumentCountErroronce the constructor gained its required parameter.[4.121.0]
application/config/rest_api_versions.phpcarries its own1.Xentry for this change under the per-task cadence, framed as a breaking removal (relation reads only — no route, filter, sort or field change). That entry is authored and maintained separately from this fragment.[4.121.0] No DB migration, no OpenAPI schema change, no route change, no language-key change, no response field added or removed. The nine relation definitions are the entire behavioural change.
[4.121.0] UI Update: REST contract addition for storefront consumers, purely additive.
GET /rest/product/category,/itemand/{id}(plus their locale-prefixed twins) now return one additional boolean key,isSensitive, to guest and authenticated-customer callers. Nothing is removed, no existing value changes, and backend payloads carry the same keys with the same values — onlyisSensitive's serialization position shifts (base block instead of backend-only block), so member order moves while content does not; a JSON object is unordered and no conforming client may depend on member order, so backend callers remain unaffected in every way that matters. A headless storefront can now reproduce legacy's sensitive-category menu presentation (e.g. an age-gate icon or a "sensitive" badge) using the same flag legacy already renders on.[4.121.0] Check for overrides: a client fork carrying its own
Custom\copy ofCategory/Resource.php— rather than extending the upstream class — must apply the same move (isSensitiveout of theisBackend()block) to gain parity; upstream cannot detect or warn about a wholesale fork copy.[4.121.0] Not addressed here, by design — a separate, row-level question. Legacy's child listing additionally excludes sensitive categories server-side (
Adv_product_category_model.php:957,->where($this->table . '.is_sensitive', 0)), while REST applies no such row filter and returns those rows to a storefront caller regardless of this change. That is a row-scoping question, not the field-parity question this fix answers, and closing it here would mean adding a forced filter — a#618-family mechanism — on top of a change that points the opposite direction (making a field public, not withholding rows). Recorded so its absence is not read as an oversight; it is deliberately deferred and needs its own issue, which has not been filed yet.[4.121.0] No DB migration, no route change, no auth/policy change, no language-key change, no field renamed or removed.
application/config/rest_api_versions.phpgains one entry under the per-task cadence, framed as additive (the opposite of the#618family's removal framing). Published OpenAPI specs regenerate at the release cut, not in this branch.[4.121.0] No
npm runbuild needed. The change touches noassets/**, no.vuefile, no SCSS and no laravel-mix config — the two new fields are a CI3 view (boxNowSettings.php) and PHP-only. Do not runnpm run all-productionfor this change.[4.121.0] Two new global helper functions in
ecommercen/helpers/transporters_helper.php:getBoxNowPaperSizeDropDown()andgetBoxNowLabelsPerPageDropDown(), bothfunction_exists-guarded like the rest of that file. They are prefixed per transporter on purpose —EltaHelper::paperSizes()is index-keyed,SkroutzHelper::paperSizes()returns['A4' => 'A4', 'thermal' => 'Thermal']andBoxNowHelper::paperSizes()returns['A4' => 'A4', 'A6' => 'A6'], so a single sharedgetPaperSizeDropDown()would not be safe.[4.121.0] Check for overrides:
Advisable\Transporters\BoxNow\BoxNowConfig::getFieldConfiguration()and::initialize()— a fork overriding either method will not pick upPAPER_SIZE/LABELS_PER_PAGEuntil it merges these additions in; until then the settings screen and the SaaS wizard silently omit both fields for that fork.Adv_transporters_model::saveBOXNOWSettings()— a fork overriding this method dropsPAPER_SIZEandLABELS_PER_PAGEon save. The method grew from 11 rows to 13.Adv_transporters_admin::validateSettingsBOXNOW()— a fork overriding this method needs the two added rules (PAPER_SIZE→trim|in_list[A4,A6],LABELS_PER_PAGE→trim|in_list[1,2,4]) or those fields fail validation.AdvPrintVoucher::printBatch()— a fork overriding the whole method does not get the newBOXNOWcase and keeps forcing single-voucher printing for BoxNow.application/views/admin/transporters/settings/boxNowSettings.php— this view lives underapplication/, so a client fork almost certainly ships its own copy and will not get the two new dropdowns automatically. This is the most likely override to bite; reapply thePAPER_SIZE/LABELS_PER_PAGEform_groupblocks — including theirform_error()calls — by hand after the next upstream merge. The option lists themselves need nosrc/change to retarget, though — because the view calls the twotransporters_helperfunctions, a fork can redefine them fromapplication/helpers/transporters_helper.php, whichMY_Loader::ecomnHelper()loads ahead of the platform copy.$config['transportersSupportingBatchVoucherPrint']inapplication/config/app.php— a fork with its ownapp.phpmust add'BOXNOW'itself, or the mass-print bulk action will not appear in that fork's admin order list.
[4.121.0] Flow doc updated. The carrier table in
docs/flows/admin/AD-34-voucher-generation.mdrecordedBOXNOWas Batch Print: No; it now readsYes (single request), matching the no-chunking decision above.[4.121.0] No DB migration, no REST API contract change (
src/Rest/**untouched —rest_api_versions.phpneeds no entry), and no new environment variable.[4.121.0] Check for overrides:
Adv_orders_adminis a main-repo legacy controller. A client fork that has overridden this controller — or copied/overridden the validation-rule method containing line 690 — does not inherit this fix and keeps rendering the raw key; verify your own fork's override status before assuming it applies.[4.121.0] No client language-file action needed. Unlike #630, this fix adds no new keys — a fork maintaining its own
adv_advisable_lang.phpcopies needs no edit here, provided its copies already carry the pluraleshop.admin.order.select.smart.points.[4.121.0] No schema, contract, or REST surface change — a legacy admin-side label lookup only.
[4.121.0] Check for overrides: nothing in the main repo currently wires up
GoogleRecaptchaTrait—ecommercen/core/Adv_front_controller.phpuses the inert baseCaptchaTrait, whosecaptchaCheck()returnstrueunconditionally — so this defect was only ever reachable in a client that wires the Google trait in itself. A fork that has overridden or copiedsetCaptchaValidationRule()does not inherit this fix and needs to re-apply both theset_message()call and the field label by hand.[4.121.0] Client language files: a fork maintaining its own copies of
adv_theme_lang.phpneeds both new keys (captcha.validation.error,captcha.field.label) added, ort()will emit the raw key text to visitors and log a miss for every failed captcha.[4.121.0] Because
set_message()is keyed by rule name (captchaCheck) rather than field name, the in-flight #586 — which adds a thirdcallback_captchaCheckrule for its v3 field — inherits this message automatically once it mergesdevelop.[4.121.0] Check for overrides:
Slide::index()/item()hand-copyHandlesRestfulActions's base actions rather than callingparent::, and both now call a new protectedenforceStorefrontSlideScope()immediately beforebuildListRequest(). A client fork that overridesSlide::index()/item()wholesale (rather than extending the shipped methods) does not inherit this call and must add it, orfilter[audienceId]stays a live oracle on that fork. The method isprotected, notprivate, precisely so a fork can instead widen or narrow the rule by overridingenforceStorefrontSlideScope()itself and callingparent::— the seamprivatewould have foreclosed.[4.121.0] UI Update:
GET /rest/slider/slideandGET /rest/slider/slide/item(plus locale-prefixed twins) now silently dropfilter[audienceId]for guest and authenticated-customer callers — the request still returns 200 with the filter simply not applied, there is no new error response. A headless storefront that sent this filter to pre-narrow a slide query must stop; there is no storefront replacement, since audience targeting is a server-side visibility decision. Backend callers are unaffected.[4.121.0] Check for overrides: the controller.
Advisable\Rest\Product\Controllers\Categorynow uses theScopesStorefrontRowstrait and callsenforceStorefrontRowScope()in all three read actions (index(),item(), andshow(), which additionally callsdenyHiddenStorefrontRow()). A fork overridingindex(),show()oritem()without callingparent::keeps the vulnerability silently — no error, a green container boot, just an unscoped endpoint. The cheapest override point isstorefrontRowScope(): StorefrontRowScope; an override must only ever narrow what a storefront sees, and the per-row gate must stay fail-closed. Grepcustom/Rest/**for overrides of this controller as a merge checklist item.[4.121.0] Check for overrides: the configurator — this one fails silently.
Advisable\Domains\Product\Category\Repository\RepositoryConfiguratorgained a constructor, now takingCategoryVisibilityScopeandArticleVisibilityScopewhere it previously took none. A fork keeping its own copy is autowired by FQCN, constructs fine, returnsvisibilityScope === null, and keeps serving hidden categories and draft article bodies to guests with a green boot.- Preferred: drop the forked configurator and rebind the scope service instead — Symfony DI here is last-wins by module load order, so from the fork's own
custom/Product/container.php:phpThis keeps upstream's configurator, so the fork inherits every relation upstream adds later.$services->set(\Custom\Product\Category\XVisibilityScope::class); $services->alias( \Advisable\Domains\Product\Category\CategoryVisibilityScope::class, \Custom\Product\Category\XVisibilityScope::class ); - If a fork must keep its own copy: add both dependencies and pass
visibilityScope:as a named argument — named because it isRelation::__construct's tenth parameter, and a positional tenth lands in$pivotTable, which for the twoMANY_TO_MANYrelations (relativeCategories,articles) is live data corruption, not a no-op.
- Preferred: drop the forked configurator and rebind the scope service instead — Symfony DI here is last-wins by module load order, so from the fork's own
[4.121.0] UI Update: a storefront that rendered unpublished categories, or reached draft articles through a product category, loses those rows. The three collection embeds (
children,relativeCategories,articles) return shorter or empty arrays — not an error, no key removed — while the singleparentembed serialises asnull, the same shape a root category already produced. The owning list is not filtered by a relation rule and pagination counts do not move. Re-baseline any fixture, snapshot or contract test that counted on those rows; a caller that legitimately needs them must use a backend token.The endpoint and the relations use two different definitions of a visible category, on purpose. The endpoint applies the category's own flag (
published = 1); the relations apply the stronger ancestor-chain rule. So a category that ispublished = 1beneath an unpublished ancestor remains enumerable fromGET /rest/product/categorywhile correctly vanishing from?with=children. This is deliberate:withMandatoryFilter()sets a scalar and structurally cannot express thewhere_not_inan ancestor-chain rule needs, and the exploit actually being closed here is?filter[published]=0. The population this leaves open is not marginal — categories hidden by the chain rule but not by their own flag measure 1.2% of wecare, 3.7% of smile, 42% of pharm16 — so a fork owner should judge their own exposure rather than assume it small. Closing the gap would need a new endpoint seam and is deliberately out of scope here.[4.121.0] No DB migration, no route change, no new configuration key, no OpenAPI schema change (the affected read actions' annotations were updated; specs regenerate at the release cut, not here).
application/config/rest_api_versions.phpcarries its own1.Xentry for this change, authored and maintained separately from this fragment.[4.121.0] Check for overrides: the five hand-duplicated scope helpers have been collapsed into a shared trait,
Advisable\Rest\Support\Controllers\ScopesStorefrontRows, used by all five controllers (Page,BlogArticle,Document,BlogCategory,BlogTag), plus a small carrier value object,StorefrontRowScope(filterKey,flagProperty,deniedSort). The override surface is now the trait's methods — allprotected, noneprivate— not per-controller duplicates:storefrontRowScope(): StorefrontRowScope(abstract; each controller implements it, supplying its own key/flag/denial and carrying that endpoint's rationale in its docblock — this is the cheapest override point, for changing a filter key, flag property or denied sort),enforceStorefrontRowScope(): void(theisBackend()early return, the forced filter, the sort denial),denyHiddenStorefrontRow(int|string $id): bool(the per-row gate onshow(), returningtrueto stop so the 404 body stays byte-identical to a genuinely missing row's), andisStorefrontVisible(object $entity): bool(still direct property access with a cast, neverisset()). A trait method is flattened into the using class, so a fork adding a second visibility column overridesenforceStorefrontRowScope()andisStorefrontVisible()and callsparent::in both, exactly as it would for an inherited method. There is no constructor-signature hazard here — that shape belongs to#637, not this delivery. The contract is unchanged: an override must only ever narrow what a storefront sees, and the per-row gate must stay fail-closed. Load-bearing warning: a fork that overrides any of the five controllers'index()/show()/item()without callingparent::keeps the vulnerability, silently — no error, a green container boot, just an unscoped endpoint. Grepcustom/Rest/**for overrides of these five controllers as a merge checklist item.[4.121.0] Check for overrides:
src/Domains/Cms/Page/ListRequest.phphad its two commented-out stubs (isPublishedin$defaultFilters,orderin$defaultSorts) deleted. A fork that had uncommented either in its own copy must remove it:$defaultFiltersis context-blind, so it would blind the admin listing; it carrieskey = null, so a client value ANDs into an empty set rather than being overridden; and$filter['value'] ?: nullcoerces a falsy0, so it cannot express "flag = 0" anyway.[4.121.0] Check for overrides: a fork whose storefront renders blog categories will see rows disappear — but not for both tables.
blog_tagsis no change from legacy: its storefront tag listing already selectedis_active = 1(ecommercen/blog/models/Adv_blog_tags_model.php:24-27,getTagsFront()), so an inactive or NULL-flagged tag was already invisible there.blog_categoriesis where rows genuinely disappear: legacy never filteredis_activeon the storefront at all —Adv_blog_category_model.php:214'sgetCategoriesFront()applies only caller-supplied conditions and all five call sites inecommercen/blog/controllers/Adv_blog.php(:135,:306,:366,:579,:1009) passlangalone,getCategoryBySlug()(:106) andgetCategoryData()(:166) filter on slug and lang only, and the column's only two appearances (:202,:236) emit the value rather than filter by it. A fork whose storefront currently renders inactive or NULL-flagged blog categories — in a nav menu, a category list, a sidebar, a filter widget — will find those categories gone from/rest/cms/blog/categoryfor storefront callers. Audit any such surface: an intentionally-rendered row must either be activated (is_active = 1) or the surface must move to a backend-token call. The affected row population is unmeasured — check your own data rather than assume the set is empty.[4.121.0]
application/config/rest_api_versions.phpcarries its own1.Xentry for this change under the per-task cadence, framed as a breaking removal. That entry is authored and maintained separately from this fragment.[4.121.0] No DB migration, no OpenAPI schema change, no route change, no new configuration, no language-key change. OpenAPI annotations on the affected read actions were updated to document the scope (specs regenerate at the release cut, not here).
[4.121.0] Check for overrides: two new shared base-class seams, both additive and opt-in — a fork that never calls them is unaffected, but a fork carrying its own copy of either base class must take them or its own subclasses will fatal on the call.
Advisable\Domains\Support\Request\QueryListBuilder\GenerateListRequest::denySort(string $key): static— new public method. It also adds a private$deniedSortKeysproperty and two new early-return branches inside the existingprotected parseSortValue()andparseRelationSort(); a fork that overrides either of those two methods silently loses the denial and must re-apply the guard.Advisable\Rest\Support\Controllers\HandlesRestfulActions::withDeniedSort(string $key): void— new protected method, andprotected buildListRequest()now forwards denied sorts as well as denied filters. A fork overridingbuildListRequest()must forward them too.
[4.121.0] Check for overrides:
Advisable\Rest\Cms\Controllers\BlogCommentgainsprotected enforceStorefrontCommentScope(), called fromindex()anditem();Advisable\Rest\Cms\Controllers\BlogArticlegainsprotected denyStorefrontCommentEmailSort(), called fromindex(),show()anditem(). A fork that overrides any of those read actions without calling the scope method re-opens the leak for its own storefront. A fork needing a different storefront rule should override the scope method — both areprotectedfor exactly that — rather than the actions.[4.121.0] Check for overrides:
Advisable\Rest\Cms\Controllers\BlogComment::show()is no longer a bareparent::show($id); it performs the per-row approval check first. A fork overridingshow()loses the 404 gate.[4.121.0] Check for overrides:
Advisable\Domains\Cms\Blog\Article\Repository\RepositoryConfiguratorandAdvisable\Domains\Cms\Blog\Comment\Repository\RepositoryConfiguratornow attachvisibilityScope:to thecomments,childrenandparentrelations. A fork that overrides either configurator by DI alias (the supported seam) and rebuilds the relation array from scratch drops the scope and serves unmoderated comments again. Do not merge the two slots oncomments—scopeis the structuralparent_comment_id IS NULLrule that binds everyone,visibilityScopeis the approved-only rule that backend callers are exempt from.[4.121.0] Check for overrides: new class
Advisable\Domains\Cms\Blog\Comment\CommentVisibilityScope, holdingpublic const STATUS_APPROVED = 'approved'and thestatic relationScope(): \Closurefactory. It is the single source of the approved literal — read the constant rather than re-declaring the string.[4.121.0] UI Update: REST contract change, breaking for storefront consumers of
GET /rest/cms/blog/comment(index //item//{id}) and of?with=comments[.children]onGET /rest/cms/blog/article.- Fields removed for guest and customer callers:
emailandstatus— absent from the payload, not nulled, including insidechildrenandparent. - Rows removed: only
approvedcomments are returned, and/{id}now answers 404 for a pending or rejected comment. - Request parameters removed for those callers:
filter[email],sort=email,sort=children.emailandsort=comments.email. All are dropped silently with a 200, exactly as any unrecognised key already is — the platform has no invalid-filter or invalid-sort error response — so a client will see a different result set, not an error.filter[status]is ignored in favour of the forcedapproved. - A headless storefront that rendered the commenter email or the moderation status must stop; there is no replacement, both are backend-only now. One that paged, searched or ordered by email must drop those parameters. Any fixture, snapshot or contract test baselined on unmoderated comments must be re-baselined against approved-only.
- Regenerated types will still declare
emailandstatus, because a backend caller does receive them — a codegen diff will look clean. Treat both as optional at every call site rather than trusting the generated shape. - Admin and back-office surfaces are unchanged.
- Fields removed for guest and customer callers:
[4.121.0] Check for overrides:
Advisable\Rest\Customer\Resources\Customer\Resourcegained two newprotectedmethods,isSelfOrBackend()andisSelf(), and itsresource()/addRelationToData()bodies were restructured — a fork that overridesresource()oraddRelationToData()on this class keeps its own ungated version and stays vulnerable, and must re-apply the gate.Advisable\Rest\Plus\Resources\Audience\Resource::addRelationToData()andAdvisable\Rest\Slider\Resources\Slide\Resource::addRelationToData()changed the same way.Advisable\Rest\Slider\Controllers\Slide::__construct()gained a required sixth argument (SlideVisibilityFilter $slideVisibility) — a fork subclassing or re-registering that controller will fatal until it passes it, andsrc/Rest/Slider/container.phpshows the wiring.application/config/rest_policies.phpis a config file forks commonly copy wholesale: a fork on its own local copy does not get theSlide/Sliderrelationsallow-lists and must re-apply them (including'default' => [], without which the middleware fails open) after its next upstream merge.[4.121.0] UI Update: any headless storefront or client UI that read customer fields out of a nested embed must stop — an audience roster, a slide's
audienceobject, or an order/wishlistcustomerobject belonging to anyone but the caller now carries only{"id": N}, and those fields will not come back.GET /rest/customer/me, the admin customer screens, and?with=customeron the caller's own orders and wishlist are unaffected and need no change./rest/slider/slidenow returns fewer slides to the storefront (expired, not-yet-active and audience-restricted ones are withheld) and 404s for such a slide requested by id, so any UI counting on the full slide list — or on a fixture, snapshot or contract test pinning a nested customer payload, a slideaudience, an audiencecustomersarray, or the unfiltered slide list — must be re-baselined.[4.121.0] No DB migration, no new config key, no language-key changes.
application/config/rest_api_versions.phpcarries the matching entry under the per-task cadence, framed as a breaking removal from the storefront contract.[4.121.0] Not fixed here, deliberately:
RelationFilterMiddleware's second fail-open (it inspects only the first?with=hop, so a nested relation is never matched against any allow-list) is a separate agreed deliverable and is out of scope for this branch — the resource-level gates are what make this issue closed without it.[4.121.0] Check for overrides:
Advisable\Rest\Transporter\Resources\Setting\Resource::resource()changed shape — a fork that overrides this resource, or subclasses it, keeps emittingregValueto guests unless it reapplies theResourceContext::isBackend()gate.application/config/rest_policies.phpis also a config file forks commonly ship a local copy of: such a fork will not pick up therelationsallow-list and its?with=settingsstays wide open to anonymous callers until it reapplies theTransporter::classblock after its next upstream merge. Both halves are needed — they close disjoint sets, so reapplying either one alone leaves a hole. Reapplying only the resource gate still leaves the other six ADMIN-only relations (pricing,optionPricing,postPricing,countyAvailabilities,publicMappings,postAvailabilities) fully guest-readable: this change adds a gate toSettingalone, and no othersrc/Rest/Transporter/Resources/*/Resource.phpcarries one, so those six are closed by the allow-list and nothing else. Reapplying only the allow-list leaves a second-hop?with=transporter.settingsoff any sub-endpoint reaching ungated credentials.[4.121.0] UI Update: breaking removal from the storefront REST contract. Any client reading
transporter.settings,.pricing,.optionPricing,.postAvailabilities,.postPricing,.countyAvailabilitiesor.publicMappingswith a guest or customer token now receives a response with those keys absent — not present-and-empty — because a denied relation is stripped from?with=before the repository loads it, and the request still returns 200.regKey/regValueare likewise absent from any non-backend serialization of a transporter setting. Such a client must move to the endpoint that owns the data (POST /rest/checkout/shippingfor priced courier options,/rest/transporter/{id}/smart-point,/dhl-rates,/asap-services) or authenticate as a backend user. Arest_api_versions.phpentry is recorded under the per-task cadence, described as a removal.[4.121.0] Docs updated:
docs/flows/admin/AD-06-transporter-admin.md'sRelations (via ?with=)line now states the scope-gated reality — backend gets all eight; customer/public gettranslationsonly; the other seven are stripped and absent, each being a backend +AUTH_ROLE_ADMINendpoint in its own right — and its Setting sub-resource section now notesregKey/regValueare serialized only whenResourceContext::isBackend();Last Updatedbumped to 2026-08-18.docs/guides/rest-middleware.mdgained aTransporterSettingrow in the "Fields filtered per resource" table forregKey/regValue, dropped the now-false claim that all Transporter settings are "fully backend-only ... do not need filtering", and corrected "Today onlyCustomer::classandLine::classdeclare one" to includeTransporter::class(#551, #612, #614). Still deferred: the tag-levelx-relationsblock onsrc/Rest/Transporter/Controllers/Transporter.phpstill advertises all eight relations flatly — deliberately, since thex-relationsschema has no scope field and#612leftLine.productsunmarked there for the same reason. The threewithparameter descriptions on the read endpoints were updated in this change.[4.121.0] No DB migration, no new config key, no language-key changes, no OpenAPI schema change beyond two field descriptions on
TransporterSettingResource(specs regenerate at the release cut, not in this branch).[4.121.0] Known residual, out of scope by agreement:
RelationFilterMiddlewarehas a second fail-open — a policy that declaresrelationsbut omits'default'still skips filtering for any scope it does not name. This change declares'default' => []and pins it in a test, but hardening the middleware itself is a separate deliverable.[4.121.0] Check for overrides: four surfaces a client fork can be sitting on top of.
src/Rest/Product/Controllers/Product.php—index(),item()andshow()all changed, and two newprotectedmethods are added:enforceStorefrontProductScope()andisStorefrontVisible(object $entity). A fork that overrides any of the three read actions and does not callenforceStorefrontProductScope()(or reproduceshow()'s per-row gate) re-opens the leak silently — the response looks normal, it just contains rows it should not. The two helpers areprotecteddeliberately, so a fork with an extra visibility column of its own can extend the scope instead of copying the whole action; an override must only ever narrow what a storefront sees (callparent::and add filters), andisStorefrontVisible()must stay fail-closed.src/Domains/Product/Line/Repository/RepositoryConfigurator.php— constructor signature changed: it now takesProductVisibilityScope. A fork with its ownCustom\copy of this configurator, or one that instantiates it directly, must add the argument or it will fatal on container boot; a fork that keeps its old copy silently loses theproductsscope.src/Domains/Product/container.php— newProduct\ProductVisibilityScope::classregistration. A fork shipping its own copy of this container file must add it, orLine\Repository\RepositoryConfiguratorcannot be autowired.application/config/rest_policies.php— a file forks commonly copy wholesale. Only a comment block changed here (theLine::classnote describing the then-open#613leak was corrected), so a stale fork copy is not a security regression — but its comment will now be describing a leak that no longer exists.
[4.121.0] UI Update: REST contract change for storefront consumers, and one part of it is breaking.
GET /rest/product/product/{id}now returns 404 to a guest or customer for an inactive or soft-deleted product, where it previously returned the row — any client deep-linking to a product by id must handle that 404. Collection reads change shape too:GET /rest/product/productand/itemnow return onlyactive = 1, soft_delete = 0rows for storefront callers, and?filter[active]/?filter[softDelete]are ignored for them (silently server-forced, not rejected — the request still returns 200).?with=lines.productsreturns only visible products at any nesting depth. Zero-priced products are still returned — deliberately, they are the gift-option SKUs the storefront renders. Nothing changes for a backend/admin UI: same rows, same filters, same sorts. No response field was added, removed or renamed.[4.121.0] No DB migration, no OpenAPI schema change, no route change, no language-key change. OpenAPI annotations on the three read actions were updated to document the scope (specs regenerate at the release cut, not here). A
rest_api_versions.phpentry is recorded under the per-task cadence, framed as a breaking removal.[4.121.0] Still open, for
#618: the same relation-level exposure survives on other guest-readable endpoints that embedProductthrough their own relations — avisibilityScopedoes not cascade across relation definitions, so each needs its own. Guest-reachable today:Video.products,Event.products,BlogArticle.products,Promo.products,VariationValue.products,Variation.product,Review.product,PriceTracking.product. Backend-only, so lower priority:Download.product,ProductMeta.product,Related.product/Related.relatedProduct,ProductList\ProductLp.product,WaitingList.product,Wishlist.product(customer-scope),ProductCode.product(reachable at depth from customer-scope cart/order reads).[4.121.0] Check for overrides:
application/config/rest_policies.phpis a config file client forks commonly copy. A fork shipping its own local copy will not pick this up — itsLine::classrow stays backend-gated and its storefront keeps getting 401, until it reapplies both themethodsblock and therelationsallow-list after its next upstream merge. Reapplying themethodsblock without therelationsallow-list opens?with=productsto guests and leaks inactive and soft-deleted products.[4.121.0] New public surface on a support class:
WithParser::splitRelations()andWithParser::topLevelName()(src/Domains/Support/Request/WithParser.php) are additive — both wrap logic that already existed as private helpers. No signature changed, no BC break.[4.121.0] Follow-up, deliberately not in this change — tracked as
#613, not a nice-to-have: the storefront can already reach these same inactive/soft-deleted product rows today, unscoped, viaGET /rest/product/product?filter[active]=0and via?with=lines.products— neither route is affected byLine's new allow-list.#613covers givingProduct(andLine.products) aRelation::$visibilityScopemirroringAdv_vendors::baseWhere()(active=1/soft_delete=0/price>0), the#588mechanism — it scopes at every nesting depth and for non-REST consumers too, which a per-endpoint allow-list cannot. Until#613lands, this branch's allow-list is defence-in-depth on theLineendpoint only.[4.121.0] Docs:
docs/flows/admin/AD-17-lines-admin.mdclaimed "All endpoints require JWT authentication" and cited stale route line numbers — both corrected.docs/guides/rest-middleware.mddocumented therelationsallow-list with a'guest'scope key thatresolveScope()can never produce and no'default'key; copying that example would have produced a silently fail-open policy, so the example and the surrounding text now spell out the three real scope keys, why'default' => []is mandatory, and how bracketed params are handled.docs/api-guides/relations.mdanddocs/api-guides/response-scoping.mdeach described scope-based relation filtering as a general platform behavior; both now say it is opt-in per controller and, today, onlyCustomerandLinedeclare it.[4.121.0] No DB migration, no OpenAPI schema change, no language-key changes. A
rest_api_versions.phpentry is recorded under the per-task cadence.[4.121.0] Check for overrides: easy_v4 and acropolis_v4 both carry a local stopgap patch to this same file/line applied ahead of this fix — drop it on the next upstream merge and take the upstream version instead of letting it conflict or double-apply. Any other fork with a local override of this view won't pick up the fix automatically and needs to re-apply the icon conditional by hand.
[4.121.0] UI Update: the orders-list icon now varies per row again (guest vs. registered) where every row previously showed the same icon — restores prior admin-facing behavior rather than introducing new behavior.
[4.121.0] Outbound payload type change: on shops running the default empty
ordersPrefix, two outbound JSON payloads now carry the order reference as a JSON string rather than a JSON number (a shop with a configured prefix already sent strings). This is a correction, not a regression, but is worth knowing about if you have bespoke integration code or reconciliation tooling that type-checks these fields:src/PaymentGateways/Klarna/Klarna.php:100—merchant_reference1. Klarna's API documents this field as a string, so this moves toward spec.- The NBG e-Commerce (Ethniki EE) hosted-checkout browser config — built at
ecommercen/checkout/controllers/Adv_checkout.php:1287-1288, emitted byapplication/views/main/layouts/checkout_complete/ethniki/ethniki_ee_process.php—order.idandorder.reference. This now agrees with the server-side session for the same order, whichsrc/PaymentGateways/NBG/NBGEEHelper.php:44(beginNewSession(string $orderId, …)) already sent as a string.
[4.121.0] Check for overrides:
Adv_order_modelis commonly overridden in client forks (application/modules/eshop/models/Order_model.php extends Adv_order_model). The four changed methods —createSerial(),set_status(),getOrder(),get_records_customer_base()— have no signature change on any of them; this is pure line-addition inside existing method bodies (+26/-0), so it's not a BC break and carries no LSP/fatal risk on merge. A fork that hasn't overridden any of the four gets the fix automatically on merge: no conflict, no action needed. A fork that has overridden any of the four silently misses the fix — PHP method override means the parent's new lines never execute, and because the child's override lives in a separate file there is no merge conflict to surface it. That's the dangerous part: nothing will flag it, so you have to go check. Grep yourapplication/modules/eshop/models/Order_model.phpfor those four method names, and where one is overridden, apply the matching coercion — the correct cast differs per method, so don't apply the same line everywhere:createSerial()— cast$insertIdto(string)unconditionally, as the method's first statement.set_status()— cast$orderSerialto(string)only when it is not null;(string) nullis'', which would turnWHERE order_serial IS NULLintoWHERE order_serial = ''and update a different row set — on an UPDATE.getOrder()— cast only past the existing empty/null guard, and don't trim, so a serial keeps matching exactly what it matches today.get_records_customer_base()— cast only whenisset($conditions['order_serial']), so anullstill rendersIS NULL.- If
createSerial()is the only one you override, that cast alone is sufficient — it's the root fix and covers all 26 serial-keyed call sites.
[4.121.0] Check for overrides: a silent positional-argument shift.
Advisable\Domains\Order\Order\WriteData::__construct()had a promoted constructor property removed (?string $cartContents, previously betweenmetaDataandremind). A fork constructingOrder\WriteDatapositionally has every argument aftermetaDatasilently shift by one position, with no error anywhere. In this repo construction is entirely named-argument (fromArray()uses named args; there is no positionalnew WriteData(for this class), so the exposure is fork-only — and it is the most important item in this note precisely because it is silent: a fork with a positional constructor call will not fail loudly, it will just start writing the wrong value into the wrong field.[4.121.0] Check for overrides: the REST contract. The
Orderwrite OpenAPI schema loses itscartContentsproperty. A client sendingcartContents/cart_contentsin an order-write request body now has that field silently ignored (dropped, not rejected) instead of accepted.[4.121.0] Check for overrides: informational only, no runtime effect.
Order\Repository\Entityloses its@property string|null $cart_contentsannotation. A fork reading$order->cart_contentswas already reading a column that has never existed in the shipped schema — this just stops documenting it as if it did.[4.121.0] Check for overrides:
- New overridable surface:
Adv_customer_model::changePassword(int $customerId, string $newPassword): bool(ecommercen/eshop/models/Adv_customer_model.php). This is additive — no BC break — and a fork that already overridesprotected generateEncryptedValues()now gets a correct REST change-password for free, with no action required. - Collision risk: a fork that already declares its own
changePassword()onCustomer_modelwith a different signature will fatal on the upstream merge (the known #417 override-seam failure mode). Forks should grepapplication/modules/eshop/models/Customer_model.phpforchangePasswordbefore merging this change.
- New overridable surface:
[4.121.0] Support note, overriding tenants only: on a stock tenant the stored hash shape is unchanged by this fix. On a tenant with a custom hashing scheme, any customer who changed their password through this REST endpoint before this release still has an unauthenticatable stored hash — this fix does not repair already-written rows, so they need a password reset.
[4.121.0] No REST contract change — request/response shapes, status codes and OpenAPI attributes are all identical; a
rest_api_versions.phpentry was still added under the per-task cadence, carrying the operational note above for tenants that override the model's password hashing.[4.121.0] Check for overrides: two constructor signatures changed (#596). Both are replacements, not additions, so a fork will fail loudly rather than silently — but neither is source-compatible.
Advisable\Rest\Cart\Controllers\Cart::__construct()— the 5th promoted parameterprotected ProductCodeRepository $productCodeRepositoryis replaced byprotected CartProductQuantityResolver $cartProductQuantityResolver(the parameter count is unchanged; nothing else in the class used the repository). A fork that subclassesCartand overrides the constructor must update its own signature andparent::__construct()call, and a fork that reads$this->productCodeRepositoryfrom an overridden method will now hit an undefined property — inject the repository itself, or use the new resolver. A fork that copiedcartProductQuantities()into its own override keeps working but keeps the per-line query too; it should delete the copy and call$this->cartProductQuantityResolver->resolve($items).Advisable\Domains\Checkout\Gift\GiftMatcher::__construct()— the 2nd parameterProductCodeRepositoryis replaced byCartProductQuantityResolver. A fork that constructsGiftMatcherby hand, aliases it to aCustom\subclass, or overrides its constructor must update accordingly; autowired DI registrations need no change.CartGiftPresenter::present()andCartGiftNearMissPresenter::present()are unchanged — same signature, same[productId => qty]input, same output.
[4.121.0] Deployment: delete
cache/container.php(or let it rebuild) — the compiled container hard-codesGiftMatcher's previous constructor arguments.[4.121.0] Behaviour change — rule-13 gift carts now report a LOWER
cart_total_vat(). This is the headline, not a refactor.563-checkout-vat-inclusive-totals.mddocumented exactly this divergence and deliberately chose the order expression, "because that is the one that has to equal the charge"; this change closes it from the other side, so the storefront/coupon-gate expression now adopts the rule-13-adjusted$paidQtythat excludes the cheapest-free unit, and bundle pricing likewise. Two customer-visible numbers move down on bundle / rule-13 carts, both accepted:- the
ESHOP.MIN_ORDER_AMOUNTgate (application/views/main/layouts/cart/cart.php:70) — a gift or bundle cart that clears the minimum today may stop clearing it; the parsed total is what is actually charged, so this is the correct basis; - the Google Analytics ecommerce value (
application/views/production/google_header.php:108, production-only, gated onGOOGLE.IS_ENABLED) — reported revenue drops to the real figure. Re-baseline any dashboard or acceptance fixture pinning either. There is no free-shipping impact: the only two shipping helpers that ever readcart_total_vat()—transfer_cost()anddelivery_cost()(ecommercen/helpers/eshop_helper.php:43,:78) — are dead. Their sole references repo-wide are two commented-out lines inapplication/views/admin/orders/edit.php:139and:144. Every live shipping path, storefront included, goes throughtransfer_cost_admin()/delivery_cost_admin()instead, and those are already fed the parsed$cart['cart_total_vat']— seeAdv_order_model::frontOrderCostTerms()(:731/:742) andparseCartForCheckout()(:842/:853) for the storefront,adminOrderCostTerms()(:1858/:1872) for admin. Despite the_adminsuffix, those are the storefront's helpers too.
- the
[4.121.0] UI Update: in the admin order editor, a coupon's discount is now computed against the items subtotal rather than the delivery/packaging-inflated grand total, and is no longer deducted twice on recalculation. The discount shown for an existing coupon on an order being edited or cloned will change wherever delivery, transport or gift packaging is priced.
[4.121.0] Check for overrides:
application/modules/eshop/models/Order_model.php— a fork overridingbaseParseCartContents()now drives everycart_total_vat()caller, including two views and the storefront coupon gate, not just the cart resource and checkout. Review the override against that wider blast radius. Such a fork must also grep its own override forcart_total_vat: a fork-only call to the helper from insidebaseParseCartContents()(or anything it reaches) would now be an infinite recursion cycle. Core has no such cycle — verified across the full transitive subtree. Separately, the helper now loadseshop/order_modelon storefront pages that previously never constructed it, so a fork whoseOrder_model::__construct()has side effects will see them earlier and more often.[4.121.0] Check for overrides:
application/modules/eshop/libraries/VatForOrder.php— itsvat()is now on the memo key path. A fork overridinginvoiceVat()/receiptVat()is handled correctly by design (the key probes behaviour, not state), subject to two residual limits, both dormant on core and both fixed by widening the probe. The probe covers only the rates[0.0, 6.0, 13.0, 24.0], so a fork whosevat()diverges only at an unprobed rate gets a stale memo hit; and it never variesvat()'s second parameter$isDigital(AdvVatForOrder.php:55), always passing the defaultfalse, so a fork whose override branches on$isDigitalcould get a stale hit for a digital-vs-physical cart within one request. Core never exercises either case — every core call site (Adv_order_model.php:552,Adv_product_parser_model.php:131,:475) callsvat()single-arg.vat()must also stay side-effect-free — it is now called four extra times per request.[4.121.0] Check for overrides / BC: the global
cart_total()helper was deleted. It had no callers in this repo, but it was a global function, so a client fork could in principle call it. A fork that does should switch tocart_total_vat()— which is whatcart_total()'s own docblock said it was equivalent to — and should expect the corrected, bundle- and rule-13-aware value.[4.121.0] Known remaining wrinkle —
Adv_orders_admin.php:1460(before this change; now:1467).$cart['cart_total'] = $orderData['cartTotal']is left as-is and is still doubly wrong:cart_totalis the NET key per the basis table ratified in563-checkout-vat-inclusive-totals.md, whilecartTotalis a gross grand total. It is harmless in this response path —calculateCart()only copies it intoold_cart_total(Adv_coupons_model.php:1010), which nothing here consumes — so correcting it is a separate, unfiled question rather than a widening of this fix.[4.121.0] Out of scope, deliberately. Two adjacent gaps in the admin path are untouched. The browser-computed
cartItemsTotalitself (footer_js.php:2559, mutated byassets/admin/js/order-gifts-block.js:281) is still derived client-side rather than from the server's parse. AndAdv_order_model::createAdminFakeCart()(:1691-1791), which builds theAdv_orders_admin::$fakeCartfed toisValidCoupon()atAdv_orders_admin.php:2639and:2676, is rule-13-aware (:1725) but still bundle-blind — it callsgetLiveProductsParsed()and neverapplyBundlePricingToCartLiveData(), whose only call site in the model is:543insidebaseParseCartContents(). So the admin-side coupon gate retains a narrower version of the same divergence on bundle carts.[4.121.0] Memo lifetime caveat. A function-local
staticis per-process, which is correct under PHP-FPM / mod_php (one request per process) but would persist across requests under a long-lived worker (RoadRunner / Swoole / FrankenPHP). The CI super-object and the session share that assumption, so this is stated rather than designed around. Note also that memoising meansbaseParseCartContents()' incidental mutations of shared model state —Adv_product_parser_model::$productsis re-initialized and repopulated by eachget()pass, twice per parse — stop happening on repeat calls. All of that state isprotectedwith no external reader, so the risk is low, but it is a real delta rather than a pure no-op.[4.121.0] Companion to
545-myinput-inputstream-memoisation.md, the other per-request memoisation fix in this release. Its lesson is applied here: the memo guard is written as a plainif (array_key_exists($key, $memo)) { return $memo[$key]; }, not the terserisset($x) or $x = ...one-liner whose operator precedence is what #545 had to repair.[4.121.0] Check for overrides — as shipped this is inert on almost every fork. Of the 34 client forks carrying a settings controller, 31 ship their own
application/views/admin/settings/only_advisable.php(so staff never see the checkbox and cannot enable the capability at all) and 30 ship their ownapplication/views/admin/settings/xml_feeds_settings.phpwith 14+ custom-price blocks and no guard (so the controls stay fully visible and submittable regardless of the switch). The feature is fully effective only in the one deployment no customer runs. That is a rollout gap to schedule, not a code defect — but read this as "the forks must be synced before the switch means anything", not as an optional view-parity audit.- Controller overrides — six forks, where a views-only sync is UNSAFE.
Adv_settings::only_advisable()is overridden by Flioukas, with noparent::call at all, so the ON→OFF reset never runs there.Adv_settings::xml_feeds_settings()is overridden by Discount, Gea, Heals, Pharm16 and Smile. On these six, refreshing only the views produces a switch that looks like it works and does not. Smile is the clearest case (Smile/application/modules/settings/controllers/Settings.php): its override duplicates the entire custom-price persist path — 14 flag writes, 14 modifier writes, two fork-only feeds (CUSTOM_PRICE_RETAIL,CUSTOM_PRICE_API_SEARCH) and its ownupdateXmlFeedCustomPriceModifier()— with no reference toCUSTOM_PRICE_ENABLEDanywhere. Sync Smile'sonly_advisable.phpalone and the switch becomes cosmetic: the inheritedonly_advisable()resets the shared flags and staff see "off", but a merchant admin can re-tick a per-feed custom-price box and save, because Smile's own save path does not know the switch exists. Adding aparent::call cannot close that — the upstream guard's whole job is to not write, and the fork's own writes still run. Each of the six needs its controller override read and gated, not just its view refreshed. A switch that appears to work but doesn't is worse than no switch, so whoever performs the view sync must be handed this list with it. - Fork-only feed keys fall outside the reset. The reset covers main's 14
CUSTOM_PRICE_<FEED>labels only. Heals and Pharm16 additionally carryCUSTOM_PRICE_EFOODplus an unsuffixedCUSTOM_PRICE/CUSTOM_PRICE_MODIFIERpair; Smile carries the two named above. On those forks, those feeds keep publishing modified prices after the switch goes off — the exact failure this feature exists to prevent. Any fork sync must extend the reset to that fork's own feed keys.
- Controller overrides — six forks, where a views-only sync is UNSAFE.
[4.121.0] Not immediate for published feeds — a known operational characteristic, not introduced by this change. Turning the switch off can take up to 24 hours to reach a public feed request. Nothing in
ecommercen/feeds/callsregistry->live()(AdvXmlextendsBase_c, notAdv_admin_controller),Registry::setValue()never busts the L2 cache, andcache_l2_default_expiresdefaults to86400— forced to-1only indevelopment— so a feed keeps serving cached registry values until the entry expires. This is systemic: everyIS_ENABLED_*/IS_PROTECTED_*toggle on the same feeds behaves the same way. It is tracked separately and deliberately not addressed here. Staff flipping the switch off for an urgent reason should expect the delay.[4.121.0] New installs only: a freshly seeded shop has XML custom pricing off and needs an Advisable staff member to enable it on
settings/only_advisablebefore any per-feed modifier takes effect. Existing installs are unaffected — the absent-row default is ON.[4.121.0] No DB migration, no
composer install, nonpm run all-production. The lang keys are runtime-loaded PHP arrays and the two views are server-rendered CI3 templates, not Vue — no build step is involved.[4.121.0] Check for overrides:
Settings::googleValidation()/Settings::googleRender()— a client overriding either method must re-apply the newgoogle_recaptcha_v3_*validation rules (includingcallback_recaptchaV3SecretIsStorableand itsset_message) and theGoogleRecaptchaService::normaliseThreshold()call that renders the effective threshold, or the reCAPTCHA v3 fields will not validate or display their default.[4.121.0] Check for overrides: any client trait/controller overriding
getCaptchaServerKey()keeps working — it is still the single source of the v2 siteverify secret, now passed intoGoogleRecaptchaServicebyGoogleRecaptchaTrait::recaptchaService()instead of being read from the Registry by the service. A client that wants to supply its own v3 secret should override the new parallel accessorgetCaptchaV3ServerKey().[4.121.0] Check for overrides: a client overriding
GoogleRecaptchaTrait::recaptchaService()must not re-add->withName('recaptcha')at the call site — the service now names its own log channel and resolves the logger lazily, and an eagerdi()call there re-introduces a DI dependency on the storefront render path.[4.121.0] Check for overrides: a client fork overriding
GoogleRecaptchaTrait::getCaptchaClientMarkup(),getCaptchaJavascript(), orsetCaptchaValidationRule()with their old signatures won't fatal — the new$withInlineStepUp/$actionparameters are optional — but it silently keeps v2-only behaviour and never activates v3, even with v3 keys configured. Adopting v3 requires the override to take the new parameter and branch on it:$withInlineStepUpdrives the inline v2 step-up container on the product page;$actionbinds the per-form v3 action (contact_form,return_form,waiting_list) verified server-side.[4.121.0] Check for overrides:
Products::renderWaitingList()— a client overriding this method must add the$this->setCaptchaAction('waiting_list')call before rendering the captcha markup. Without it the product page mints a v3 token for the defaultsubmitaction while the waiting-list endpoint verifies againstwaiting_list, and every waiting-list submission fails closed.[4.121.0] Check for overrides: a client overriding
CaptchaTraitorCodeigniterCaptchaTraitwholesale must carry the new no-opsetCaptchaAction(string $action): void, whichProducts::renderWaitingList()now calls unconditionally.[4.121.0] Check for overrides: a client fork that supplies its own captcha provider trait (rather than using upstream
CaptchaTrait,CodeigniterCaptchaTraitorGoogleRecaptchaTrait) must implement the full provider contract:setCaptchaValidationRule(),captchaCheck(),setCaptchaAction(string $action): voidandcaptchaStepUpAvailable(): bool.Products::renderWaitingList()andWaiting_list::add()now call the last two unconditionally — the previous defensivemethod_exists()guard oncaptchaStepUpAvailable()has been removed.[4.121.0] Check for overrides: a client fork with its own
application/views/**/footer/footer_js.phpoverride carrying a waiting-list handler keeps the pre-v3 handler, and its waiting list will fail under v3. It must port the v3 token resolver, the inline v2 step-up reveal, and thecaptcha_step_upbranch — or drop the override. The same applies to a fork overriding themaintheme product views that renders the waiting-list block without$captchaMarkup.[4.121.0] Check for overrides:
AdvGiftCardOrdersModel::acceptGiftCard()(ecommercen/gift_cards/models/AdvGiftCardOrdersModel.php) — signature changed fromvoidtobool. A fork that overrides this method, or copies its body, keeps the unguarded coupon-first shape and the money leak; it must adopt claim-before-issue.- New overridable surface:
protected acceptGiftCardFrom($orderId, array $allowedFrom): boolandacceptGiftCardManually($orderId): boolonAdvGiftCardOrdersModel. - A fork overriding
AdvGiftCardAdminListing::acceptGiftCard()must route toacceptGiftCardManually()and handle thefalse→ 409 case, or staff lose manual-accept for cancelled orders. - Any fork subclass or test double typing
acceptGiftCard(): voidmust widen tobool. AdvGiftCardPage::acceptGiftCard()(ecommercen/gift_cards/controllers/AdvGiftCardPage.php:1109) — the thinprotectedseam that all ~16 storefront entry points funnel through, signature changed fromvoidtoboolso it can pass the model's claim result up to its callers. A fork override declared: voidwill fatal on an LSP return-type variance error as soon as the parent declares: bool; a fork override with no return type at all is quieter but worse — it silently returnsnull(falsy), which suppressesacceptPostActions()on every accept, not just refused ones. No such override exists in this repo —application/modules/gift_cards/controllers/Gift_card_page.phpis an empty pass-through.- That widening is what let
successView()and the XPay'accept'branch start callingacceptPostActions()only when the accept actually claimed the row, instead of unconditionally as before. Harmless here, sinceacceptPostActions()is itself an empty pass-through, but a fork that overrides it to send an email or SMS would have re-sent it on a refused (no-op) accept — e.g. a customer refreshing an already-completed bank return URL. No customer-visible change:renderSuccess()still runs on the refused path exactly as before, mirroring the existingpiraeusSuccessAction()'render_success'branch.
[4.121.0] UI Update:
assets/admin/js/giftCards/components/GiftCardOrders.vue— the Accept and Cancel actions previously had no error handling: a non-2xx response silently failed with no toast and a stale grid. Both now show an error toast (server message when present, otherwise the existing generic retry string) and refresh the grid on failure, so the row's true status — including the new 409 on a stale double-click — becomes visible.[4.121.0] UI Update: admin → payment settings screen. The storefront payways panel (
METHODS/PAYWAY) and the gift-card payways panel (METHODS/PAYWAY_GIFT_CARDS) now redisplay the merchant's saved payment-method order instead of the fixed default order. No fields, routes, or saved values changed shape — only the redisplay order.[4.121.0] Check for overrides:
application/views/admin/settings/payment_settings.phpis a view template, not a class a fork subclasses — a fork carrying its own wholesale copy of this file will not pick up this fix by merging upstream and must apply the same two-line change locally. In both the storefront panel (~line 70) and the gift-card panel (~line 213), change:php$selected_payways = $selected_payways_match + $payway_methods_keys;to:
php$selected_payways = array_merge($payway_methods_keys, $selected_payways_match);This issue was filed from a client fork (Smile Pharmacy) that had already independently patched its own copy of this exact view — direct evidence other forks may carry local overrides of
payment_settings.phpthat won't inherit this fix.[4.121.0] No behaviour change. This is pure statement ordering, not a functional change. Nothing downstream in
indexExtras()readshitsfor the current product:fixSelect()(Adv_products.php:700-743) does not includehitsin its column list, so$this->render['productData']->hitsnever exists and no view can render it.updateProductHits()itself is a pure pass-through to$this->product_model->updateHits(...)— it sets no$this->render[...]key, writes no session state, and returns nothing, so there is no side effect for other statements in the method to depend on. One accepted edge case: a product listed in its ownshop_related_productsrow (a data error, not a supported configuration) can shift its own rank by one position ingetRelatedProductsLp()'sORDER BY shop_product.hits DESC, since the hit increment used to land before that read and now lands after it.[4.121.0] Check for overrides. No
protectedmethod signature changed onupdateProductHits()orindexExtras(), so a client fork inherits this automatically — nothing to update on merge for a fork that hasn't touched either method. However, a client fork that overridesindexExtras()itself keeps its own statement ordering unchanged and does not receive this reorder — because the override lives in a separate file, there is no merge conflict to surface that, so if a fork has such an override it needs to be checked and reordered by hand to get the same benefit.[4.121.0] This is not a performance fix — do not read it as one. Issue #471's own verification measured
causal_reads: none(post-write SELECTs are not pinned to the DB primary today) and replicas serving 98.2% of SELECTs in steady state, concluding "there is no routing bug." This change has no measurable effect on the current setup. Its value is purely as a prerequisite: it is a "writes-last" discipline that keeps a futurecausal_readsrollout from pinning the whole read-heavy render pipeline to the primary just because a write happened early in the method.[4.121.0] No schema change, no REST contract change, no config key, no UI change.
[4.121.0] Check for overrides: a fork with a
Custom\override of thisValidator(or a subclass of it) does not inherit the new enum check and keeps accepting arbitrary channels. The newprivate messageTypeChannelError()is a private class member, so no LSP/inheritance conflict — but a fork that copied the whole class body will diverge silently and needs to re-apply the check by hand.[4.121.0] UI Update: a backend client sending a lowercase channel, or a message category in
messageType, now gets a 422 where it previously got a 200. Such a request was writing a malformed row — the fix is to send the category intypeand the channel inmessageType, not to work around the 422.[4.121.0] No schema/shape change and no OpenAPI diff — the
messageTypeproperty already advertisedEMAIL/SMS; arest_api_versions.phpentry was still added under the per-task cadence.[4.121.0] Check for overrides (informational only — no signature changed): no production code changed in this delivery, so no
protectedmethod signature moved. A client fork that overridesMY_Sessiondoes not inherit these tests, and if its override changesisRouteExcluded()/normalizeRouteElements()semantics the new assertions encode main-repo behaviour only.[4.121.0] No schema, REST contract, config-key, or UI change.
[4.121.0] Test-only change: closes a test gap and adds no runtime behaviour. There is nothing for a client to action on upgrade.
[4.121.0] Forks that dispatch
ask_us_email— this merge DELETES your template SILENTLY. A fork that rendersmain/mail/ask_us_email.php(itsemailViews.jsontemplateFolderismain/mail, with a fork-added layout key + mailer method, rather than owning a copy under its own template folder) loses the file on merge with NO conflict and NO prompt — git only conflicts on a path the fork modified, and this one is unmodified there. The failure surfaces at runtime: the next dispatch fatals with "Unable to load the requested file". At least one client is known to be in this position, so this note is not hypothetical.- To keep it, make it fork-owned — during merge resolution, not after. Restore from the pre-merge tip and commit it in the fork:
git checkout <pre-merge-sha> -- application/views/main/mail/ask_us_email.php, thengit add+ commit. Upstream no longer carries that path, so it will never conflict again and no future merge can remove it. Preferably move it under the fork's own template folder (application/views/<client>/mail/) and point the fork'semailViews.jsonlayout key at it — that is where a fork-only template belongs. - The admin preview entry is gone for every fork. The list is a hard-coded array inline in
AdvEmailViewer::index()with no override seam (unlikesampleEmailData(), which has three), so restoring the preview means overridingindex()and copying all 20 remaining entries. A fork that only ever saw this template in the preview page and never dispatched it needs no action — that is the intended outcome. sampleEmailData()keys retained on purpose.full_name,emailandmessageare still set, so a fork's restored copy renders in the preview without further edits.
- To keep it, make it fork-owned — during merge resolution, not after. Restore from the pre-merge tip and commit it in the fork:
[4.121.0] No DB migration, no
composer install, no container rebuild, nonpm run all-production— two deleted server-rendered CI3 templates and one removed array element.[4.121.0] REQUIRES
npm run all-production: two admin.vuesources changed, so the admin bundles must be rebuilt for the fixes to reach the browser.assets/admin/js/giftCards/components/GiftCardOrders.vue— Accept/Cancel error toasts and grid refresh on failure.- the admin order-transporters block —
public/ui/admin/dist/order-transporters-block.js.
[4.121.0] Behaviour note: order edit/repeat now issue one additional
POST /<lang>/api/transporters/getAvailableTransportersduring page load. It is the same call the page already made on any subsequent interaction, and it is skipped entirely when no transporter is preselected.[4.121.0] Availability is still enforced. The added
calculateCosts()runs the normalcompareTransporters()pass, so a preselected carrier that is no longer available for the order's address is still cleared and must be re-picked — the fix publishes the selection, it does not force it past the availability rules.[4.121.0] Merge conflict resolution (
assets/admin/js/order/AdminOrderTransporters.vue): the upstream change is only the guardedcalculateCosts()block inserted at the end ofmounted(), immediately before the closingawait this.getExternalTransporterCost(). Keep all of a fork's ownmounted()additions and take this block alongside them. Two ordering constraints matter and a resolution that breaks either reintroduces the bug:- it must come after
this.selectedTransporter = this.clonedTransporter, otherwise there is nothing to recalculate for; - the
if (this.selectedTransporter)guard must be kept, otherwise order create pays for a redundant request on every load. - A fork that moved the listener registrations above the first
awaitshould re-check this hunk by hand: with the listeners bound early the page-readynotifyTransportersis no longer lost, but it can then fire beforeinitTransporters()resolves, andcompareTransporters()against an emptyavailableTransporterswill clear the preselection instead.
- it must come after
[4.121.0] UI Update:
application/views/admin/blog/blogs/update.php— the Builder<select>in the "Article details" block is renamed frombuilder_block_id_<lang>}tobuilder_block_id_<lang>. Any fork-side JS or test that targets the old malformed name must be updated.[4.121.0] Data note: the fix stops further loss but does not restore already-detached blocks. Articles edited since
6fb7ef5837haveblog_mui.builder_block_id = NULLand need the Builder re-selected by hand. To find them:SELECT blog_id, lang FROM blog_mui WHERE builder_block_id IS NULL;[4.121.0] Merge conflict resolution (
application/views/admin/blog/blogs/update.php): this view is one of the most commonly customized admin templates, so forks routinely carry local edits to it and this hunk may land in a conflict. Resolve as follows:- The upstream change is only the two
builder_block_id_...strings inside the<?php if ($builderEnabled) : ?>block. Keep all of the fork's surrounding markup, extra fields, layout classes and loop-variable naming; take upstream's braced form for those two strings alone. - If the fork renamed the loop variable (some forks still use
$l_abbr), keep the fork's variable name and just add the braces:"builder_block_id_{$l_abbr}". The variable's name is irrelevant — the missing{is the whole defect. - Post-merge verification, which must return no matches:bash
grep -rn 'builder_block_id_\$[A-Za-z_][A-Za-z0-9_]*}' application/ custom/ - A fork that keeps its own copy of this screen under a client-owned template path (a
template_view_adminother thanadmin) will not receive the fix through the merge — apply the same one-character correction to that copy too, and let the grep above cover it.
- The upstream change is only the two