111 —AI
Magento 1 chatbot: de read-only vorm die blijft staan
Een klant wil een chatbot op een Magento 1-shop uit 2017 schroeven. De kunst: read-only houden, weg van de checkout en uit de order-tabellen.
Waar de vraag meestal binnenkomt
Het is een dinsdagmiddag-pingetje van een agency-eigenaar in Utrecht. Een klant van hem draait een Magento 1.9.4.5-shop die zo'n €40k per maand omzet in vloertegels en badkamerarmaturen. De briefing is twee zinnen lang: "Klant wil een AI-chatbot op de productpagina's. Kun je een prijs scopen?" Erbij zit een Loom, opgenomen om 23:41 de avond ervoor, waarin de agency-eigenaar met een trillende camera door de storefront loopt en wijst naar de "Hulp nodig?"-widgets die hij bij concurrenten heeft zien staan.
De site draait op PHP 7.4. Op Mage_Core staan acht community-patches gestapeld bovenop de officiële SUPEE-11346. De checkout is een custom one-page-variant uit 2018 die niemand bij de agency nog heeft aangeraakt sinds de oorspronkelijke developer vertrok. De MySQL database is 18 GB, vooral EAV-bloat. De /checkout/onepage controller draait nog op de originele prototype.js-stack.
Dit is het punt waarop het gesprek moet ophouden te gaan over "een AI-chatbot" en moet gaan over welke vorm die chatbot mag aannemen. Op een Magento 1-shop van deze leeftijd breekt de verkeerde vorm de checkout binnen een week.
Waarom Magento 1 write-paths afstraft
Magento 1 ging in juni 2020 end-of-life. De officiële support stopte, security patches stopten, en het hele EAV-model werd een museumstuk. In de praktijk opereert elke third-party code die naar de database schrijft nu zonder vangrails. Er komt geen community-patch meer voor het hoekgeval waarin jouw chatbot-widget een cart-update inhaalt en een verouderde quote_id_mask-rij achterlaat.
De checkout-flow op een typische Magento 1.9-shop raakt minstens 14 tabellen aan bij een enkele order. sales_flat_quote, sales_flat_quote_item, sales_flat_quote_address, sales_flat_order, sales_flat_order_item, sales_flat_order_payment, plus hun _grid-tegenhangers. Het quote-subsysteem staat erom bekend dat het slecht reageert op writes vanuit code buiten de standaard checkout-controllers. We hebben agencies meegemaakt die shops erfden waarin een half-afgemaakte "abandoned cart"-extensie nog steeds schreef naar sales_flat_quote-rijen, die de standaard observer dan op het verkeerde moment opnieuw verwerkte, twee minuten nadat de klant al had afgerekend.
Elke chatbot die ook maar iets naar die database schrijft, neemt verantwoordelijkheid op zich voor een schema uit 2017 zonder upstream patches. Dat is de verkeerde ruil.
De read-only vorm
De vorm die we elke keer aanraden is deze. De bot leest. Hij schrijft niet. Hij houdt geen session bij. Hij personaliseert niet. Hij beantwoordt productvragen vanuit de catalogus, verzendvragen vanuit een platte YAML en beleidsvragen vanuit de CMS-blocks. Hij kan niet zien wie de klant is, kan niet zien wat er in het mandje zit en kan niets in de order-pipeline triggeren.
Concreet betekent dat:
- Het bot endpoint leest uit een kleine set catalogus-tabellen via een aparte MySQL-user met alleen SELECT-rechten.
- De widget laadt alleen op categorie- en productpagina's, nooit op /checkout, /customer, /onestepcheckout of een admin-route.
- De bot heeft zijn eigen path prefix (meestal /bot/) die volledig buiten Magento's index.php-routing gehouden wordt.
- De bot zet geen session-cookie, zodat hij de Magento-frontend session niet per ongeluk kan invalideren.
Een werkend .htaccess-fragment in de document root:
# Houd het bot-widget script volledig uit de checkout
<LocationMatch "^/(checkout|onestepcheckout|customer/account|admin)">
Header always set Content-Security-Policy "script-src 'self' 'unsafe-inline'; connect-src 'self'"
</LocationMatch>
# Route /bot/ naar een losstaand PHP-bestand, niet Magento's index.php
RewriteRule ^bot/api/(.*)$ /bot/api.php?path=$1 [L,QSA]
Het CSP-block is de dragende regel. Komt het loader-script van de bot in een CMS-block terecht dat doorlekt naar de checkout, dan weigert de browser het op te halen. De checkout-JS blijft op zijn oude prototype.js-stack draaien, onaangetast. De CSP-referentie van MDN is de bron waar je naar terug kunt vallen voor de connect-src en script-src semantiek als je de regel in een code review moet verdedigen.
De MySQL grant
De bot draait als zijn eigen user, niet als de Magento-user. De grants zijn op tabel-niveau scoped:
CREATE USER 'pier_bot'@'localhost' IDENTIFIED BY '...';
GRANT SELECT ON shop.catalog_product_entity TO 'pier_bot'@'localhost';
GRANT SELECT ON shop.catalog_product_entity_varchar TO 'pier_bot'@'localhost';
GRANT SELECT ON shop.catalog_product_entity_decimal TO 'pier_bot'@'localhost';
GRANT SELECT ON shop.catalog_product_entity_text TO 'pier_bot'@'localhost';
GRANT SELECT ON shop.eav_attribute TO 'pier_bot'@'localhost';
GRANT SELECT ON shop.cataloginventory_stock_item TO 'pier_bot'@'localhost';
FLUSH PRIVILEGES;
Geen toegang tot sales_*, geen toegang tot customer_*, geen toegang tot core_session. Wordt de bot op enig niveau gecompromitteerd (prompt injection, library CVE, slechte deploy), dan is de blast radius de publieke catalogus. Die is sowieso al publiek.
De view-laag die EAV uit de prompt houdt
De catalogus-tabellen zijn niet leesbaar als chat-context. Het is EAV. Een productnaam staat in catalog_product_entity_varchar met attribute_id 71 op de meeste installaties, gejoind terug aan catalog_product_entity via entity_id. Ruwe EAV in een language model gooien is een slecht idee, zowel vanwege de token-kosten als omdat het model joins gaat hallucineren.
De vorm die werkt is een gedenormaliseerde view, die de bot bij het opstarten ververst:
CREATE OR REPLACE VIEW bot_product_card AS
SELECT
e.entity_id,
e.sku,
name.value AS name,
descr.value AS short_description,
price.value AS price,
stock.qty AS qty,
stock.is_in_stock
FROM catalog_product_entity e
LEFT JOIN catalog_product_entity_varchar name
ON name.entity_id = e.entity_id AND name.attribute_id = 71 AND name.store_id = 0
LEFT JOIN catalog_product_entity_text descr
ON descr.entity_id = e.entity_id AND descr.attribute_id = 73 AND descr.store_id = 0
LEFT JOIN catalog_product_entity_decimal price
ON price.entity_id = e.entity_id AND price.attribute_id = 75 AND price.store_id = 0
LEFT JOIN cataloginventory_stock_item stock
ON stock.product_id = e.entity_id;
Attribute IDs verschillen per installatie. Bevestig die van jou met SELECT attribute_id, attribute_code FROM eav_attribute WHERE entity_type_id = 4 voor je de view live zet. De bot bevraagt bot_product_card, niet de EAV-tabellen rechtstreeks. De view is het contract. Wil je later veranderen wat de bot ziet, dan pas je de view aan, niet het code-pad.
Wat we uiteindelijk bij Pier geleverd hebben
Toen we Pier bouwden, kwamen we deze vorm zo vaak tegen dat we read-only mode er meteen in hebben ingebakken: wijs hem op de FTP- en MySQL-credentials van een verouderde site, geef hem een SELECT-only user mee, en de MySQL editor weigert te schrijven. De versiegeschiedenis houdt nog steeds elk bestand bij dat de operator naast de bot aanraakt, dus een zaterdagavond-tweak rolt met één toetsaanslag terug.
Het kleinste wat je vandaag kunt doen, voor je één regel bot-code schrijft: draai SHOW GRANTS FOR 'magento'@'localhost' op de shop waar je "even een chatbot" op moet zetten. Heeft de user die de connectie houdt GRANT ALL, dan weet je al welk gesprek je eerst met de klant moet voeren.
— Vragen —
Kan de chatbot dan op zijn minst lezen wat er in het mandje van de klant zit?
Niet in de read-only vorm. Cart-context betekent lezen uit sales_flat_quote, en dat betekent dat je write-path-verantwoordelijkheid erft op een schema dat in 2020 EOL ging. Dat is precies de ruil die je probeert te vermijden.
Waarom niet gewoon een hosted widget zoals Intercom of Tidio gebruiken?
Die zijn prima voor algemene vragen. Ook die moeten via CSP uit /checkout gehouden worden, anders krijg je dezelfde prototype.js-race condities zodra het widget-script op de one-page checkout laadt.
Heb ik echt een aparte MySQL-user nodig voor de bot?
Ja. Hergebruik je de Magento-credentials, dan erft de bot GRANT ALL op de order-tabellen, en daarmee verdwijnt elke vangrail die de read-only vorm je nu juist moet geven.