Storefront Service

storefront-service is the SSR rendering engine for every store. It owns all layout data — theme files, section trees, Lua resolvers — and produces fully rendered HTML for every storefront request. It has no dependency on a client-side rendering framework. Dynamic interactions use HTML fragment endpoints consumed by Hotwire Turbo or htmx, so the browser always holds server-rendered HTML regardless of whether an interaction is a full navigation or a partial update.

Rendering Model#

Every page is rendered server-side and delivered as complete HTML. There is no client-side hydration step and no virtual DOM.

Fragment endpoints return small HTML snippets for dynamic interactions (cart drawer updates, live search, form submissions). The browser applies them via Hotwire Turbo or htmx without a full page reload.

Template Engine#

All templates are written in Pebble (quarkus-pebble extension). Pebble uses a Liquid-like syntax that is intentionally familiar to store operators coming from Shopify — existing Liquid knowledge transfers directly.

Every template executes inside a PebbleSafePolicy sandbox. The policy removes filesystem access, Java reflection, and arbitrary Java method calls. Template authors work entirely within the Pebble expression language and cannot reach the JVM.

Sections and Blocks#

Page Composition#

A page is an ordered list of Sections. Each section carries:

FieldDescription
typeMaps to a Pebble template file and a Java DataResolver
settingsJSON blob of user-configured values (colours, text, toggles)
blocksOptional ordered list of sub-components within the section

Each Block is a sub-component within a section with its own type and settings blob. Blocks are rendered inside the parent section’s template.

Variable Cascade#

Variables flow down the following chain at render time:

theme.settings → page → section.settings / section.data → block.settings

The Pebble | default: filter drives fallback at each level. A block template can read section.settings, section.data, and theme.settings directly. Blocks cannot read the settings or data of sibling sections.

Schema Files#

Every section type and block type ships with a settings JSON schema. The schema defines which settings keys exist, their types, default values, and the UI controls Easy CMS renders for them. It is the contract between the template author and the editor.

Dynamic Data#

Sections that need live data declare a data_source setting in their schema. At render time, storefront-service invokes the DataResolver registered for that section type. The resolver makes calls — typically via Catalog gRPC or another internal service — and injects the result into section.data before Pebble renders the template.

All DataResolver calls are server-side and happen synchronously at render time. There is no client-side data fetching for section data.

Themes#

Structure#

Themes are versioned bundles of template files, schema files, and static assets. The platform ships base themes. Stores can override any file in a base theme without forking it. All store overrides go through the sandbox asset pipeline before going live.

Resolution Order#

store override → base theme → platform default

When resolving a template file, storefront-service checks the store’s override layer first, then the base theme, then the platform default. A store can change any single template without touching the rest of the theme.

Easy CMS and Code Coexistence#

ModeWritesReads
Easy CMSSection tree JSON (section order + settings values)Schema files — to know which controls to render
Code editorTemplate .pebble files and schema .json files

The two write paths are fully segregated. Easy CMS never writes template files. The code editor never writes section tree JSON. Neither path can corrupt the other.

Sandbox Integration#

storefront-service is a sandbox participant. When a request carries a valid signed preview token, the service reads from the sandbox asset layer instead of the live published layer — templates, schemas, Lua resolvers, section trees, and static assets. The resulting preview URL is shareable with any stakeholder who has the token, without requiring a platform login.

At sandbox publish time, content-service calls storefront-service via the SandboxParticipant gRPC interface to apply or roll back theme asset overrides and section tree changes atomically.

Store-Level Custom Code#

{% fetch %} Tag#

The {% fetch %} tag is a Pebble template extension for simple outbound HTTP. It makes a GET request to an endpoint on the store’s HTTP allowlist and binds the response as a template variable. It has no conditionals, no loops, and no side effects — the right tool when a store needs to pull a small piece of external data (e.g. a loyalty point balance) into a template without writing a full Lua resolver.

Lua Resolvers#

For business logic that requires branching, session context, or composition of multiple data sources, stores write Lua resolvers executed via LuaJ (pure JVM — no native Lua process).

The execution environment is stripped:

  • io, os, debug, package globals removed
  • Instruction count capped per invocation

Two built-in functions are available:

FunctionDescription
fetch(url)Outbound HTTP GET to an allowlisted endpoint
resolve(name)Call another named resolver in the same store

Resolver output is injected into section.data and rendered by Pebble exactly like DataResolver output. Circular call graphs are rejected at save time — the platform performs a cycle check when the resolver is published.

All resolver files are authored in the Monaco in-app editor and go through the sandbox asset pipeline before going live.

Client-Side JavaScript#

Store-level JavaScript runs in the browser. There is no build step and no bundler — files are raw ES modules served from content-service.

File Structure#

FileScope
assets/store.jsRuns on every page of the store
sections/<type>/section.jsRuns only when that section type is present on the page

SS3 Event Bus#

The platform injects a global SS3 event bus for cross-section communication:

SS3.on('cart:updated', (payload) => { /* ... */ });
SS3.emit('cart:updated', { count: 3 });
SS3.off('cart:updated', handler);

Lifecycle Hooks#

HookCalled when
onLoad(container)Section enters the DOM
onUnload(container)Section is removed from the DOM
onBlockSelect(block)A block is selected in Easy CMS
onBlockDeselect(block)A block is deselected in Easy CMS

Security Constraints#

CSP blocks eval and inline scripts. External script domains are on a per-store allowlist managed by the store admin.

Custom Domain Routing#

