Skip to content

Storefront runtime and themes

Shop storefronts are rendered by an Astro server in storefront/ (ADR-013). It reads everything through the Storefront API and renders the theme the shop chose in Settings → Storefront. It holds no database connection and no secrets except one shared key used for rate limiting.

visitor ── {slug}.flowery.com.ua ── nginx ─┬─ /control, sign-in, /storefront-api/v1, /build, /storage, /up, /release ─► Laravel
or www.shop.ua (ADR-022) └─ everything else ─► Astro (Node) ── API calls over loopback ─► Laravel

In production, nginx routes only the storefront pages of shop hosts (subdomains and shops’ own domains) to the Node server and leaves every other path with Laravel. If the Node server is down (a deploy restart), Laravel answers those paths with a short “back in a moment” page carrying the shop’s phone (production cutover). The previous Laravel storefront was retired on 5 October 2026.

src/middleware.ts is the runtime. Themes never see it.

  1. Shop. The Host header must be a well-formed {slug}.{STOREFRONT_DOMAIN} or, for a shop on its own domain (ADR-022), any well-formed hostname; the platform’s root and www are never shops. The host is sent to the API as is, and a host no shop has is the API’s 404 (shown as the plain 404 page). The runtime still cannot be pointed at another server: API calls go to STOREFRONT_API_ADDRESS and, with STOREFRONT_API_SNI set, present and verify that one TLS name (flowery.com.ua in production, covered by the server’s certificate) while the Host header stays the shop’s. Without it, TLS verifies the shop host itself, which only works for subdomains.

  2. Runtime paths. The runtime handles a few paths itself:

    • /__storefront/release reports the commit the build came from.
    • /__storefront/language?to=uk|en&back=/path sets the locale cookie, which the control panel shares, and returns only to a same-site path.
    • /__storefront/exit-preview ends a preview.
    • /create-bouquet redirects to /builder.
  3. Preview. A link with ?preview=<token> moves the token into an httpOnly, host-only cookie for an hour and redirects to the clean URL, so the token does not linger in history or referrers. When the API says the token is no longer valid, the runtime deletes the cookie.

  4. Configuration. GET /shop, with the preview token when present. If the shop is unknown the visitor gets a plain 404 page; if the API is unreachable, a 503 page with Retry-After. One address. The answer names the shop’s address (domain: its own domain when live, otherwise the subdomain). A visit on any other host that reaches the same shop is answered with a 301 to that address, same path and query, cacheable for an hour so a later change of address is picked up. Previews are exempt: the preview cookie is host-only and members preview on the subdomain.

  5. Language. The visitor’s locale cookie, otherwise the shop’s default language, which is Ukrainian unless the shop chose English in its profile.

  6. Route. The URL scheme belongs to the runtime, so links keep working when a shop switches themes:

    Path Template
    / home
    /catalogue catalogue (query: category, occasion, q, sort, available, page)
    /bouquets/{id} bouquet
    /flowers/{id} flower
    /builder builder
    /cart cart
    /checkout checkout (checkout)
    /orders/{40-character token} order, the private status page
    anything else not-found (status 404)

    Opening soon. While GET /shop says open: false (the shop has no active catalogue bouquet and no active flower), every page shows a static “opening soon” page instead of the theme (src/runtime/coming-soon.ts). It is in the visitor’s language and shows the shop’s name, phone (a tel: link), address and opening hours, all escaped. It is not indexed (noindex) and has its own strict CSP. Order status pages and previews still render the theme, so earlier customers can follow their orders and the owner can see the design before adding products.

    Astro lets only the page, not a theme’s template, set the status before the response streams. So the runtime checks the bouquet, flower or order a route names first (runtime/records.ts) and answers 404 when it is missing. Until 6 October 2026 a missing bouquet or flower showed the not-found content with status 200 (found while testing the status page). A status link is fetched once per request and shared with the template.

  7. Theme. The runtime rewrites the request internally to /theme-entry/<theme>/…, the theme’s own entry page, which is not reachable directly. Each theme therefore has its own CSS bundle. A configured theme that this runtime lacks falls back to Bloom.

  8. Headers.

    • Every page: Cache-Control: no-cache, X-Content-Type-Options, Referrer-Policy, X-Frame-Options and Permissions-Policy.
    • Previews: private, no-store and X-Robots-Tag: noindex.
    • Checkout and status pages: private, no-store, X-Robots-Tag: noindex, nofollow and Referrer-Policy: no-referrer, since a status page’s address carries its token. The runtime never keeps a status answer in memory.

