Storefront (Astro)
The storefront runtime, its SDK and the Bloom and Noir themes live in storefront/, a separate npm project like docs/. The design is in Storefront runtime and themes.
Local development
Section titled “Local development”The Laravel app must be running (Herd serves *.flowery.test).
cd storefrontnpm cicp .env.example .env # flowery.test, plain http, API on 127.0.0.1set -a; . ./.env; set +anpm run dev # http://pilot-shop.flowery.test:4321/- API calls. The development server forwards
/storefront-apito Herd on the same shop host, so islands and the cart cookie behave as in production. - Port. Astro may start the server in the background when there is no terminal; use
npx astro dev status,npx astro dev logsandnpx astro dev stopto manage it. - Previewing a draft. Settings → Storefront → Preview draft opens the Laravel host. Until phase D, change the port in that link to
:4321.
To run the production build locally (with CSP, as served in production):
npm run buildSTOREFRONT_PROXY_API=true PORT=4322 HOST=127.0.0.1 node dist/server/entry.mjsSTOREFRONT_PROXY_API=true makes the runtime pass /storefront-api requests to the API itself. Production never sets it: nginx routes those paths to Laravel.
Demo shops
Section titled “Demo shops”php artisan db:seed makes each demo shop a realistic Ukrainian florist on the storefront, through StorefrontShowcaseSeeder:
- Ukrainian names and real UAH prices.
- Nine flowers and ten bouquets, with occasions and free-licence photos (
resources/demo/CREDITS.md). - Fresh stock for the new flowers, with the hydrangea scarce so “few left” shows.
- A filled profile, and a published Bloom storefront with a hero photo.
The demo inventory states and orders are unchanged.
To upgrade an existing local database, run php artisan db:seed --class=StorefrontShowcaseSeeder. It is idempotent, and it does not overwrite a storefront the shop has already published.
Environment
Section titled “Environment”| Variable | Meaning |
|---|---|
STOREFRONT_DOMAIN |
Shops live on {slug}.{domain}; other hosts get 404. Default flowery.com.ua |
STOREFRONT_API_URL |
API base with a {host} placeholder. Default https://{host}/storefront-api/v1 |
STOREFRONT_API_ADDRESS |
Connect to this address instead of resolving the shop host (production: 127.0.0.1, so calls stay on the server; TLS still checks the shop’s name) |
STOREFRONT_RUNTIME_KEY |
Same value as Laravel’s STOREFRONT_RUNTIME_KEY; lets API rate limits see each visitor |
STOREFRONT_SECURE_COOKIES |
false only for plain-http local development |
STOREFRONT_PROXY_API |
true only for local runs of the production build |
PORT, HOST |
Where the Node server listens (production build) |
Checks
Section titled “Checks”| Command | What it proves |
|---|---|
npm run sdk:check |
packages/sdk/src/api.d.ts matches docs/storefront-api/openapi.yaml (npm run sdk:generate rewrites it) |
npm run themes:check |
Themes stay inside their boundary |
npm run format:check, npm run lint |
Prettier and ESLint |
npm run check |
astro check: types in .astro, .ts and .tsx |
npm test |
Vitest: exact money, host and route parsing, request helpers, cache, theme boundary |
npm run build |
The Node server build |
The CI job storefront runs all of these plus npm audit. Deploys wait for it, and scripts/check-all runs the same steps.
Production cutover
Section titled “Production cutover”The storefront runs beside Laravel on the Forge server. The files to apply are in deploy/storefront/. Each step is done in Forge or over SSH by the owner; the read-only checks can be run by anyone.
How it fits together
- Routing. nginx sends only storefront pages on shop hosts (subdomains, and shops’ own domains since ADR-022) to the Node server on
127.0.0.1:4330:/,/catalogue,/bouquets/N,/flowers/N,/builder,/cart,/checkout,/orders/{token},/create-bouquet,/__storefront/…and/_astro/…. - Laravel keeps everything else: the root domain,
www,/control, sign-in,/storefront-api,/build,/storage,/up,/releaseand/docs. - The switch. One
mapvalue decides (every host is a storefront except the root andwww), so turning it off is a one-line rollback. - Restarts. A Forge daemon (supervisor) keeps the server running. On every deploy, the deploy script builds
storefront/in the new release and stops the old process; supervisor starts it again from the newcurrentrelease. That needs no sudo, and theforgeuser may otherwise only restart all supervisor programs, which would bounce other sites’ daemons. - Release check. Once the repository variable
STOREFRONT_RELEASE_URLis set, CI waits for/__storefront/releaseto report the deployed commit, as it does for Laravel’s/release.
The routing was tested on 4 October 2026 with the same snippets behind a local nginx:
- Every storefront path on a shop host reaches Astro.
- Every Laravel path, and every root-domain or
wwwpath, reaches Laravel. - With the storefront stopped, shop pages fall back to Laravel.
Steps
- Shared secret. Generate a key with
openssl rand -hex 32. AddSTOREFRONT_RUNTIME_KEY=<key>to the site’s environment in Forge. - Storefront environment. Over SSH, copy
deploy/storefront/storefront.env.exampleto/home/forge/flowery.com.ua/storefront.env. Put the same key in it, thenchmod 600it. - Deploy script. Add the two blocks from
deploy/storefront/deploy-script.shto the site’s deploy script: the build with the other build steps, and the restart after the release is activated. - Deploy. Deploy (merge, or Forge’s Deploy button). Check that
~/flowery.com.ua/current/storefront/dist/server/entry.mjsexists. - Background process. In Forge → Site → Background processes, add the process from
deploy/storefront/README.md(older Forge: Server → Daemons). It needs a build incurrent, so deploy first. Check it:The first prints the deployed commit; the second prints 200.Terminal window curl -s -H 'Host: batata.flowery.com.ua' http://127.0.0.1:4330/__storefront/releasecurl -s -o /dev/null -w '%{http_code}\n' -H 'Host: batata.flowery.com.ua' http://127.0.0.1:4330/ - nginx. In Forge → Site → Edit Nginx Configuration:
- Paste
nginx-map.confabove the firstserver {. - Paste
nginx-locations.confinside theserverblock, aboveinclude forge-conf/…/site.conf;. - Save; Forge tests the configuration before reloading.
- Paste
- Verify in a browser:
- A shop’s home, catalogue, bouquet, builder and cart pages.
- “Preview draft” from Settings → Storefront, which now opens the new storefront.
- The control panel and sign-in on the shop host.
- The root domain, which should be unchanged.
- The browser’s console.
- Release check. Run
gh variable set STOREFRONT_RELEASE_URL --body https://release.flowery.com.ua/__storefront/release. - Retire the old storefront. Done 5 October 2026. The Laravel storefront’s pages, cart routes, controllers and components are gone. Laravel now answers storefront paths only through the nginx fallback (the storefront process restarting or down) with a short bilingual 503 page, “back in a moment”, carrying the shop’s published phone and
Retry-After: 30. Keep theerror_page 502 504line: it is what shows that page instead of a 502.- The
storefrontroute name still builds links (onboarding, previews, dev login). - Locally,
pilot-shop.flowery.testwithout a port shows that page; the storefront itself runs on:4321.
- The
Applied 5 October 2026.
- Server files: the shared key went into Laravel’s
.env(a backup was kept beside it) andstorefront.envover SSH, generated on the server and never displayed. - Forge: the owner made the Forge changes.
- Checks, read-only:
- The process answered on 4330 with the live commit.
- After nginx, the shop pages came from the storefront with its CSP, while
/control,/login,/storefront-api, the root domain,/releaseandwwwstayed with Laravel. - An unknown shop got the storefront’s “shop not found” page.
- No console errors.
Restarting on deploy. The deploy script’s line must be anchored:
pkill -f -- '^node --env-file=/home/forge/flowery.com.ua/storefront.env' || trueForge runs the deploy as bash -c with the whole script on its command line. An unanchored pkill -f pattern therefore also matched the deploy’s own shell: the first deploy after the cutover (5 October 2026) was cut off after the restart, and the old-release purge never ran. The anchored pattern matches only a process that starts with node.
Adding storefront paths. nginx sends only the listed paths to the storefront. When a release adds a page, replace the location ~ … line in Forge with the one from deploy/storefront/nginx-locations.conf before switching a feature on, then check the path with curl -I. Laravel’s fallback route in routes/web.php lists the same paths, so the “back in a moment” page covers them too.
- Stage 3A checkout (October 2026) added
/checkoutand/orders/{token}. Until the line is updated, those paths reach Laravel and get the “back in a moment” page, so a shop’s checkout must stay off. - Replace only that line, and check that nginx reloaded. On 5 October 2026 the site’s file in Forge’s editor (
/etc/nginx/sites-available/flowery.com.ua) was replaced by its inner rules only, without themap, theserver { listen 443 … }block andinclude forge-conf/3410669/site.conf;(which carries the PHP location). nginx’s configuration test failed on every save and restart, so nginx quietly kept the previous configuration and the new paths never went live. A reboot or hard restart would have failed to start nginx for every site on the shared server.- The file must keep this shape: the two
include forge-conf/…/before/*lines, themap,server { … the storefront location, the @flowery_laravel location, include forge-conf/3410669/site.conf; }, and the twoafter/*includes. - After saving, confirm the reload (read-only):
ssh chat-ui "ps -eo lstart,cmd | grep 'nginx: worker' | grep -v grep | head -1"must show the time of the save, andcurl -sI https://<shop>.flowery.com.ua/checkoutmust carry the storefront’scontent-security-policyheader.
- The file must keep this shape: the two
Rollback. In the Nginx configuration, change the map’s default 1 to 0 and save. After step 9, Laravel only shows the “back in a moment” page on storefront paths, so a rollback means a short outage of the shop pages, not the old storefront.
Shops’ own domains
Section titled “Shops’ own domains”A shop’s own domain (ADR-022, custom domains) reaches this server through Cloudflare with the shop’s hostname unchanged in both the Host header and the TLS name. Three server-side changes make the site answer such hosts; the files are in deploy/storefront/:
server_name. In theserver { listen 443 ssl; … }block,server_name .flowery.com.ua ~^[a-z0-9.-]+$;so a hostname no other site on the shared server claims lands in this block (nginx-custom-domains.confexplains the order nginx tries names in). The certificate is still the site’s*.flowery.com.uaone; Cloudflare accepts it for these hosts because the zone’s encryption mode is Full, not Full (strict).- The map.
nginx-map.confnow defaults to1and lists the root andwwwas0: a shop’s own domain is a storefront. Laravel’s custom-host routes answer the Storefront API and the fallback page on it; everything else there is a 404 by routing, not by nginx. - Real addresses. The
set_real_ip_fromlines innginx-custom-domains.confandreal_ip_header CF-Connecting-IP, inside theserverblock (at the top of the file they would apply to every site on the shared server), give nginx, Laravel’s rate limits and the storefront’sX-Forwarded-Forthe visitor’s address instead of Cloudflare’s edge. All traffic already came through Cloudflare, so until this was applied every visitor shared one Storefront API bucket. - The runtime’s TLS name.
STOREFRONT_API_SNI=flowery.com.uainstorefront.env: the runtime presents and verifies that name on its loopback API calls while sending the shop’sHost, so a shop on its own domain reaches the API too (runtime). Restart the storefront process after changing the file.
After saving, confirm the reload as above, then check read-only on the server: curl -sk --resolve www.example-shop.ua:443:127.0.0.1 https://www.example-shop.ua/ must return the storefront’s “shop not found” page, not another site’s.
Applied 8 October 2026 (release 8c1f118, the same evening the first part of ADR-022 was deployed). The owner pasted the full file in Forge; read-only checks afterwards: the workers restarted at the time of the save, a foreign host at the origin got the storefront’s 404 with its CSP and /login on it a plain 404, the shop subdomain still came from Astro, the root from Laravel. One wrong turn worth knowing: Forge’s editor also shows forge-conf/…/site.conf (the PHP location and headers); that is the file included by the site file, not the one to edit.
Adding a theme
Section titled “Adding a theme”- Create
storefront/themes/<id>/with atheme.jsonmanifest. Laravel validates the manifest at boot (configuration). - Write
Theme.astroand the seven templates, importing only@flowery/storefront-sdk, React and the theme’s own files. - Add the theme to
src/runtime/themes.tsand give it an entry page atsrc/pages/theme-entry/<id>/[...path].astro, copied from Bloom’s. - Run
npm run themes:checkand check every template in the browser in both languages.