Each store can serve its storefront from one or more custom domains managed by store-service. At runtime, the gateway resolves the incoming Host header to a store by calling store-service’s GetStoreByDomain gRPC method, then injects the resolved X-Store-Id header before forwarding to storefront-service. No additional lookup is required in storefront-service — it receives the store context through the standard header pipeline.

Provisioning Flow#

1. Admin adds domain via store-service: POST /stores/{id}/domains
2. store-service creates the store_domain record (ssl_status: PENDING)
3. store-service requests an ACM certificate for the domain
4. ACM issues a DNS CNAME validation record
5. store-service returns the CNAME to the admin for DNS configuration
6. ACM validates the domain and issues the certificate (ssl_status: ACTIVE)
7. CloudFront distribution is updated to serve the domain with the ACM cert
8. Incoming traffic on the custom domain is routed through the standard
   gateway → storefront-service pipeline with X-Store-Id injected

Domain Resolution Cache#

The gateway caches the domain → store_id mapping with a 60-second TTL. On cache miss it calls GetStoreByDomain. When a domain is removed from store-service, the gateway cache expires within the TTL window — no active invalidation.

Schema#

CREATE TABLE base_theme (
  id          UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  name        VARCHAR(128) NOT NULL,
  version     VARCHAR(32)  NOT NULL,
  created_at  TIMESTAMPTZ  NOT NULL DEFAULT now()
);

CREATE TABLE store_theme (
  id             UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  store_id       UUID         NOT NULL UNIQUE,
  base_theme_id  UUID         NOT NULL REFERENCES base_theme(id),
  name           VARCHAR(128) NOT NULL,
  created_at     TIMESTAMPTZ  NOT NULL DEFAULT now(),
  updated_at     TIMESTAMPTZ  NOT NULL DEFAULT now()
);

-- Template files (.pebble), schema files (.json), theme settings
-- Store overrides shadow base_theme files at the same path
CREATE TABLE theme_asset (
  id           UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  theme_id     UUID         NOT NULL REFERENCES store_theme(id) ON DELETE CASCADE,
  store_id     UUID         NOT NULL,
  path         TEXT         NOT NULL,
  content      TEXT         NOT NULL,
  content_type VARCHAR(64)  NOT NULL,
  updated_at   TIMESTAMPTZ  NOT NULL DEFAULT now(),
  UNIQUE (theme_id, path)
);

CREATE TABLE storefront_page (
  id          UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  store_id    UUID         NOT NULL,
  slug        TEXT         NOT NULL,
  title       VARCHAR(512),
  created_at  TIMESTAMPTZ  NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ  NOT NULL DEFAULT now(),
  UNIQUE (store_id, slug)
);

CREATE TABLE page_section (
  id          UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  page_id     UUID         NOT NULL REFERENCES storefront_page(id) ON DELETE CASCADE,
  store_id    UUID         NOT NULL,
  type        VARCHAR(128) NOT NULL,
  settings    JSONB        NOT NULL DEFAULT '{}',
  position    INT          NOT NULL,
  UNIQUE (page_id, position)
);

CREATE TABLE section_block (
  id          UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  section_id  UUID         NOT NULL REFERENCES page_section(id) ON DELETE CASCADE,
  store_id    UUID         NOT NULL,
  type        VARCHAR(128) NOT NULL,
  settings    JSONB        NOT NULL DEFAULT '{}',
  position    INT          NOT NULL,
  UNIQUE (section_id, position)
);

CREATE TABLE lua_resolver (
  id          UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  store_id    UUID         NOT NULL,
  name        VARCHAR(128) NOT NULL,
  source      TEXT         NOT NULL,
  created_at  TIMESTAMPTZ  NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ  NOT NULL DEFAULT now(),
  UNIQUE (store_id, name)
);

REST API#

Theme Management#

MethodRouteDescription
GET/storefront/themeGet active store theme
GET/storefront/theme/assetsList theme assets
GET/storefront/theme/assets/{path}Get asset content
PUT/storefront/theme/assets/{path}Create or update asset
DELETE/storefront/theme/assets/{path}Delete store override (reverts to base)
POST/storefront/theme/resetReset all store overrides to base theme

Pages#

MethodRouteDescription
GET/storefront/pagesList pages
POST/storefront/pagesCreate page
GET/storefront/pages/{id}/sectionsGet section tree
PUT/storefront/pages/{id}/sectionsReplace full section tree
PATCH/storefront/pages/{id}/sections/{sectionId}Update section settings

Lua Resolvers#

MethodRouteDescription
GET/storefront/resolversList resolvers
POST/storefront/resolversCreate resolver
PATCH/storefront/resolvers/{id}Update resolver source
DELETE/storefront/resolvers/{id}Delete resolver

gRPC Interface#

service SandboxParticipant {
  rpc ApplySandbox(ApplySandboxRequest) returns (ApplySandboxResponse);
  rpc RollbackSandbox(RollbackSandboxRequest) returns (RollbackSandboxResponse);
}

Kafka Events Consumed#

TopicSourceAction
store.provisionedstore-serviceSeed default theme and homepage section tree
content.sandbox.publishedcontent-serviceInvalidate page render cache for the store
store.settings_updatedstore-serviceInvalidate cached store settings

Vault Configuration#

Key pathPurpose
ss3/kv/storefront-service/db.urlPostgreSQL JDBC URL
ss3/kv/storefront-service/db.usernameDatabase username
ss3/kv/storefront-service/db.passwordDatabase password
ss3/kv/storefront-service/redis.urlRedis URL for page render cache
ss3/kv/shared/Shared Kafka bootstrap and OTel config