A theme’s Theme.astro receives one ThemeContext (from @flowery/storefront-sdk):

  • shop: name, currency, profile and theme configuration.
  • theme: the active theme’s settings and enabled sections, with images resolved to {url, alt}.
  • locale and preview.
  • route: template, parameters, path and the allowed query keys.
  • api: the Storefront API client, bound to this shop.
  • origin, for canonical and Open Graph URLs: the shop’s one address (in a preview, the host the preview was opened on).
  • links: language switch and exit preview.

Islands (React components hydrated in the browser) use the same SDK against the same-origin API. The cart cookie rides along, and every change returns the whole cart priced by the server.

  • Catalogue and shop answers. The runtime keeps them in memory for 30 seconds per shop URL and language, at most 2,000 entries. Identical concurrent requests share one API call.
  • Not cached: the cart and previews.
  • After publishing, a new version shows within 30 seconds.
  • Rate limits. The runtime calls the API from the server. It sends the shared STOREFRONT_RUNTIME_KEY with the visitor’s address (the last X-Forwarded-For entry, which nginx appended), so Laravel’s rate limits apply per visitor. Without the key, Laravel ignores the forwarded address.

storefront/packages/sdk (@flowery/storefront-sdk) is the only package a theme imports:

  • API types, generated from docs/storefront-api/openapi.yaml (npm run sdk:generate). CI fails if the committed types differ from the contract.
  • A typed client. It throws ApiError with the status and field errors.
  • Exact money. Prices are summed as integer minor units (bigint), never floats, and formatted for the visitor’s language. The builder’s running total is an estimate; the server prices the bouquet again when it is added.
  • Helpers: paths, setting.* (typed reads of validated settings), occasionLabel, and createTranslator for a theme’s own strings, including plurals (t.choice('stems', 5), with the forms in each language’s order, for example Ukrainian one|few|many).
  • React hooks:
    • useCart: one cart shared by every island on the page, with loading for the first read and saving for changes.
    • useBuilder: stems within each flower’s max_per_bouquet, with a functional step() so quick clicks all count; packaging; the estimate.
    • useCartPanel, openCartPanel and closeCartPanel: whether the side cart is open, shared by every island, so an add button can open the theme’s cart drawer.
    • useModalDialog(open, onClose): a native modal <dialog> with focus kept inside, a closed scroll and an immediate close from Escape, the backdrop or a button. Used by menus and side carts.
    • useDisclosure(): the WAI-ARIA disclosure pattern for navigation panels, closing on Escape (focus returns to the button) and on a click or focus outside.
storefront/themes/<id>/
theme.json manifest: settings DSL, section types, defaults (read by Laravel too)
Theme.astro entry: picks the template for context.route.template
templates/ home, catalogue, bouquet, flower, builder, cart, checkout, order, not-found
sections/ … the theme's own structure
islands/*.tsx interactive parts, using the SDK hooks
locales/{uk,en}.json
styles.css Tailwind scoped to the theme's own files

Boundary. npm run themes:check (scripts/check-themes.mjs, also in CI and scripts/check-all) refuses:

  • imports other than the SDK, React, font packages and the theme’s own files;
  • reading the request, cookies, environment or process;
  • set:html or other raw HTML or code injection;
  • inline scripts, network calls of the theme’s own, and outside addresses.

Content Security Policy.

  • Scripts run only from the storefront itself; Astro’s hydration scripts are allowed by hash.
  • Styles come from the theme’s own stylesheet. Style attributes are allowed because themes set validated colours that way.
  • Images may come from the storefront or any https host; product photos live on the platform’s storage host.
  • Fonts come from the storefront, connections only to the storefront, and forms submit only to the storefront.
Bloom Noir
Look Paper and ink, Piazzolla with Commissioner (or Commissioner only), hairlines, 4:5 photography (design) Warm near-black, Prata with condensed Sofia Sans labels, optional uppercase headings, 3:4 photography
Home sections Hero, categories, new arrivals, featured (×3), occasions, build-your-own, about, contacts, text (×4) Hero, featured (×2), new arrivals, contacts
Catalogue Category tabs, occasion and order menus (links), in-stock toggle, search, pages Category and sort links, pages
Builder A list of stems beside an order slip with a colour bar; a bottom bar on phones A list with steppers

Honest content, as in the honesty hotfix:

  • Each block appears only when its data exists: no contacts without a phone, address, hours or links; no builder prompt without stems today.
  • A product with no photo is a label card: a paper tone kept per product, its catalogue number, name and flowers.
  • Availability is the API’s label, never a number.
  • The cart says online ordering is not open yet, and lists the shop’s own contacts when it has them. Stage 3A brings checkout.